@intentius/chant-lexicon-docker 0.124.31 → 0.124.33

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 (97) hide show
  1. package/dist/codegen/docs-cli.js +7 -0
  2. package/dist/codegen/docs.js +163 -0
  3. package/dist/codegen/generate-cli.js +26 -0
  4. package/dist/codegen/generate-compose.js +16 -0
  5. package/dist/codegen/generate-dockerfile.js +16 -0
  6. package/dist/codegen/generate.d.ts.map +1 -1
  7. package/dist/codegen/generate.js +212 -0
  8. package/dist/codegen/naming.js +45 -0
  9. package/dist/codegen/package.js +40 -0
  10. package/dist/codegen/patches.js +31 -0
  11. package/dist/codegen/versions.js +41 -0
  12. package/dist/compose-key.d.ts +19 -0
  13. package/dist/compose-key.d.ts.map +1 -0
  14. package/dist/compose-key.js +26 -0
  15. package/dist/composites/catalog.js +113 -0
  16. package/dist/composites/docker-otel-collector.js +43 -0
  17. package/dist/composites/docker-web-service.js +51 -0
  18. package/dist/composites/index.js +6 -0
  19. package/dist/coverage.js +35 -0
  20. package/dist/default-labels.js +52 -0
  21. package/dist/detect.js +8 -0
  22. package/dist/dockerfile-order.js +16 -0
  23. package/dist/generated/index.d.ts +10 -0
  24. package/dist/generated/index.d.ts.map +1 -1
  25. package/dist/generated/index.js +8 -0
  26. package/dist/generated/runtime.js +4 -0
  27. package/dist/import/adapter.d.ts.map +1 -1
  28. package/dist/import/adapter.js +86 -0
  29. package/dist/import/generator.d.ts.map +1 -1
  30. package/dist/import/generator.js +159 -0
  31. package/dist/import/parser.d.ts +31 -0
  32. package/dist/import/parser.d.ts.map +1 -1
  33. package/dist/import/parser.js +181 -0
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +20 -0
  37. package/dist/integrity.json +3 -3
  38. package/dist/interpolation.js +54 -0
  39. package/dist/lint/audit-catalog.js +25 -0
  40. package/dist/lint/audit-lineage.js +39 -0
  41. package/dist/lint/post-synth/add-url-checksum.js +35 -0
  42. package/dist/lint/post-synth/apt-no-recommends.js +34 -0
  43. package/dist/lint/post-synth/apt-update-alone.js +34 -0
  44. package/dist/lint/post-synth/cache-busting-copy.js +61 -0
  45. package/dist/lint/post-synth/copy-from-stage.js +53 -0
  46. package/dist/lint/post-synth/docker-helpers.js +148 -0
  47. package/dist/lint/post-synth/index.js +30 -0
  48. package/dist/lint/post-synth/no-latest-image.js +30 -0
  49. package/dist/lint/post-synth/no-root-user.js +35 -0
  50. package/dist/lint/post-synth/prefer-copy.js +43 -0
  51. package/dist/lint/post-synth/relative-workdir.js +34 -0
  52. package/dist/lint/post-synth/repeated-cmd.js +30 -0
  53. package/dist/lint/post-synth/root-after-user.js +39 -0
  54. package/dist/lint/post-synth/ssh-port-exposed.js +58 -0
  55. package/dist/lint/post-synth/unused-global-arg.js +34 -0
  56. package/dist/lint/post-synth/unused-volume.js +41 -0
  57. package/dist/lint/rules/data/deprecated-images.js +26 -0
  58. package/dist/lint/rules/data/known-base-images.js +18 -0
  59. package/dist/lint/rules/index.js +5 -0
  60. package/dist/lint/rules/no-latest-tag.js +57 -0
  61. package/dist/lint/rules/per-kind-order.js +129 -0
  62. package/dist/list-artifacts.js +133 -0
  63. package/dist/lsp/completions.d.ts.map +1 -1
  64. package/dist/lsp/completions.js +18 -0
  65. package/dist/lsp/hover.d.ts.map +1 -1
  66. package/dist/lsp/hover.js +33 -0
  67. package/dist/manifest.json +1 -1
  68. package/dist/package-cli.js +20 -0
  69. package/dist/plugin.js +229 -0
  70. package/dist/serializer.d.ts.map +1 -1
  71. package/dist/serializer.js +390 -0
  72. package/dist/spec/fetch-compose.js +28 -0
  73. package/dist/spec/fetch-engine.js +19 -0
  74. package/dist/spec/parse-compose.js +160 -0
  75. package/dist/spec/parse-engine.js +36 -0
  76. package/dist/types/index.d.ts +10 -0
  77. package/dist/validate-cli.js +16 -0
  78. package/dist/validate.js +37 -0
  79. package/dist/variables.js +42 -0
  80. package/package.json +8 -8
  81. package/src/codegen/generate.test.ts +10 -0
  82. package/src/codegen/generate.ts +3 -0
  83. package/src/codegen/package.ts +2 -2
  84. package/src/compose-key.ts +31 -0
  85. package/src/generated/index.d.ts +10 -0
  86. package/src/generated/index.ts +10 -0
  87. package/src/import/adapter.ts +2 -1
  88. package/src/import/generator.test.ts +5 -4
  89. package/src/import/generator.ts +75 -48
  90. package/src/import/parser.ts +58 -65
  91. package/src/import/roundtrip.test.ts +46 -3
  92. package/src/import/testdata/names-and-fields.yaml +44 -0
  93. package/src/index.ts +3 -0
  94. package/src/lsp/completions.ts +2 -1
  95. package/src/lsp/hover.ts +2 -1
  96. package/src/serializer.test.ts +31 -0
  97. package/src/serializer.ts +15 -2
@@ -19,33 +19,26 @@ export class DockerGenerator {
19
19
  const warnings: string[] = [];
20
20
  const imports = new Set<string>();
21
21
  const lines: string[] = [];
22
+ const names = new ExportNames();
22
23
 
23
24
  for (const entity of entities) {
24
- switch (entity.kind) {
25
- case "service":
26
- imports.add("Service");
27
- lines.push(generateService(entity));
28
- break;
29
- case "volume":
30
- imports.add("Volume");
31
- lines.push(generateVolume(entity));
32
- break;
33
- case "network":
34
- imports.add("Network");
35
- lines.push(generateNetwork(entity));
36
- break;
37
- case "config":
38
- imports.add("DockerConfig");
39
- lines.push(generateConfig(entity));
40
- break;
41
- case "secret":
42
- imports.add("DockerSecret");
43
- lines.push(generateSecret(entity));
44
- break;
45
- case "dockerfile":
46
- imports.add("Dockerfile");
47
- lines.push(generateDockerfile(entity, warnings));
48
- break;
25
+ if (entity.kind === "dockerfile") {
26
+ imports.add("Dockerfile");
27
+ lines.push(generateDockerfile(entity, warnings));
28
+ continue;
29
+ }
30
+ const ctor = CONSTRUCTORS[entity.kind];
31
+ imports.add(ctor);
32
+ const exportName = names.take(entity.name, entity.kind);
33
+ const expr = `new ${ctor}(${propsLiteral(entity)})`;
34
+ // An export name that differs from the compose key would rename the
35
+ // key on build, and every reference to it by name would dangle, so
36
+ // the key is set explicitly (#3937).
37
+ if (exportName === entity.name) {
38
+ lines.push(`export const ${exportName} = ${expr};`);
39
+ } else {
40
+ imports.add("composeKey");
41
+ lines.push(`export const ${exportName} = composeKey(${JSON.stringify(entity.name)}, ${expr});`);
49
42
  }
50
43
  }
51
44
 
@@ -56,30 +49,56 @@ export class DockerGenerator {
56
49
  }
57
50
  }
58
51
 
59
- function generateService(svc: ServiceIR): string {
60
- const propsStr = JSON.stringify(svc.props, null, 2)
61
- .replace(/"([a-z_][a-z0-9_]*)":/g, "$1:");
62
- return `export const ${sanitizeName(svc.name)} = new Service(${propsStr});`;
63
- }
64
-
65
- function generateVolume(vol: VolumeIR): string {
66
- const hasProps = Object.keys(vol.props).length > 0;
67
- return `export const ${sanitizeName(vol.name)} = new Volume(${hasProps ? JSON.stringify(vol.props) : "{}"});`;
68
- }
69
-
70
- function generateNetwork(net: NetworkIR): string {
71
- const hasProps = Object.keys(net.props).length > 0;
72
- return `export const ${sanitizeName(net.name)} = new Network(${hasProps ? JSON.stringify(net.props) : "{}"});`;
52
+ const CONSTRUCTORS = {
53
+ service: "Service",
54
+ volume: "Volume",
55
+ network: "Network",
56
+ config: "DockerConfig",
57
+ secret: "DockerSecret",
58
+ } as const;
59
+
60
+ function propsLiteral(entity: ServiceIR | VolumeIR | NetworkIR | ConfigIR | SecretIR): string {
61
+ if (entity.kind === "service") {
62
+ return JSON.stringify(entity.props, null, 2).replace(/"([a-z_][a-z0-9_]*)":/g, "$1:");
63
+ }
64
+ return Object.keys(entity.props).length > 0 ? JSON.stringify(entity.props) : "{}";
73
65
  }
74
66
 
75
- function generateConfig(cfg: ConfigIR): string {
76
- const hasProps = Object.keys(cfg.props).length > 0;
77
- return `export const ${sanitizeName(cfg.name)} = new DockerConfig(${hasProps ? JSON.stringify(cfg.props) : "{}"});`;
78
- }
67
+ /** Words an export cannot be named, and the names the generated file imports. */
68
+ const RESERVED = new Set([
69
+ "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do",
70
+ "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "import", "in",
71
+ "instanceof", "new", "null", "return", "super", "switch", "this", "throw", "true", "try", "typeof",
72
+ "var", "void", "while", "with", "yield", "let", "static", "implements", "interface", "package",
73
+ "private", "protected", "public", "await", "arguments", "eval", "undefined", "NaN", "Infinity",
74
+ "Service", "Volume", "Network", "DockerConfig", "DockerSecret", "Dockerfile", "composeKey",
75
+ ]);
79
76
 
80
- function generateSecret(sec: SecretIR): string {
81
- const hasProps = Object.keys(sec.props).length > 0;
82
- return `export const ${sanitizeName(sec.name)} = new DockerSecret(${hasProps ? JSON.stringify(sec.props) : "{}"});`;
77
+ /**
78
+ * Export names for compose keys: identifiers, unique within the file. A key
79
+ * shared by two kinds (a service and a volume both named `db`) gives the
80
+ * second one a kind suffix (`dbVolume`).
81
+ */
82
+ class ExportNames {
83
+ private readonly used = new Set<string>();
84
+
85
+ take(key: string, kind: string): string {
86
+ const base = sanitizeName(key);
87
+ const suffixed = `${base}${kind[0]!.toUpperCase()}${kind.slice(1)}`;
88
+ for (const c of [base, suffixed]) {
89
+ if (!RESERVED.has(c) && !this.used.has(c)) {
90
+ this.used.add(c);
91
+ return c;
92
+ }
93
+ }
94
+ for (let i = 2; ; i++) {
95
+ const c = `${suffixed}${i}`;
96
+ if (!this.used.has(c)) {
97
+ this.used.add(c);
98
+ return c;
99
+ }
100
+ }
101
+ }
83
102
  }
84
103
 
85
104
  /** The instructions a step can name; anything else (MAINTAINER) is left as a comment. */
@@ -137,7 +156,15 @@ function quote(value: string): string {
137
156
  return JSON.stringify(value);
138
157
  }
139
158
 
159
+ /**
160
+ * A TypeScript identifier for a compose key: kebab, snake and dotted keys
161
+ * become camelCase, other characters are dropped, and a leading digit gets
162
+ * an underscore.
163
+ */
140
164
  function sanitizeName(name: string): string {
141
- // Convert kebab/snake to camelCase
142
- return name.replace(/[-_]([a-z])/g, (_, c) => c.toUpperCase());
165
+ let id = name.replace(/[^A-Za-z0-9$]+([A-Za-z0-9])/g, (_, c: string) => c.toUpperCase());
166
+ id = id.replace(/[^A-Za-z0-9_$]/g, "");
167
+ if (id === "") id = "entity";
168
+ if (/^[0-9]/.test(id)) id = `_${id}`;
169
+ return id;
143
170
  }
@@ -3,6 +3,9 @@
3
3
  * to an intermediate representation (IR) for TypeScript code generation.
4
4
  */
5
5
 
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
6
9
  import { parseYAML } from "@intentius/chant/yaml";
7
10
 
8
11
  export interface ServiceIR {
@@ -61,26 +64,58 @@ export interface ParseResult {
61
64
  warnings: string[];
62
65
  }
63
66
 
64
- const SERVICE_PROPS = [
65
- "image", "ports", "environment", "volumes", "depends_on",
66
- "restart", "healthcheck", "labels", "command", "entrypoint",
67
- "networks", "build", "deploy", "secrets", "configs",
67
+ /** The compose sections import reads, with the kind each holds and its entry in the lexicon. */
68
+ const SECTIONS = [
69
+ { section: "services", kind: "service", entry: "Service" },
70
+ { section: "volumes", kind: "volume", entry: "Volume" },
71
+ { section: "networks", kind: "network", entry: "Network" },
72
+ { section: "configs", kind: "config", entry: "DockerConfig" },
73
+ { section: "secrets", kind: "secret", entry: "DockerSecret" },
68
74
  ] as const;
69
75
 
70
- const VOLUME_PROPS = ["driver", "driver_opts", "external", "labels"] as const;
71
- const NETWORK_PROPS = ["driver", "external", "attachable", "labels"] as const;
72
- const CONFIG_PROPS = ["file", "external"] as const;
73
- const SECRET_PROPS = ["file", "external"] as const;
76
+ type ComposeKind = (typeof SECTIONS)[number]["kind"];
74
77
 
78
+ let fieldsCache: Map<ComposeKind, Set<string>> | undefined;
79
+
80
+ /**
81
+ * The fields the Compose spec defines for each kind, read from the generated
82
+ * lexicon (`lexicon-docker.json`, or `dist/meta.json` in a package built
83
+ * without it). The props types come from the same spec (#3934), so import
84
+ * keeps exactly what the types accept (#3937).
85
+ */
86
+ export function composeFields(): Map<ComposeKind, Set<string>> {
87
+ if (fieldsCache) return fieldsCache;
88
+ const here = dirname(fileURLToPath(import.meta.url));
89
+ const candidates = [join(here, "..", "generated", "lexicon-docker.json"), join(here, "..", "..", "dist", "meta.json")];
90
+ const path = candidates.find((c) => existsSync(c));
91
+ if (!path) {
92
+ throw new Error(`docker import: the generated lexicon is missing (tried ${candidates.join(", ")}); run npm run generate in lexicons/docker`);
93
+ }
94
+ const lexicon = JSON.parse(readFileSync(path, "utf-8")) as Record<string, { properties?: Record<string, unknown> }>;
95
+ const fields = new Map<ComposeKind, Set<string>>();
96
+ for (const { kind, entry } of SECTIONS) {
97
+ fields.set(kind, new Set(Object.keys(lexicon[entry]?.properties ?? {})));
98
+ }
99
+ fieldsCache = fields;
100
+ return fields;
101
+ }
102
+
103
+ /**
104
+ * Keep every field the Compose spec defines for the kind, and `x-`
105
+ * extension fields. Anything else is dropped and named in `warnings`.
106
+ */
75
107
  function extractProps(
76
108
  raw: unknown,
77
- allowed: readonly string[],
109
+ allowed: Set<string>,
110
+ where: string,
111
+ kind: string,
112
+ warnings: string[],
78
113
  ): Record<string, unknown> {
79
114
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
80
- const obj = raw as Record<string, unknown>;
81
115
  const props: Record<string, unknown> = {};
82
- for (const key of allowed) {
83
- if (key in obj) props[key] = obj[key];
116
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
117
+ if (allowed.has(key) || key.startsWith("x-")) props[key] = value;
118
+ else warnings.push(`${where}: "${key}" is not a Compose ${kind} field and was dropped`);
84
119
  }
85
120
  return props;
86
121
  }
@@ -96,64 +131,22 @@ export class DockerParser {
96
131
  const entities: DockerIR[] = [];
97
132
  const warnings: string[] = [];
98
133
  const doc = parseYAML(content) as Record<string, unknown>;
134
+ const fields = composeFields();
99
135
 
100
- // services
101
- const services = doc["services"];
102
- if (services && typeof services === "object" && !Array.isArray(services)) {
103
- for (const [name, raw] of Object.entries(services as Record<string, unknown>)) {
104
- entities.push({
105
- kind: "service",
106
- name,
107
- props: extractProps(raw, SERVICE_PROPS),
108
- });
109
- }
110
- }
111
-
112
- // volumes
113
- const volumes = doc["volumes"];
114
- if (volumes && typeof volumes === "object" && !Array.isArray(volumes)) {
115
- for (const [name, raw] of Object.entries(volumes as Record<string, unknown>)) {
116
- entities.push({
117
- kind: "volume",
118
- name,
119
- props: extractProps(raw, VOLUME_PROPS),
120
- });
121
- }
122
- }
123
-
124
- // networks
125
- const networks = doc["networks"];
126
- if (networks && typeof networks === "object" && !Array.isArray(networks)) {
127
- for (const [name, raw] of Object.entries(networks as Record<string, unknown>)) {
128
- entities.push({
129
- kind: "network",
130
- name,
131
- props: extractProps(raw, NETWORK_PROPS),
132
- });
133
- }
134
- }
135
-
136
- // configs
137
- const configs = doc["configs"];
138
- if (configs && typeof configs === "object" && !Array.isArray(configs)) {
139
- for (const [name, raw] of Object.entries(configs as Record<string, unknown>)) {
140
- entities.push({
141
- kind: "config",
142
- name,
143
- props: extractProps(raw, CONFIG_PROPS),
144
- });
145
- }
136
+ const known = new Set<string>(SECTIONS.map((s) => s.section));
137
+ for (const key of Object.keys(doc ?? {})) {
138
+ if (!known.has(key)) warnings.push(`"${key}" at the top level is not imported`);
146
139
  }
147
140
 
148
- // secrets
149
- const secrets = doc["secrets"];
150
- if (secrets && typeof secrets === "object" && !Array.isArray(secrets)) {
151
- for (const [name, raw] of Object.entries(secrets as Record<string, unknown>)) {
141
+ for (const { section, kind } of SECTIONS) {
142
+ const entries = doc?.[section];
143
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) continue;
144
+ for (const [name, raw] of Object.entries(entries as Record<string, unknown>)) {
152
145
  entities.push({
153
- kind: "secret",
146
+ kind,
154
147
  name,
155
- props: extractProps(raw, SECRET_PROPS),
156
- });
148
+ props: extractProps(raw, fields.get(kind)!, `${section}.${name}`, kind, warnings),
149
+ } as DockerIR);
157
150
  }
158
151
  }
159
152
 
@@ -178,8 +178,6 @@ function buildImport(source: string): string {
178
178
 
179
179
  describe("roundtrip: compose → TypeScript → compose", () => {
180
180
  test("configs and secrets import as DockerConfig and DockerSecret and build the same file", () => {
181
- // Names without hyphens: the generator camel-cases export names, which renames a
182
- // hyphenated key (db-password becomes dbPassword, #3937).
183
181
  const content = testdata("configs-secrets.yaml");
184
182
  const { entities } = new DockerParser().parse(content);
185
183
  const { source } = new DockerGenerator().generate(entities);
@@ -188,6 +186,50 @@ describe("roundtrip: compose → TypeScript → compose", () => {
188
186
  expect(source).toContain("new DockerSecret(");
189
187
  expect(parseYAML(buildImport(source))).toEqual(parseYAML(content));
190
188
  });
189
+
190
+ test("full.yaml builds the same file: every field kept, hyphenated keys kept (#3937)", () => {
191
+ const content = testdata("full.yaml");
192
+ const { entities, warnings } = new DockerParser().parse(content);
193
+ expect(warnings).toEqual([]);
194
+ const { source } = new DockerGenerator().generate(entities);
195
+ expect(parseYAML(buildImport(source))).toEqual(parseYAML(content));
196
+ });
197
+
198
+ test("fields outside the old fixed list, x- extensions, and keys that are not identifiers survive (#3937)", () => {
199
+ const content = testdata("names-and-fields.yaml");
200
+ const { entities, warnings } = new DockerParser().parse(content);
201
+ expect(warnings).toEqual([]);
202
+ const { source } = new DockerGenerator().generate(entities);
203
+ expect(parseYAML(buildImport(source))).toEqual(parseYAML(content));
204
+ });
205
+
206
+ test("through the chant import adapter", () => {
207
+ const content = testdata("names-and-fields.yaml");
208
+ const [file] = new DockerTemplateGenerator().generate(new DockerTemplateParser().parse(content));
209
+ expect(parseYAML(buildImport(file!.content))).toEqual(parseYAML(content));
210
+ });
211
+ });
212
+
213
+ describe("compose import warns on what it cannot keep (#3937)", () => {
214
+ test("a field the Compose spec does not define is dropped with a warning", () => {
215
+ const content = "services:\n api:\n image: nginx\n not_a_field: 1\nnetworks:\n n:\n bogus: true\n";
216
+ const { entities, warnings } = new DockerParser().parse(content);
217
+ expect(entities.find((e) => e.name === "api")).toMatchObject({ props: { image: "nginx" } });
218
+ expect(warnings).toEqual([
219
+ 'services.api: "not_a_field" is not a Compose service field and was dropped',
220
+ 'networks.n: "bogus" is not a Compose network field and was dropped',
221
+ ]);
222
+ });
223
+
224
+ test("a top-level section import does not carry is named", () => {
225
+ const { warnings } = new DockerParser().parse("name: proj\nservices:\n a:\n image: x\n");
226
+ expect(warnings).toEqual(['"name" at the top level is not imported']);
227
+ });
228
+
229
+ test("the adapter hands parser warnings to chant import", () => {
230
+ const ir = new DockerTemplateParser().parse("services:\n api:\n image: nginx\n not_a_field: 1\n");
231
+ expect(ir.warnings).toEqual(['services.api: "not_a_field" is not a Compose service field and was dropped']);
232
+ });
191
233
  });
192
234
 
193
235
  describe("every name the importers emit is exported by the lexicon", () => {
@@ -198,7 +240,8 @@ describe("every name the importers emit is exported by the lexicon", () => {
198
240
  ];
199
241
  expect(new Set(entities.map((e) => e.kind))).toEqual(new Set(["service", "volume", "network", "config", "secret", "dockerfile"]));
200
242
  const names = importedNames(new DockerGenerator().generate(entities).source);
201
- expect(names.length).toBe(6);
243
+ // The six constructors, and composeKey for full.yaml's hyphenated keys.
244
+ expect(names.length).toBe(7);
202
245
  for (const name of names) {
203
246
  expect(typeof (lexicon as Record<string, unknown>)[name], name).toBe("function");
204
247
  }
@@ -0,0 +1,44 @@
1
+ services:
2
+ web-app:
3
+ image: myapp:latest
4
+ working_dir: /srv
5
+ user: "1000:1000"
6
+ env_file:
7
+ - .env
8
+ stop_grace_period: 30s
9
+ x-team: platform
10
+ depends_on:
11
+ - db_primary
12
+ volumes:
13
+ - "app-data:/data"
14
+ networks:
15
+ - back-tier
16
+ secrets:
17
+ - db-password
18
+ configs:
19
+ - nginx.conf
20
+ db_primary:
21
+ image: postgres:16
22
+ db:
23
+ image: postgres:16
24
+ volumes:
25
+ - "db:/var/lib/postgresql/data"
26
+ volumes:
27
+ app-data:
28
+ name: shared-app-data
29
+ db:
30
+ networks:
31
+ back-tier:
32
+ internal: true
33
+ driver_opts:
34
+ com.docker.network.bridge.name: br-back
35
+ configs:
36
+ nginx.conf:
37
+ content: |
38
+ server {}
39
+ name: nginx-conf
40
+ labels:
41
+ tier: edge
42
+ secrets:
43
+ db-password:
44
+ environment: DB_PASSWORD
package/src/index.ts CHANGED
@@ -17,6 +17,9 @@ export { defaultLabels, defaultAnnotations, isDefaultLabels, isDefaultAnnotation
17
17
  export { DEFAULT_LABELS_MARKER, DEFAULT_ANNOTATIONS_MARKER } from "./default-labels";
18
18
  export type { DefaultLabels, DefaultAnnotations } from "./default-labels";
19
19
 
20
+ // Compose keys that are not identifiers (#3937)
21
+ export { composeKey, getComposeKey, COMPOSE_KEY } from "./compose-key";
22
+
20
23
  // Generated entities — populated by `npm run generate`
21
24
  export * from "./generated/index";
22
25
 
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from "module";
2
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
2
3
  import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
3
4
  import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
- const require = createRequire(import.meta.url);
5
+ const require = createRequire(moduleSourceUrl(import.meta.url));
5
6
 
6
7
  let cachedIndex: LexiconIndex | null = null;
7
8
 
package/src/lsp/hover.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from "module";
2
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
2
3
  import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
3
4
  import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
- const require = createRequire(import.meta.url);
5
+ const require = createRequire(moduleSourceUrl(import.meta.url));
5
6
 
6
7
  let cachedIndex: LexiconIndex | null = null;
7
8
 
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { dockerSerializer } from "./serializer";
3
+ import { composeKey } from "./compose-key";
3
4
  import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
4
5
  import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
5
6
 
@@ -489,3 +490,33 @@ describe("telemetry attribution", () => {
489
490
  );
490
491
  });
491
492
  });
493
+
494
+ describe("composeKey (#3937)", () => {
495
+ test("the entity takes the key, and a reference to it serializes as the key", () => {
496
+ const data = composeKey("app-data", new MockVolume({ driver: "local" }));
497
+ const out = dockerSerializer.serialize(new Map<string, Declarable>([
498
+ ["web", composeKey("web-app", new MockService({ image: "nginx", volumes: [data] }))],
499
+ ["appData", data],
500
+ ])) as string;
501
+ expect(out).toContain(" web-app:\n");
502
+ expect(out).toContain(" app-data:\n");
503
+ expect(out).toContain(" - app-data\n");
504
+ expect(out).not.toContain("appData");
505
+ });
506
+
507
+ test("two entities of one kind with the same key is an error", () => {
508
+ expect(() => dockerSerializer.serialize(new Map<string, Declarable>([
509
+ ["db", new MockService({ image: "postgres" })],
510
+ ["primary", composeKey("db", new MockService({ image: "postgres" }))],
511
+ ]))).toThrow('docker: db and primary both take the compose key "db"');
512
+ });
513
+
514
+ test("a service and a volume may share a key", () => {
515
+ const out = dockerSerializer.serialize(new Map<string, Declarable>([
516
+ ["db", new MockService({ image: "postgres" })],
517
+ ["dbVolume", composeKey("db", new MockVolume({}))],
518
+ ])) as string;
519
+ expect(out).toContain("services:\n db:\n");
520
+ expect(out).toContain("volumes:\n db:");
521
+ });
522
+ });
package/src/serializer.ts CHANGED
@@ -17,6 +17,7 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
17
17
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
18
18
  import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
19
19
  import { emitYAMLEntry } from "@intentius/chant/yaml";
20
+ import { getComposeKey } from "./compose-key";
20
21
  import { EXEC_FORM_STEPS, PER_KIND_ORDER, STEP_INSTRUCTIONS } from "./dockerfile-order";
21
22
 
22
23
  // ── Helpers ───────────────────────────────────────────────────────
@@ -368,9 +369,20 @@ export const dockerSerializer: Serializer = {
368
369
  _outputs?: LexiconOutput[],
369
370
  context?: SerializeContext,
370
371
  ): string | SerializerResult {
372
+ // A compose entity's key is its export name unless composeKey set one
373
+ // (#3937); references to it serialize as the same key. Two entities of
374
+ // one kind cannot share a key.
371
375
  const entityNames = new Map<Declarable, string>();
376
+ const taken = new Map<string, { name: string; entity: Declarable }>();
372
377
  for (const [name, entity] of entities) {
373
- entityNames.set(entity, name);
378
+ const key = getComposeKey(entity) ?? name;
379
+ entityNames.set(entity, key);
380
+ const slot = `${(entity as unknown as Record<string, unknown>).entityType}\0${key}`;
381
+ const other = taken.get(slot);
382
+ if (other !== undefined && other.entity !== entity) {
383
+ throw new Error(`docker: ${other.name} and ${name} both take the compose key "${key}"`);
384
+ }
385
+ taken.set(slot, { name, entity });
374
386
  }
375
387
 
376
388
  const services = new Map<string, Declarable>();
@@ -381,7 +393,8 @@ export const dockerSerializer: Serializer = {
381
393
  const dockerfiles = new Map<string, Declarable>();
382
394
  let defaultLabels: Record<string, string> = {};
383
395
 
384
- for (const [name, entity] of entities) {
396
+ for (const [exportName, entity] of entities) {
397
+ const name = entityNames.get(entity) ?? exportName;
385
398
  // Skip property-kind entities
386
399
  if (isPropertyDeclarable(entity)) continue;
387
400