@intentius/chant-lexicon-k8s 0.1.0 → 0.1.5

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 (54) hide show
  1. package/dist/integrity.json +42 -39
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +121 -0
  4. package/dist/rules/k8s-helpers.ts +39 -0
  5. package/dist/rules/wk8401.ts +98 -0
  6. package/dist/rules/wk8402.ts +43 -0
  7. package/dist/rules/wk8403.ts +60 -0
  8. package/dist/types/index.d.ts +30 -0
  9. package/package.json +11 -7
  10. package/src/actions/actions.test.ts +1 -1
  11. package/src/codegen/generate-cli.ts +1 -1
  12. package/src/codegen/generate.ts +22 -0
  13. package/src/codegen/naming.test.ts +1 -1
  14. package/src/codegen/package.ts +2 -5
  15. package/src/codegen/snapshot.test.ts +1 -1
  16. package/src/codegen/typecheck.test.ts +1 -1
  17. package/src/composites/cockroachdb-region-stack.ts +553 -0
  18. package/src/composites/composites.test.ts +4 -4
  19. package/src/composites/index.ts +8 -0
  20. package/src/composites/ray-cluster.ts +590 -0
  21. package/src/composites/ray-job.ts +235 -0
  22. package/src/composites/ray-service.ts +271 -0
  23. package/src/coverage.test.ts +1 -1
  24. package/src/crd/crd-sources.ts +29 -0
  25. package/src/crd/loader.ts +13 -21
  26. package/src/crd/parser.test.ts +1 -1
  27. package/src/crd/parser.ts +17 -12
  28. package/src/default-labels.test.ts +1 -1
  29. package/src/generated/index.d.ts +30 -0
  30. package/src/generated/index.ts +13 -0
  31. package/src/generated/lexicon-k8s.json +121 -0
  32. package/src/import/generator.test.ts +1 -1
  33. package/src/import/parser.test.ts +1 -1
  34. package/src/import/roundtrip.test.ts +1 -1
  35. package/src/index.ts +4 -0
  36. package/src/lint/post-synth/k8s-helpers.test.ts +1 -1
  37. package/src/lint/post-synth/k8s-helpers.ts +39 -0
  38. package/src/lint/post-synth/post-synth.test.ts +149 -1
  39. package/src/lint/post-synth/wk8401.ts +98 -0
  40. package/src/lint/post-synth/wk8402.ts +43 -0
  41. package/src/lint/post-synth/wk8403.ts +60 -0
  42. package/src/lint/rules/rules.test.ts +1 -1
  43. package/src/lsp/completions.test.ts +1 -1
  44. package/src/lsp/hover.test.ts +1 -1
  45. package/src/package-cli.ts +1 -1
  46. package/src/plugin.test.ts +3 -3
  47. package/src/plugin.ts +7 -9
  48. package/src/serializer.test.ts +1 -1
  49. package/src/serializer.ts +2 -0
  50. package/src/skills/chant-k8s-ray.md +252 -0
  51. package/src/spec/fetch.test.ts +1 -1
  52. package/src/spec/parse.test.ts +1 -1
  53. package/src/validate-cli.ts +1 -1
  54. package/src/validate.test.ts +1 -1
@@ -0,0 +1,60 @@
1
+ /**
2
+ * WK8403: spec.rayVersion does not match head image tag
3
+ *
4
+ * When spec.rayVersion is set but doesn't match the version in the head
5
+ * container image tag, the KubeRay autoscaler sidecar will run a different
6
+ * Ray version than the cluster. This can cause gRPC compatibility failures.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { getPrimaryOutput, parseK8sManifests, extractRayVersion } from "./k8s-helpers";
11
+
12
+ export const wk8403: PostSynthCheck = {
13
+ id: "WK8403",
14
+ description: "spec.rayVersion should match the Ray version in the head container image tag",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const [, output] of ctx.outputs) {
20
+ const yaml = getPrimaryOutput(output);
21
+ const manifests = parseK8sManifests(yaml);
22
+
23
+ for (const manifest of manifests) {
24
+ if (manifest.kind !== "RayCluster") continue;
25
+
26
+ const clusterName = manifest.metadata?.name ?? "RayCluster";
27
+ const spec = manifest.spec as Record<string, unknown> | undefined;
28
+ if (!spec) continue;
29
+
30
+ const rayVersion = spec.rayVersion as string | undefined;
31
+ if (!rayVersion) continue; // WK8402 covers the missing case
32
+
33
+ // Extract version from head container image
34
+ const headGroupSpec = spec.headGroupSpec as Record<string, unknown> | undefined;
35
+ const tmpl = headGroupSpec?.template as Record<string, unknown> | undefined;
36
+ const podSpec = tmpl?.spec as Record<string, unknown> | undefined;
37
+ const containers = podSpec?.containers as Array<Record<string, unknown>> | undefined;
38
+ if (!Array.isArray(containers) || containers.length === 0) continue;
39
+
40
+ const image = containers[0].image as string | undefined;
41
+ if (!image) continue;
42
+
43
+ const imageVersion = extractRayVersion(image);
44
+ if (!imageVersion) continue; // Can't determine version from tag
45
+
46
+ if (imageVersion !== rayVersion) {
47
+ diagnostics.push({
48
+ checkId: "WK8403",
49
+ severity: "warning",
50
+ message: `RayCluster "${clusterName}": spec.rayVersion "${rayVersion}" does not match head image tag "${imageVersion}" — autoscaler may run a mismatched Ray version`,
51
+ entity: clusterName,
52
+ lexicon: "k8s",
53
+ });
54
+ }
55
+ }
56
+ }
57
+
58
+ return diagnostics;
59
+ },
60
+ };
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import { hardcodedNamespaceRule } from "./hardcoded-namespace";
3
3
  import { latestImageTagRule } from "./latest-image-tag";
4
4
  import { missingResourceLimitsRule } from "./missing-resource-limits";
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import { existsSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import { existsSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env tsx
2
2
  /**
3
3
  * CLI entry point for Kubernetes lexicon packaging.
4
4
  */
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import { existsSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
@@ -30,10 +30,10 @@ describe("k8sPlugin", () => {
30
30
  expect(rules.some((r) => r.id === "WK8001")).toBe(true);
31
31
  });
32
32
 
33
- test("postSynthChecks() returns array of 22 checks", () => {
33
+ test("postSynthChecks() returns array of post-synth checks", () => {
34
34
  const checks = k8sPlugin.postSynthChecks!();
35
35
  expect(Array.isArray(checks)).toBe(true);
36
- expect(checks.length).toBe(23);
36
+ expect(checks.length).toBe(26);
37
37
  });
38
38
 
39
39
  test("intrinsics() returns empty array", () => {
package/src/plugin.ts CHANGED
@@ -5,24 +5,26 @@
5
5
  * lint rules, and LSP/MCP integration for Kubernetes manifests.
6
6
  */
7
7
 
8
- import { createRequire } from "module";
9
8
  import type { LexiconPlugin, InitTemplateSet, ResourceMetadata } from "@intentius/chant/lexicon";
10
- const require = createRequire(import.meta.url);
11
9
  import type { LintRule } from "@intentius/chant/lint/rule";
12
10
  import { discoverPostSynthChecks } from "@intentius/chant/lint/discover";
13
11
  import { createSkillsLoader, createDiffTool, createCatalogResource } from "@intentius/chant/lexicon-plugin-helpers";
14
12
  import { join, dirname } from "path";
15
13
  import { fileURLToPath } from "url";
16
14
  import { k8sSerializer } from "./serializer";
15
+ import { hardcodedNamespaceRule } from "./lint/rules/hardcoded-namespace";
16
+ import { latestImageTagRule } from "./lint/rules/latest-image-tag";
17
+ import { missingResourceLimitsRule } from "./lint/rules/missing-resource-limits";
18
+ import { k8sCompletions } from "./lsp/completions";
19
+ import { k8sHover } from "./lsp/hover";
20
+ import { K8sParser } from "./import/parser";
21
+ import { K8sGenerator } from "./import/generator";
17
22
 
18
23
  export const k8sPlugin: LexiconPlugin = {
19
24
  name: "k8s",
20
25
  serializer: k8sSerializer,
21
26
 
22
27
  lintRules(): LintRule[] {
23
- const { hardcodedNamespaceRule } = require("./lint/rules/hardcoded-namespace");
24
- const { latestImageTagRule } = require("./lint/rules/latest-image-tag");
25
- const { missingResourceLimitsRule } = require("./lint/rules/missing-resource-limits");
26
28
  return [hardcodedNamespaceRule, latestImageTagRule, missingResourceLimitsRule];
27
29
  },
28
30
 
@@ -204,22 +206,18 @@ export const service = new Service({
204
206
  },
205
207
 
206
208
  completionProvider(ctx: import("@intentius/chant/lsp/types").CompletionContext) {
207
- const { k8sCompletions } = require("./lsp/completions");
208
209
  return k8sCompletions(ctx);
209
210
  },
210
211
 
211
212
  hoverProvider(ctx: import("@intentius/chant/lsp/types").HoverContext) {
212
- const { k8sHover } = require("./lsp/hover");
213
213
  return k8sHover(ctx);
214
214
  },
215
215
 
216
216
  templateParser() {
217
- const { K8sParser } = require("./import/parser");
218
217
  return new K8sParser();
219
218
  },
220
219
 
221
220
  templateGenerator() {
222
- const { K8sGenerator } = require("./import/generator");
223
221
  return new K8sGenerator();
224
222
  },
225
223
 
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import { k8sSerializer } from "./serializer";
3
3
  import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
4
4
  import {
package/src/serializer.ts CHANGED
@@ -95,6 +95,8 @@ const API_GROUP_VERSIONS: Record<string, string> = {
95
95
  CertManager: "cert-manager.io/v1",
96
96
  ExternalSecrets: "external-secrets.io/v1",
97
97
  Monitoring: "monitoring.coreos.com/v1",
98
+ // KubeRay operator CRDs
99
+ Ray: "ray.io/v1",
98
100
  };
99
101
 
100
102
  function deriveGVKFromType(entityType: string): { apiVersion: string; kind: string } | null {
@@ -0,0 +1,252 @@
1
+ ---
2
+ skill: chant-k8s-ray
3
+ description: KubeRay composites for distributed Ray clusters on Kubernetes — RayCluster, RayJob, RayService
4
+ user-invocable: true
5
+ ---
6
+
7
+ # KubeRay Composites
8
+
9
+ Three composites cover the full KubeRay surface: persistent clusters, ephemeral batch jobs, and Ray Serve HTTP endpoints.
10
+
11
+ ## Prerequisites
12
+
13
+ KubeRay operator must be installed before applying any Ray CRs:
14
+
15
+ ```bash
16
+ kubectl apply -f https://github.com/ray-project/kuberay/releases/download/v1.3.0/kuberay-operator.yaml
17
+ kubectl -n kuberay-operator wait deploy/kuberay-operator --for=condition=Available --timeout=120s
18
+ ```
19
+
20
+ ## When to use which composite
21
+
22
+ | Composite | Use case |
23
+ |---|---|
24
+ | `RayCluster` | Interactive dev, long-lived infra, jobs submitted via CLI / Ray client |
25
+ | `RayJob` | Training pipelines, batch jobs — spins up → runs → tears down |
26
+ | `RayService` | Ray Serve HTTP endpoints with zero-downtime blue-green upgrades |
27
+
28
+ ---
29
+
30
+ ## RayCluster — persistent cluster
31
+
32
+ ```typescript
33
+ import { RayCluster } from "@intentius/chant-lexicon-k8s";
34
+
35
+ export const {
36
+ serviceAccount,
37
+ clusterRole, // only when enableAutoscaler: true
38
+ clusterRoleBinding, // only when enableAutoscaler: true
39
+ networkPolicy,
40
+ pdb,
41
+ pvc, // only when sharedStorage is set
42
+ dashboardService, // only when exposeDashboard: true
43
+ rayCluster,
44
+ } = RayCluster({
45
+ name: "ray",
46
+ namespace: "ray-system",
47
+ cluster: {
48
+ image: "us-central1-docker.pkg.dev/my-project/ray-images/ray:2.40.0",
49
+ head: {
50
+ resources: { cpu: "2", memory: "8Gi" },
51
+ shmSize: "4Gi", // /dev/shm for PyTorch multi-process tensor sharing
52
+ },
53
+ workerGroups: [
54
+ {
55
+ groupName: "cpu",
56
+ replicas: 2,
57
+ minReplicas: 1,
58
+ maxReplicas: 8,
59
+ resources: { cpu: "2", memory: "4Gi" },
60
+ idleTimeoutSeconds: 60,
61
+ },
62
+ {
63
+ groupName: "gpu",
64
+ replicas: 0,
65
+ minReplicas: 0,
66
+ maxReplicas: 4,
67
+ resources: { cpu: "4", memory: "16Gi", gpu: 1 },
68
+ gpuTolerations: true,
69
+ idleTimeoutSeconds: 300, // higher — amortize GPU init overhead
70
+ },
71
+ ],
72
+ },
73
+ sharedStorage: {
74
+ storageClass: "ray-filestore",
75
+ size: "1Ti",
76
+ mountPath: "/mnt/ray-data", // mounted on all pods (head + all workers)
77
+ },
78
+ spilloverBucket: "ray-spill", // GCS bucket for object store overflow
79
+ enableAutoscaler: true,
80
+ exposeDashboard: false, // use kubectl port-forward 8265 in dev
81
+ });
82
+ ```
83
+
84
+ **Key props:**
85
+
86
+ | Prop | Type | Description |
87
+ |---|---|---|
88
+ | `name` | `string` | Resource name prefix |
89
+ | `namespace` | `string` | Kubernetes namespace |
90
+ | `cluster.image` | `string` | Ray Docker image (pre-built recommended) |
91
+ | `cluster.head.resources` | `ResourceSpec` | CPU/memory for the head pod |
92
+ | `cluster.head.shmSize` | `string?` | Size of /dev/shm emptyDir (default: `"2Gi"`) |
93
+ | `cluster.workerGroups` | `WorkerGroupSpec[]` | One entry per worker group |
94
+ | `sharedStorage` | `object?` | PVC + volume mounts on all pods |
95
+ | `spilloverBucket` | `string?` | GCS bucket for Ray object store spillover |
96
+ | `enableAutoscaler` | `boolean?` | Emit ClusterRole/CRB for in-tree autoscaler |
97
+ | `exposeDashboard` | `boolean?` | Emit LoadBalancer Service for port 8265 |
98
+ | `labels` | `Record<string, string>?` | Extra labels on all resources |
99
+ | `defaults` | `object?` | Deep-merge overrides onto any generated resource |
100
+
101
+ ---
102
+
103
+ ## RayJob — ephemeral cluster per batch job
104
+
105
+ ```typescript
106
+ import { RayJob } from "@intentius/chant-lexicon-k8s";
107
+
108
+ export const { serviceAccount, networkPolicy, pvc, rayJob } = RayJob({
109
+ name: "train-job",
110
+ namespace: "ray-system",
111
+ entrypoint: "python train.py --epochs 10",
112
+ cluster: {
113
+ image: "us-central1-docker.pkg.dev/my-project/ray-images/ray:2.40.0",
114
+ head: { resources: { cpu: "2", memory: "8Gi" } },
115
+ workerGroups: [
116
+ { groupName: "cpu", replicas: 4, resources: { cpu: "4", memory: "16Gi" } },
117
+ ],
118
+ },
119
+ shutdownAfterJobFinishes: true, // default: true — cluster tears down after job
120
+ ttlSecondsAfterFinished: 300, // default: 300 — delay before RayJob CR is deleted
121
+ runtimeEnvYAML: "pip:\n - torch==2.3.0",
122
+ spilloverBucket: "ray-spill",
123
+ });
124
+ ```
125
+
126
+ **Key props:**
127
+
128
+ | Prop | Type | Description |
129
+ |---|---|---|
130
+ | `entrypoint` | `string` | Shell command to run as the Ray job |
131
+ | `runtimeEnvYAML` | `string?` | Ray runtime env YAML (pip packages, env vars, working_dir) |
132
+ | `shutdownAfterJobFinishes` | `boolean?` | Tear down cluster after job completes (default: `true`) |
133
+ | `ttlSecondsAfterFinished` | `number?` | Seconds before deleting the RayJob CR (default: `300`) |
134
+
135
+ All other props (`cluster`, `sharedStorage`, `spilloverBucket`, `enableAutoscaler`) work the same as `RayCluster`.
136
+
137
+ ---
138
+
139
+ ## RayService — persistent Ray Serve endpoint
140
+
141
+ ```typescript
142
+ import { RayService } from "@intentius/chant-lexicon-k8s";
143
+
144
+ export const {
145
+ serviceAccount, networkPolicy, pdb, pvc,
146
+ serveService, // LoadBalancer Service on port 8000
147
+ rayService,
148
+ } = RayService({
149
+ name: "inference",
150
+ namespace: "ray-system",
151
+ serveConfigV2: `
152
+ applications:
153
+ - name: classifier
154
+ import_path: app:deployment
155
+ route_prefix: /
156
+ deployments:
157
+ - name: Classifier
158
+ num_replicas: 2
159
+ ray_actor_options:
160
+ num_cpus: 1
161
+ `,
162
+ cluster: {
163
+ image: "us-central1-docker.pkg.dev/my-project/ray-images/ray:2.40.0",
164
+ head: { resources: { cpu: "2", memory: "8Gi" } },
165
+ workerGroups: [
166
+ { groupName: "serve", replicas: 2, minReplicas: 1, maxReplicas: 8,
167
+ resources: { cpu: "4", memory: "16Gi" } },
168
+ ],
169
+ },
170
+ enableAutoscaler: true,
171
+ });
172
+ // Access: kubectl port-forward svc/inference-serve-svc 8000:8000
173
+ ```
174
+
175
+ `serveService` is always emitted — a LoadBalancer Service on port 8000. To expose it via Ingress, add an annotation via `defaults.serveService`.
176
+
177
+ ---
178
+
179
+ ## Shared types
180
+
181
+ ```typescript
182
+ interface ResourceSpec {
183
+ cpu: string; // "2", "500m"
184
+ memory: string; // "4Gi", "512Mi"
185
+ gpu?: number; // adds nvidia.com/gpu resource limit
186
+ }
187
+
188
+ interface HeadGroupSpec {
189
+ resources: ResourceSpec;
190
+ shmSize?: string; // /dev/shm size, default "2Gi"
191
+ rayStartParams?: Record<string, string>; // extra ray start flags
192
+ env?: Array<{ name: string; value: string }>;
193
+ }
194
+
195
+ interface WorkerGroupSpec {
196
+ groupName: string;
197
+ replicas: number;
198
+ minReplicas?: number;
199
+ maxReplicas?: number;
200
+ resources: ResourceSpec;
201
+ idleTimeoutSeconds?: number; // default 60; use 300+ for GPU
202
+ gpuTolerations?: boolean; // tolerate nvidia.com/gpu taint
203
+ rayStartParams?: Record<string, string>;
204
+ env?: Array<{ name: string; value: string }>;
205
+ }
206
+ ```
207
+
208
+ ---
209
+
210
+ ## Production defaults (encoded in composites)
211
+
212
+ All three composites automatically apply these defaults — no manual configuration needed:
213
+
214
+ | Default | Why |
215
+ |---|---|
216
+ | `preStop: ["ray", "stop"]` + `terminationGracePeriodSeconds: 120` | Graceful drain on pod eviction; in-flight tasks complete rather than fail |
217
+ | `idleTimeoutSeconds: 60` (default) | Prevents stuck idle workers consuming resources |
218
+ | `--num-cpus` derived from `resources.cpu` | Prevents autoscaler over-commit; without this Ray reads host CPU count, not container limit |
219
+ | `RAY_object_spilling_config` env var | Routes large object spills to GCS; without this, large models or shuffled datasets OOM the head |
220
+ | `shmSize` dshm emptyDir | PyTorch tensor sharing via /dev/shm; default 2Gi, set 4Gi+ for multi-process training |
221
+ | `gpuTolerations: true` | Adds `nvidia.com/gpu: present: NoSchedule` toleration; required for GPU node pools with standard taints |
222
+
223
+ ---
224
+
225
+ ## NetworkPolicy strategy
226
+
227
+ The composites emit a `NetworkPolicy` using `podSelector` only for intra-cluster rules — no IP CIDR blocks for Ray traffic. This avoids the GKE secondary IP range mismatch problem: GKE allocates pod CIDRs from secondary ranges that differ from declared subnet CIDRs, so CIDR-based NetworkPolicy rules silently fail when pods move nodes.
228
+
229
+ GCS/HTTPS egress uses an ipBlock rule with RFC1918 ranges excluded — this allows Google APIs (storage.googleapis.com) while blocking internal lateral movement.
230
+
231
+ Ports covered: 6379 (GCS object store), 8265 (dashboard), 10001–10002 (Ray client), 8080 (metrics), 32768–60999 (ephemeral gRPC).
232
+
233
+ DNS egress (port 53 UDP/TCP) is always allowed — required for head service resolution.
234
+
235
+ ---
236
+
237
+ ## Troubleshooting
238
+
239
+ **Workers not joining the cluster**
240
+ Check the NetworkPolicy allows port 6379 from worker pods. The composite uses `ray.io/cluster-name: <name>` as the podSelector label — confirm this label is present on the pods (`kubectl get pods -n ray-system --show-labels`).
241
+
242
+ **Autoscaler not scaling up**
243
+ `enableAutoscaler: true` is required to emit the ClusterRole with pod CRUD permissions. Without it, the autoscaler controller cannot create or delete pods and will silently fail.
244
+
245
+ **GPU workers not scheduling**
246
+ Set `gpuTolerations: true` on the GPU worker group. Without the `nvidia.com/gpu: present: NoSchedule` toleration, pods won't schedule on GPU-tainted nodes. Also confirm the node pool taint key matches.
247
+
248
+ **Head OOM on large workloads**
249
+ Set `spilloverBucket` to a GCS bucket the head pod can write to. The head pod needs GCS access — use Workload Identity and bind the K8s ServiceAccount to a GCP SA with `roles/storage.objectAdmin` on the bucket. The composite injects `RAY_object_spilling_config` automatically when `spilloverBucket` is set.
250
+
251
+ **Pre-built images vs runtimeEnv pip installs**
252
+ Avoid `runtimeEnvYAML` pip installs in production. Each worker restart re-runs pip install, adding minutes to cold start at scale. Pre-build a Docker image with all dependencies baked in and push it to Artifact Registry.
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import { fetchK8sSchema, fetchSchemas, K8S_SCHEMA_VERSION } from "./fetch";
3
3
 
4
4
  describe("fetch", () => {
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import {
3
3
  gvkToTypeName,
4
4
  gvkToApiVersion,
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env tsx
2
2
  /**
3
3
  * CLI entry point for Kubernetes lexicon validation.
4
4
  */
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import { validate } from "./validate";
3
3
 
4
4
  describe("validate", () => {