@intentius/chant-lexicon-docker 0.1.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 (81) hide show
  1. package/README.md +24 -0
  2. package/dist/integrity.json +19 -0
  3. package/dist/manifest.json +15 -0
  4. package/dist/meta.json +222 -0
  5. package/dist/rules/apt-no-recommends.ts +43 -0
  6. package/dist/rules/docker-helpers.ts +114 -0
  7. package/dist/rules/no-latest-image.ts +36 -0
  8. package/dist/rules/no-latest-tag.ts +63 -0
  9. package/dist/rules/no-root-user.ts +36 -0
  10. package/dist/rules/prefer-copy.ts +53 -0
  11. package/dist/rules/ssh-port-exposed.ts +68 -0
  12. package/dist/rules/unused-volume.ts +49 -0
  13. package/dist/skills/chant-docker-patterns.md +153 -0
  14. package/dist/skills/chant-docker.md +129 -0
  15. package/dist/types/index.d.ts +93 -0
  16. package/package.json +53 -0
  17. package/src/codegen/docs-cli.ts +10 -0
  18. package/src/codegen/docs.ts +12 -0
  19. package/src/codegen/generate-cli.ts +36 -0
  20. package/src/codegen/generate-compose.ts +21 -0
  21. package/src/codegen/generate-dockerfile.ts +21 -0
  22. package/src/codegen/generate.test.ts +105 -0
  23. package/src/codegen/generate.ts +158 -0
  24. package/src/codegen/naming.test.ts +81 -0
  25. package/src/codegen/naming.ts +54 -0
  26. package/src/codegen/package.ts +65 -0
  27. package/src/codegen/patches.ts +42 -0
  28. package/src/codegen/versions.ts +15 -0
  29. package/src/composites/index.ts +12 -0
  30. package/src/coverage.test.ts +33 -0
  31. package/src/coverage.ts +54 -0
  32. package/src/default-labels.test.ts +85 -0
  33. package/src/default-labels.ts +72 -0
  34. package/src/generated/index.d.ts +93 -0
  35. package/src/generated/index.ts +10 -0
  36. package/src/generated/lexicon-docker.json +222 -0
  37. package/src/generated/runtime.ts +4 -0
  38. package/src/import/generator.test.ts +133 -0
  39. package/src/import/generator.ts +127 -0
  40. package/src/import/parser.test.ts +137 -0
  41. package/src/import/parser.ts +190 -0
  42. package/src/import/roundtrip.test.ts +49 -0
  43. package/src/import/testdata/full.yaml +43 -0
  44. package/src/import/testdata/simple.yaml +9 -0
  45. package/src/import/testdata/webapp.yaml +41 -0
  46. package/src/index.ts +29 -0
  47. package/src/interpolation.test.ts +41 -0
  48. package/src/interpolation.ts +76 -0
  49. package/src/lint/post-synth/apt-no-recommends.ts +43 -0
  50. package/src/lint/post-synth/docker-helpers.ts +114 -0
  51. package/src/lint/post-synth/no-latest-image.ts +36 -0
  52. package/src/lint/post-synth/no-root-user.ts +36 -0
  53. package/src/lint/post-synth/post-synth.test.ts +181 -0
  54. package/src/lint/post-synth/prefer-copy.ts +53 -0
  55. package/src/lint/post-synth/ssh-port-exposed.ts +68 -0
  56. package/src/lint/post-synth/unused-volume.ts +49 -0
  57. package/src/lint/rules/data/deprecated-images.ts +28 -0
  58. package/src/lint/rules/data/known-base-images.ts +20 -0
  59. package/src/lint/rules/index.ts +5 -0
  60. package/src/lint/rules/no-latest-tag.ts +63 -0
  61. package/src/lint/rules/rules.test.ts +82 -0
  62. package/src/lsp/completions.test.ts +34 -0
  63. package/src/lsp/completions.ts +20 -0
  64. package/src/lsp/hover.test.ts +34 -0
  65. package/src/lsp/hover.ts +38 -0
  66. package/src/package-cli.ts +42 -0
  67. package/src/plugin.test.ts +117 -0
  68. package/src/plugin.ts +250 -0
  69. package/src/serializer.test.ts +294 -0
  70. package/src/serializer.ts +322 -0
  71. package/src/skills/chant-docker-patterns.md +153 -0
  72. package/src/skills/chant-docker.md +129 -0
  73. package/src/spec/fetch-compose.ts +35 -0
  74. package/src/spec/fetch-engine.ts +25 -0
  75. package/src/spec/parse-compose.ts +110 -0
  76. package/src/spec/parse-engine.ts +47 -0
  77. package/src/validate-cli.ts +19 -0
  78. package/src/validate.test.ts +16 -0
  79. package/src/validate.ts +44 -0
  80. package/src/variables.test.ts +32 -0
  81. package/src/variables.ts +47 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Docker lexicon plugin.
3
+ *
4
+ * Provides serializer, lint rules, post-synth checks, intrinsics, LSP support,
5
+ * and code generation for Docker Compose and Dockerfile resources.
6
+ */
7
+
8
+ import { createRequire } from "module";
9
+ import type { LexiconPlugin, IntrinsicDef, InitTemplateSet } from "@intentius/chant/lexicon";
10
+ const require = createRequire(import.meta.url);
11
+ import type { LintRule } from "@intentius/chant/lint/rule";
12
+ import { discoverPostSynthChecks } from "@intentius/chant/lint/discover";
13
+ import { createSkillsLoader, createDiffTool, createCatalogResource } from "@intentius/chant/lexicon-plugin-helpers";
14
+ import { join, dirname } from "path";
15
+ import { fileURLToPath } from "url";
16
+ import { dockerSerializer } from "./serializer";
17
+
18
+ export const dockerPlugin: LexiconPlugin = {
19
+ name: "docker",
20
+ serializer: dockerSerializer,
21
+
22
+ lintRules(): LintRule[] {
23
+ const { noLatestTagRule } = require("./lint/rules/no-latest-tag");
24
+ return [noLatestTagRule];
25
+ },
26
+
27
+ postSynthChecks() {
28
+ const postSynthDir = join(dirname(fileURLToPath(import.meta.url)), "lint", "post-synth");
29
+ return discoverPostSynthChecks(postSynthDir, import.meta.url);
30
+ },
31
+
32
+ intrinsics(): IntrinsicDef[] {
33
+ return [
34
+ {
35
+ name: "env",
36
+ description: "Docker Compose variable interpolation — ${VAR}, ${VAR:-default}, ${VAR:?error}",
37
+ outputKey: "env",
38
+ isTag: false,
39
+ },
40
+ ];
41
+ },
42
+
43
+ initTemplates(template?: string): InitTemplateSet {
44
+ if (template === "webapp") {
45
+ return {
46
+ src: {
47
+ "compose.ts": `import { Service, Volume } from "@intentius/chant-lexicon-docker";
48
+
49
+ export const db = new Service({
50
+ image: "postgres:16-alpine",
51
+ environment: {
52
+ POSTGRES_DB: "myapp",
53
+ POSTGRES_USER: "myapp",
54
+ POSTGRES_PASSWORD: "secret",
55
+ },
56
+ volumes: ["pgdata:/var/lib/postgresql/data"],
57
+ });
58
+
59
+ export const pgdata = new Volume({});
60
+
61
+ export const api = new Service({
62
+ image: "myapp:1.0",
63
+ ports: ["8080:8080"],
64
+ depends_on: ["db"],
65
+ environment: {
66
+ DATABASE_URL: "postgresql://myapp:secret@db:5432/myapp",
67
+ },
68
+ });
69
+ `,
70
+ },
71
+ };
72
+ }
73
+
74
+ // Default template
75
+ return {
76
+ src: {
77
+ "compose.ts": `import { Service } from "@intentius/chant-lexicon-docker";
78
+
79
+ export const api = new Service({
80
+ image: "myapp:1.0",
81
+ ports: ["8080:8080"],
82
+ restart: "unless-stopped",
83
+ });
84
+ `,
85
+ },
86
+ };
87
+ },
88
+
89
+ detectTemplate(data: unknown): boolean {
90
+ if (typeof data !== "object" || data === null) return false;
91
+ const obj = data as Record<string, unknown>;
92
+ // Docker Compose files have a services: key
93
+ return "services" in obj;
94
+ },
95
+
96
+ completionProvider(ctx: import("@intentius/chant/lsp/types").CompletionContext) {
97
+ const { dockerCompletions } = require("./lsp/completions");
98
+ return dockerCompletions(ctx);
99
+ },
100
+
101
+ hoverProvider(ctx: import("@intentius/chant/lsp/types").HoverContext) {
102
+ const { dockerHover } = require("./lsp/hover");
103
+ return dockerHover(ctx);
104
+ },
105
+
106
+ templateParser() {
107
+ const { DockerParser } = require("./import/parser");
108
+ return new DockerParser();
109
+ },
110
+
111
+ templateGenerator() {
112
+ const { DockerGenerator } = require("./import/generator");
113
+ return new DockerGenerator();
114
+ },
115
+
116
+ async generate(options?: { verbose?: boolean }): Promise<void> {
117
+ const { generate, writeGeneratedFiles } = await import("./codegen/generate");
118
+ const { dirname: pathDirname } = await import("path");
119
+ const { fileURLToPath: urlToPath } = await import("url");
120
+
121
+ const result = await generate({ verbose: options?.verbose ?? true });
122
+ const pkgDir = pathDirname(pathDirname(urlToPath(import.meta.url)));
123
+ writeGeneratedFiles(result, pkgDir);
124
+
125
+ console.error(
126
+ `Generated ${result.resources} entities, ${result.properties} property types, ${result.enums} enums`,
127
+ );
128
+ if (result.warnings.length > 0) {
129
+ console.error(`${result.warnings.length} warnings`);
130
+ }
131
+ },
132
+
133
+ async validate(options?: { verbose?: boolean }): Promise<void> {
134
+ const { validate } = await import("./validate");
135
+ const { printValidationResult } = await import("@intentius/chant/codegen/validate");
136
+ const result = await validate();
137
+ printValidationResult(result);
138
+ },
139
+
140
+ async coverage(options?: { verbose?: boolean; minOverall?: number }): Promise<void> {
141
+ const { analyzeDockerCoverage } = await import("./coverage");
142
+ await analyzeDockerCoverage({
143
+ verbose: options?.verbose,
144
+ minOverall: options?.minOverall,
145
+ });
146
+ },
147
+
148
+ async package(options?: { verbose?: boolean; force?: boolean }): Promise<void> {
149
+ const { packageLexicon } = await import("./codegen/package");
150
+ const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
151
+ const { join: pathJoin, dirname: pathDirname } = await import("path");
152
+ const { fileURLToPath: urlToPath } = await import("url");
153
+
154
+ const { spec, stats } = await packageLexicon({ verbose: options?.verbose, force: options?.force });
155
+
156
+ const pkgDir = pathDirname(pathDirname(urlToPath(import.meta.url)));
157
+ const distDir = pathJoin(pkgDir, "dist");
158
+ writeBundleSpec(spec, distDir);
159
+
160
+ console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
161
+ },
162
+
163
+ mcpTools() {
164
+ return [createDiffTool(dockerSerializer, "Compare current build output against previous output for Docker Compose")];
165
+ },
166
+
167
+ mcpResources() {
168
+ return [
169
+ createCatalogResource(import.meta.url, "Docker Entity Catalog", "JSON list of all supported Docker entity types", "lexicon-docker.json"),
170
+ {
171
+ uri: "examples/basic-app",
172
+ name: "Basic App Example",
173
+ description: "A basic Docker Compose application with a service and volume",
174
+ mimeType: "text/typescript",
175
+ async handler(): Promise<string> {
176
+ return `import { Service, Volume } from "@intentius/chant-lexicon-docker";
177
+
178
+ export const db = new Service({
179
+ image: "postgres:16-alpine",
180
+ environment: {
181
+ POSTGRES_DB: "myapp",
182
+ POSTGRES_USER: "myapp",
183
+ POSTGRES_PASSWORD: "secret",
184
+ },
185
+ volumes: ["pgdata:/var/lib/postgresql/data"],
186
+ });
187
+
188
+ export const pgdata = new Volume({});
189
+
190
+ export const api = new Service({
191
+ image: "myapp:1.0",
192
+ ports: ["8080:8080"],
193
+ depends_on: ["db"],
194
+ });
195
+ `;
196
+ },
197
+ },
198
+ ];
199
+ },
200
+
201
+ async docs(options?: { verbose?: boolean }): Promise<void> {
202
+ const { generateDocs } = await import("./codegen/docs");
203
+ await generateDocs(options);
204
+ },
205
+
206
+ skills: createSkillsLoader(import.meta.url, [
207
+ {
208
+ file: "chant-docker.md",
209
+ name: "chant-docker",
210
+ description: "Docker Compose service/volume/network lifecycle — build, serialize, deploy",
211
+ triggers: [
212
+ { type: "file-pattern", value: "**/*.compose.ts" },
213
+ { type: "file-pattern", value: "**/docker-compose.ts" },
214
+ { type: "context", value: "docker compose" },
215
+ { type: "context", value: "docker service" },
216
+ { type: "context", value: "dockerfile" },
217
+ { type: "context", value: "container" },
218
+ ],
219
+ parameters: [],
220
+ examples: [
221
+ {
222
+ title: "Basic service with database",
223
+ description: "Create a web service and PostgreSQL database",
224
+ input: "Create a Node.js API with a PostgreSQL database",
225
+ output: `new Service({ image: "myapi:1.0", depends_on: ["db"] })`,
226
+ },
227
+ ],
228
+ },
229
+ {
230
+ file: "chant-docker-patterns.md",
231
+ name: "chant-docker-patterns",
232
+ description: "Docker Compose patterns — databases, caches, networks, multi-stage builds",
233
+ triggers: [
234
+ { type: "context", value: "docker compose" },
235
+ { type: "context", value: "multi-stage" },
236
+ { type: "context", value: "dockerfile pattern" },
237
+ { type: "context", value: "docker network" },
238
+ { type: "context", value: "docker volume" },
239
+ ],
240
+ parameters: [],
241
+ examples: [
242
+ {
243
+ title: "Multi-stage Dockerfile",
244
+ input: "Create a multi-stage Dockerfile for Node.js",
245
+ output: `new Dockerfile({ stages: [{ from: "node:20-alpine", as: "build", ... }] })`,
246
+ },
247
+ ],
248
+ },
249
+ ]),
250
+ };
@@ -0,0 +1,294 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { dockerSerializer } from "./serializer";
3
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
4
+ import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
5
+
6
+ // ── Mock entities ──────────────────────────────────────────────────
7
+
8
+ class MockService implements Declarable {
9
+ readonly [DECLARABLE_MARKER] = true as const;
10
+ readonly lexicon = "docker";
11
+ readonly entityType = "Docker::Compose::Service";
12
+ readonly kind = "resource" as const;
13
+ readonly props: Record<string, unknown>;
14
+
15
+ constructor(props: Record<string, unknown> = {}) {
16
+ this.props = props;
17
+ }
18
+ }
19
+
20
+ class MockVolume implements Declarable {
21
+ readonly [DECLARABLE_MARKER] = true as const;
22
+ readonly lexicon = "docker";
23
+ readonly entityType = "Docker::Compose::Volume";
24
+ readonly kind = "resource" as const;
25
+ readonly props: Record<string, unknown>;
26
+
27
+ constructor(props: Record<string, unknown> = {}) {
28
+ this.props = props;
29
+ }
30
+ }
31
+
32
+ class MockNetwork implements Declarable {
33
+ readonly [DECLARABLE_MARKER] = true as const;
34
+ readonly lexicon = "docker";
35
+ readonly entityType = "Docker::Compose::Network";
36
+ readonly kind = "resource" as const;
37
+ readonly props: Record<string, unknown>;
38
+
39
+ constructor(props: Record<string, unknown> = {}) {
40
+ this.props = props;
41
+ }
42
+ }
43
+
44
+ class MockDockerfile implements Declarable {
45
+ readonly [DECLARABLE_MARKER] = true as const;
46
+ readonly lexicon = "docker";
47
+ readonly entityType = "Docker::Dockerfile";
48
+ readonly kind = "resource" as const;
49
+ readonly props: Record<string, unknown>;
50
+
51
+ constructor(props: Record<string, unknown> = {}) {
52
+ this.props = props;
53
+ }
54
+ }
55
+
56
+ class MockPropertyDeclarable implements Declarable {
57
+ readonly [DECLARABLE_MARKER] = true as const;
58
+ readonly lexicon = "docker";
59
+ readonly entityType = "Docker::Compose::HealthCheck";
60
+ readonly kind = "property" as const;
61
+ readonly props: Record<string, unknown>;
62
+
63
+ constructor(props: Record<string, unknown> = {}) {
64
+ this.props = props;
65
+ }
66
+ }
67
+
68
+ class MockDefaultLabels implements Declarable {
69
+ readonly [DECLARABLE_MARKER] = true as const;
70
+ readonly [Symbol.for("docker.defaultLabels")] = true as const;
71
+ readonly lexicon = "docker";
72
+ readonly entityType = "Docker::DefaultLabels";
73
+ readonly kind = "resource" as const;
74
+ readonly props: Record<string, unknown>;
75
+
76
+ constructor(props: Record<string, unknown> = {}) {
77
+ this.props = props;
78
+ }
79
+ }
80
+
81
+ // ── Identity tests ─────────────────────────────────────────────────
82
+
83
+ describe("dockerSerializer", () => {
84
+ test("has correct name", () => {
85
+ expect(dockerSerializer.name).toBe("docker");
86
+ });
87
+
88
+ test("has correct rulePrefix", () => {
89
+ expect(dockerSerializer.rulePrefix).toBe("DKR");
90
+ });
91
+ });
92
+
93
+ // ── Empty input ────────────────────────────────────────────────────
94
+
95
+ describe("dockerSerializer.serialize — empty", () => {
96
+ test("returns newline for empty entities", () => {
97
+ const output = dockerSerializer.serialize(new Map());
98
+ expect(output).toBe("\n");
99
+ });
100
+ });
101
+
102
+ // ── Single service ─────────────────────────────────────────────────
103
+
104
+ describe("dockerSerializer.serialize — single service", () => {
105
+ test("serializes one service with image", () => {
106
+ const entities = new Map<string, Declarable>();
107
+ entities.set("api", new MockService({ image: "nginx:1.25" }));
108
+
109
+ const output = dockerSerializer.serialize(entities) as string;
110
+ expect(output).toContain("services:");
111
+ expect(output).toContain("api:");
112
+ expect(output).toContain("image: nginx:1.25");
113
+ });
114
+
115
+ test("omits null/undefined properties", () => {
116
+ const entities = new Map<string, Declarable>();
117
+ entities.set("api", new MockService({ image: "nginx", restart: undefined }));
118
+
119
+ const output = dockerSerializer.serialize(entities) as string;
120
+ expect(output).not.toContain("restart:");
121
+ });
122
+ });
123
+
124
+ // ── Multi-resource ────────────────────────────────────────────────
125
+
126
+ describe("dockerSerializer.serialize — multi-resource", () => {
127
+ test("serializes services, volumes, networks in order", () => {
128
+ const entities = new Map<string, Declarable>();
129
+ entities.set("api", new MockService({ image: "myapp:latest" }));
130
+ entities.set("data", new MockVolume({}));
131
+ entities.set("frontend", new MockNetwork({}));
132
+
133
+ const output = dockerSerializer.serialize(entities) as string;
134
+ expect(output).toContain("services:");
135
+ expect(output).toContain("volumes:");
136
+ expect(output).toContain("networks:");
137
+
138
+ const servicesIdx = output.indexOf("services:");
139
+ const volumesIdx = output.indexOf("volumes:");
140
+ const networksIdx = output.indexOf("networks:");
141
+ expect(servicesIdx).toBeLessThan(volumesIdx);
142
+ expect(volumesIdx).toBeLessThan(networksIdx);
143
+ });
144
+ });
145
+
146
+ // ── Dockerfile emission ───────────────────────────────────────────
147
+
148
+ describe("dockerSerializer.serialize — Dockerfile", () => {
149
+ test("emits Dockerfile.{name} in SerializerResult", () => {
150
+ const entities = new Map<string, Declarable>();
151
+ entities.set("api", new MockService({ image: "myapp:latest" }));
152
+ entities.set("api", new MockService({
153
+ build: { dockerfile: "Dockerfile.builder" },
154
+ }));
155
+ entities.set("builder", new MockDockerfile({
156
+ from: "node:20-alpine",
157
+ run: ["npm ci", "npm run build"],
158
+ cmd: `["node", "dist/index.js"]`,
159
+ }));
160
+
161
+ const output = dockerSerializer.serialize(entities);
162
+ expect(typeof output).toBe("object");
163
+ const result = output as { primary: string; files: Record<string, string> };
164
+ expect(result.files).toBeDefined();
165
+ expect(result.files["Dockerfile.builder"]).toBeDefined();
166
+ expect(result.files["Dockerfile.builder"]).toContain("FROM node:20-alpine");
167
+ expect(result.files["Dockerfile.builder"]).toContain("RUN npm ci");
168
+ });
169
+
170
+ test("primary contains compose YAML when Dockerfile present", () => {
171
+ const entities = new Map<string, Declarable>();
172
+ entities.set("web", new MockService({ image: "nginx" }));
173
+ entities.set("builder", new MockDockerfile({ from: "node:20" }));
174
+
175
+ const result = dockerSerializer.serialize(entities) as { primary: string; files: Record<string, string> };
176
+ expect(result.primary).toContain("services:");
177
+ expect(result.primary).toContain("web:");
178
+ });
179
+
180
+ test("returns string (no SerializerResult) when no Dockerfile entities", () => {
181
+ const entities = new Map<string, Declarable>();
182
+ entities.set("web", new MockService({ image: "nginx" }));
183
+
184
+ const output = dockerSerializer.serialize(entities);
185
+ expect(typeof output).toBe("string");
186
+ });
187
+ });
188
+
189
+ // ── Default labels ────────────────────────────────────────────────
190
+
191
+ describe("dockerSerializer.serialize — default labels", () => {
192
+ test("merges default labels into every service", () => {
193
+ const entities = new Map<string, Declarable>();
194
+ entities.set("labels", new MockDefaultLabels({ labels: { "app.team": "platform" } }));
195
+ entities.set("api", new MockService({ image: "myapp:1.0" }));
196
+ entities.set("worker", new MockService({ image: "myworker:1.0" }));
197
+
198
+ const output = dockerSerializer.serialize(entities) as string;
199
+ expect(output).toContain("app.team: platform");
200
+ });
201
+
202
+ test("default labels are not emitted as a top-level key", () => {
203
+ const entities = new Map<string, Declarable>();
204
+ entities.set("labels", new MockDefaultLabels({ labels: { "app.team": "platform" } }));
205
+ entities.set("api", new MockService({ image: "myapp:1.0" }));
206
+
207
+ const output = dockerSerializer.serialize(entities) as string;
208
+ expect(output).not.toContain("Docker::DefaultLabels");
209
+ });
210
+ });
211
+
212
+ // ── Property kind skipping ─────────────────────────────────────────
213
+
214
+ describe("dockerSerializer.serialize — property skipping", () => {
215
+ test("skips entities with kind=property", () => {
216
+ const entities = new Map<string, Declarable>();
217
+ entities.set("api", new MockService({ image: "myapp:1.0" }));
218
+ entities.set("hc", new MockPropertyDeclarable({ test: "curl -f http://localhost" }));
219
+
220
+ const output = dockerSerializer.serialize(entities) as string;
221
+ // Property entities should not appear as top-level services
222
+ expect(output).not.toContain("Docker::Compose::HealthCheck");
223
+ });
224
+ });
225
+
226
+ // ── Key order ────────────────────────────────────────────────────
227
+
228
+ describe("dockerSerializer.serialize — key order", () => {
229
+ test("services appears before volumes appears before networks", () => {
230
+ const entities = new Map<string, Declarable>();
231
+ entities.set("net", new MockNetwork({ driver: "bridge" }));
232
+ entities.set("vol", new MockVolume({ driver: "local" }));
233
+ entities.set("svc", new MockService({ image: "nginx" }));
234
+
235
+ const output = dockerSerializer.serialize(entities) as string;
236
+ const svcIdx = output.indexOf("services:");
237
+ const volIdx = output.indexOf("volumes:");
238
+ const netIdx = output.indexOf("networks:");
239
+
240
+ expect(svcIdx).toBeLessThan(volIdx);
241
+ expect(volIdx).toBeLessThan(netIdx);
242
+ });
243
+ });
244
+
245
+ // ── Intrinsic serialization ───────────────────────────────────────
246
+
247
+ describe("dockerSerializer.serialize — intrinsics", () => {
248
+ test("serializes env() intrinsic to ${VAR} string", () => {
249
+ const mockEnvIntrinsic = {
250
+ [INTRINSIC_MARKER]: true,
251
+ toJSON: () => "${APP_IMAGE:-myapp:latest}",
252
+ toString: () => "${APP_IMAGE:-myapp:latest}",
253
+ };
254
+
255
+ const entities = new Map<string, Declarable>();
256
+ entities.set("api", new MockService({ image: mockEnvIntrinsic }));
257
+
258
+ const output = dockerSerializer.serialize(entities) as string;
259
+ expect(output).toContain("${APP_IMAGE:-myapp:latest}");
260
+ });
261
+ });
262
+
263
+ // ── Multi-stage Dockerfile ────────────────────────────────────────
264
+
265
+ describe("dockerSerializer.serialize — multi-stage Dockerfile", () => {
266
+ test("emits multiple FROM instructions for stages", () => {
267
+ const entities = new Map<string, Declarable>();
268
+ entities.set("app", new MockDockerfile({
269
+ stages: [
270
+ { from: "node:20-alpine", as: "builder", run: ["npm ci", "npm run build"] },
271
+ { from: "nginx:alpine", copy: ["--from=builder /app/dist /usr/share/nginx/html"] },
272
+ ],
273
+ }));
274
+
275
+ const output = dockerSerializer.serialize(entities);
276
+ const result = output as { primary: string; files: Record<string, string> };
277
+ const dockerfile = result.files["Dockerfile.app"];
278
+ expect(dockerfile).toContain("FROM node:20-alpine AS builder");
279
+ expect(dockerfile).toContain("FROM nginx:alpine");
280
+ expect(dockerfile).toContain("RUN npm ci");
281
+ });
282
+ });
283
+
284
+ // ── Name generation ───────────────────────────────────────────────
285
+
286
+ describe("dockerSerializer.serialize — name gen", () => {
287
+ test("uses export name as service key", () => {
288
+ const entities = new Map<string, Declarable>();
289
+ entities.set("myWebServer", new MockService({ image: "nginx" }));
290
+
291
+ const output = dockerSerializer.serialize(entities) as string;
292
+ expect(output).toContain("myWebServer:");
293
+ });
294
+ });