@intentius/chant-lexicon-docker 0.124.31 → 0.124.32

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
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Parse the Compose Spec JSON Schema into ParsedResult entries.
3
+ *
4
+ * The Compose spec defines a top-level "services", "volumes", "networks",
5
+ * "configs", and "secrets" map — each value is a typed resource.
6
+ * We model those as the five Compose entity types.
7
+ */
8
+ /**
9
+ * The TypeScript type of a Compose string slot. Compose interpolates every
10
+ * value, so `env()` fits where a string does; an entity (a service, network,
11
+ * volume, config, secret or Dockerfile) is written as its name.
12
+ */
13
+ export const COMPOSE_STRING = "ComposeString";
14
+ const ENTITIES = [
15
+ { def: "service", shortName: "Service", description: "A containerized service definition in Docker Compose" },
16
+ { def: "volume", shortName: "Volume", description: "A named volume definition in Docker Compose" },
17
+ { def: "network", shortName: "Network", description: "A network definition in Docker Compose" },
18
+ { def: "config", shortName: "Config", description: "A config definition in Docker Compose" },
19
+ { def: "secret", shortName: "Secret", description: "A secret definition in Docker Compose" },
20
+ ];
21
+ /**
22
+ * Parse the Compose Spec JSON Schema buffer into ParsedResult entries.
23
+ * Returns one entry per top-level Compose resource type, with a property for
24
+ * every field the spec defines on it (#3934). Each property's `type` is a
25
+ * TypeScript type converted from the schema; string slots use
26
+ * {@link COMPOSE_STRING}.
27
+ */
28
+ export function parseComposeSpec(data) {
29
+ const spec = JSON.parse(data.toString("utf-8"));
30
+ const defs = spec.$defs ?? spec.definitions ?? {};
31
+ return ENTITIES.map(({ def, shortName, description }) => {
32
+ const schema = defs[def];
33
+ const required = new Set(schema?.required ?? []);
34
+ const properties = Object.entries(schema?.properties ?? {}).map(([name, prop]) => ({
35
+ name,
36
+ type: toTsType(prop, defs, new Set([def])),
37
+ ...(prop.description ? { description: oneLine(prop.description) } : {}),
38
+ ...(required.has(name) ? { required: true } : {}),
39
+ }));
40
+ return { typeName: `Docker::Compose::${shortName}`, shortName, description, isResource: true, properties };
41
+ });
42
+ }
43
+ function oneLine(text) {
44
+ return text.replace(/\s+/g, " ").replace(/\*\//g, "* /").trim();
45
+ }
46
+ /** A property name as a TypeScript key: bare when it is an identifier. */
47
+ function key(name) {
48
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
49
+ }
50
+ /** Convert one JSON Schema node to a TypeScript type. `seen` guards $ref cycles. */
51
+ export function toTsType(schema, defs, seen = new Set()) {
52
+ if (schema === undefined || schema === true)
53
+ return "unknown";
54
+ if (schema === false)
55
+ return "never";
56
+ if (schema.$ref) {
57
+ const name = schema.$ref.replace(/^#\/(\$defs|definitions)\//, "");
58
+ const target = defs[name];
59
+ if (!target || seen.has(name))
60
+ return "unknown";
61
+ return toTsType(target, defs, new Set([...seen, name]));
62
+ }
63
+ const alts = schema.oneOf ?? schema.anyOf;
64
+ if (alts) {
65
+ // A oneOf beside a type or properties narrows them; the alternatives carry the shape.
66
+ return union(alts.map((a) => toTsType(a, defs, seen)));
67
+ }
68
+ if (schema.allOf) {
69
+ const parts = schema.allOf.map((a) => toTsType(a, defs, seen)).filter((t) => t !== "unknown");
70
+ return parts.length ? parts.map(paren).join(" & ") : "unknown";
71
+ }
72
+ if (schema.const !== undefined)
73
+ return JSON.stringify(schema.const);
74
+ if (schema.enum)
75
+ return union(schema.enum.map((v) => JSON.stringify(v)));
76
+ const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
77
+ if (types.length === 0) {
78
+ if (schema.properties || schema.patternProperties || schema.additionalProperties !== undefined)
79
+ return objectType(schema, defs, seen);
80
+ if (schema.items)
81
+ return `Array<${toTsType(schema.items, defs, seen)}>`;
82
+ return "unknown";
83
+ }
84
+ return union(types.map((t) => {
85
+ switch (t) {
86
+ case "string":
87
+ return COMPOSE_STRING;
88
+ case "number":
89
+ case "integer":
90
+ return "number";
91
+ case "boolean":
92
+ return "boolean";
93
+ case "null":
94
+ return "null";
95
+ case "array":
96
+ return `Array<${schema.items ? toTsType(schema.items, defs, seen) : "unknown"}>`;
97
+ case "object":
98
+ return objectType(schema, defs, seen);
99
+ default:
100
+ return "unknown";
101
+ }
102
+ }));
103
+ }
104
+ function objectType(schema, defs, seen) {
105
+ const props = Object.entries(schema.properties ?? {});
106
+ const patterns = Object.entries(schema.patternProperties ?? {});
107
+ const extensions = patterns.some(([p]) => p === "^x-");
108
+ const others = patterns.filter(([p]) => p !== "^x-");
109
+ const additional = schema.additionalProperties;
110
+ if (props.length === 0) {
111
+ const values = others.map(([, v]) => toTsType(v, defs, seen));
112
+ if (additional && additional !== true)
113
+ values.push(toTsType(additional, defs, seen));
114
+ if (values.length)
115
+ return `Record<string, ${union(values)}>`;
116
+ return additional === false && !extensions ? "Record<string, never>" : "Record<string, unknown>";
117
+ }
118
+ const required = new Set(schema.required ?? []);
119
+ const fields = props.map(([name, p]) => `${key(name)}${required.has(name) ? "" : "?"}: ${toTsType(p, defs, seen)}`);
120
+ if (extensions)
121
+ fields.push("[key: `x-${string}`]: unknown");
122
+ // Free-form keys beside the named ones: the named ones keep their types.
123
+ if (others.length || (additional && additional !== true))
124
+ fields.push("[key: string]: unknown");
125
+ return `{ ${fields.join("; ")} }`;
126
+ }
127
+ function paren(t) {
128
+ return /[|&]/.test(t) && !/^[{(]/.test(t) ? `(${t})` : t;
129
+ }
130
+ function union(types) {
131
+ const unique = [...new Set(types.flatMap(splitTop))];
132
+ if (unique.includes("unknown"))
133
+ return "unknown";
134
+ return unique.join(" | ");
135
+ }
136
+ /** The members of a union type, split only at the top level. */
137
+ function splitTop(type) {
138
+ const out = [];
139
+ let depth = 0;
140
+ let quote = false;
141
+ let start = 0;
142
+ for (let i = 0; i < type.length; i++) {
143
+ const c = type[i];
144
+ if (c === '"')
145
+ quote = !quote;
146
+ if (quote)
147
+ continue;
148
+ if (c === "{" || c === "(" || c === "<" || c === "[")
149
+ depth++;
150
+ else if (c === "}" || c === ")" || c === ">" || c === "]")
151
+ depth--;
152
+ else if (depth === 0 && type.startsWith(" | ", i)) {
153
+ out.push(type.slice(start, i));
154
+ start = i + 3;
155
+ i += 2;
156
+ }
157
+ }
158
+ out.push(type.slice(start));
159
+ return out;
160
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Parse the Docker Engine API OpenAPI spec for Dockerfile-related types.
3
+ *
4
+ * We extract only the image-builder relevant definitions:
5
+ * ContainerConfig, BuildInfo, and related types.
6
+ */
7
+ /**
8
+ * Parse the Docker Engine API YAML buffer for Dockerfile instruction types.
9
+ * Returns a single DockerfileParseResult describing the Dockerfile entity.
10
+ */
11
+ export function parseEngineApi(_data) {
12
+ return {
13
+ typeName: "Docker::Dockerfile",
14
+ shortName: "Dockerfile",
15
+ description: "A Dockerfile definition with ordered build instructions",
16
+ instructions: [
17
+ { name: "from", description: "Base image (required first instruction)" },
18
+ { name: "arg", description: "Build-time argument", multi: true },
19
+ { name: "env", description: "Environment variable", multi: true },
20
+ { name: "run", description: "Run a command during build", multi: true, execForm: true },
21
+ { name: "copy", description: "Copy files from build context", multi: true },
22
+ { name: "add", description: "Add files (supports URLs and archives)", multi: true },
23
+ { name: "workdir", description: "Set working directory" },
24
+ { name: "user", description: "Set user for subsequent instructions" },
25
+ { name: "expose", description: "Document exposed ports", multi: true, numeric: true },
26
+ { name: "volume", description: "Create mount points", multi: true },
27
+ { name: "label", description: "Add metadata labels", multi: true },
28
+ { name: "entrypoint", description: "Set the entrypoint executable", execForm: true },
29
+ { name: "cmd", description: "Default command arguments", execForm: true },
30
+ { name: "healthcheck", description: "Health check instruction" },
31
+ { name: "shell", description: "Set the shell for shell-form commands (JSON array form)", stepOnly: true, execForm: true },
32
+ { name: "stopsignal", description: "Signal sent to stop the container", stepOnly: true },
33
+ { name: "onbuild", description: "Instruction to run when the image is used as a base", stepOnly: true },
34
+ ],
35
+ };
36
+ }
@@ -194,6 +194,8 @@ export interface ServiceProps {
194
194
  volumes_from?: Array<ComposeString>;
195
195
  /** The working directory in which the entrypoint or command will be run */
196
196
  working_dir?: ComposeString;
197
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
198
+ [extension: `x-${string}`]: unknown;
197
199
  }
198
200
 
199
201
  /** A named volume definition in Docker Compose */
@@ -208,6 +210,8 @@ export interface VolumeProps {
208
210
  external?: boolean | ComposeString | { name?: ComposeString; [key: `x-${string}`]: unknown };
209
211
  /** Add metadata to the volume using labels. */
210
212
  labels?: Record<string, ComposeString | number | boolean | null> | Array<ComposeString>;
213
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
214
+ [extension: `x-${string}`]: unknown;
211
215
  }
212
216
 
213
217
  /** A network definition in Docker Compose */
@@ -232,6 +236,8 @@ export interface NetworkProps {
232
236
  attachable?: boolean | ComposeString;
233
237
  /** Add metadata to the network using labels. */
234
238
  labels?: Record<string, ComposeString | number | boolean | null> | Array<ComposeString>;
239
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
240
+ [extension: `x-${string}`]: unknown;
235
241
  }
236
242
 
237
243
  /** A config definition in Docker Compose */
@@ -250,6 +256,8 @@ export interface DockerConfigProps {
250
256
  labels?: Record<string, ComposeString | number | boolean | null> | Array<ComposeString>;
251
257
  /** Driver to use for templating the config's value. */
252
258
  template_driver?: ComposeString;
259
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
260
+ [extension: `x-${string}`]: unknown;
253
261
  }
254
262
 
255
263
  /** A secret definition in Docker Compose */
@@ -270,6 +278,8 @@ export interface DockerSecretProps {
270
278
  driver_opts?: Record<string, ComposeString | number>;
271
279
  /** Driver to use for templating the secret's value. */
272
280
  template_driver?: ComposeString;
281
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
282
+ [extension: `x-${string}`]: unknown;
273
283
  }
274
284
 
275
285
  export declare const Service: new (props?: ServiceProps, attributes?: Record<string, unknown>) => Declarable;
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Thin entry point for `npm run validate` in lexicon-docker.
4
+ */
5
+ import { validate } from "./validate.js";
6
+ const result = await validate();
7
+ for (const check of result.checks) {
8
+ const status = check.ok ? "OK" : "FAIL";
9
+ const msg = check.error ? ` — ${check.error}` : "";
10
+ console.error(` [${status}] ${check.name}${msg}`);
11
+ }
12
+ if (!result.success) {
13
+ console.error("Validation failed");
14
+ process.exit(1);
15
+ }
16
+ console.error("All validation checks passed.");
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Validate generated lexicon-docker artifacts.
3
+ */
4
+ import { dirname, join } from "path";
5
+ import { existsSync } from "fs";
6
+ import { fileURLToPath } from "url";
7
+ import { validateLexiconArtifacts } from "@intentius/chant/codegen/validate";
8
+ const REQUIRED_NAMES = [
9
+ "Service",
10
+ "Volume",
11
+ "Network",
12
+ "DockerConfig",
13
+ "DockerSecret",
14
+ "Dockerfile",
15
+ ];
16
+ /**
17
+ * Validate the generated lexicon-docker artifacts.
18
+ */
19
+ export async function validate(opts) {
20
+ const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
21
+ const generatedDir = join(basePath, "src", "generated");
22
+ if (!existsSync(generatedDir)) {
23
+ return {
24
+ success: false,
25
+ checks: [{
26
+ name: "generated-dir",
27
+ ok: false,
28
+ error: 'src/generated/ not found — run "chant dev generate" first',
29
+ }],
30
+ };
31
+ }
32
+ return validateLexiconArtifacts({
33
+ lexiconJsonFilename: "lexicon-docker.json",
34
+ requiredNames: REQUIRED_NAMES,
35
+ basePath,
36
+ });
37
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Well-known Docker and Docker Compose environment variable names.
3
+ *
4
+ * Use with the env() intrinsic for type-safe interpolation.
5
+ *
6
+ * @example
7
+ * import { env, DOCKER_VARS } from "@intentius/chant-lexicon-docker";
8
+ *
9
+ * export const api = new Service({
10
+ * environment: {
11
+ * BUILDKIT_ENABLED: env(DOCKER_VARS.DOCKER_BUILDKIT),
12
+ * },
13
+ * });
14
+ */
15
+ /** Docker daemon and client environment variables */
16
+ export const DOCKER_VARS = {
17
+ /** Enable Docker BuildKit: DOCKER_BUILDKIT=1 */
18
+ DOCKER_BUILDKIT: "DOCKER_BUILDKIT",
19
+ /** Docker host socket/address */
20
+ DOCKER_HOST: "DOCKER_HOST",
21
+ /** Docker TLS verify */
22
+ DOCKER_TLS_VERIFY: "DOCKER_TLS_VERIFY",
23
+ /** Docker config directory */
24
+ DOCKER_CONFIG: "DOCKER_CONFIG",
25
+ /** Docker content trust */
26
+ DOCKER_CONTENT_TRUST: "DOCKER_CONTENT_TRUST",
27
+ };
28
+ /** Docker Compose specific environment variables */
29
+ export const COMPOSE_VARS = {
30
+ /** Override project name: COMPOSE_PROJECT_NAME */
31
+ COMPOSE_PROJECT_NAME: "COMPOSE_PROJECT_NAME",
32
+ /** Override compose file: COMPOSE_FILE */
33
+ COMPOSE_FILE: "COMPOSE_FILE",
34
+ /** Path separator for COMPOSE_FILE */
35
+ COMPOSE_PATH_SEPARATOR: "COMPOSE_PATH_SEPARATOR",
36
+ /** Docker compose profiles to enable */
37
+ COMPOSE_PROFILES: "COMPOSE_PROFILES",
38
+ /** Compose conversion flag */
39
+ COMPOSE_CONVERT_WINDOWS_PATHS: "COMPOSE_CONVERT_WINDOWS_PATHS",
40
+ /** Docker host for compose */
41
+ DOCKER_DEFAULT_PLATFORM: "DOCKER_DEFAULT_PLATFORM",
42
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-docker",
3
- "version": "0.124.31",
3
+ "version": "0.124.32",
4
4
  "description": "Docker lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -33,22 +33,22 @@
33
33
  ".": {
34
34
  "development": "./src/index.ts",
35
35
  "types": "./dist/index.d.ts",
36
- "default": "./src/index.ts"
36
+ "default": "./dist/index.js"
37
37
  },
38
38
  "./*": {
39
39
  "development": "./src/*.ts",
40
40
  "types": "./dist/*.d.ts",
41
- "default": "./src/*.ts"
41
+ "default": "./dist/*.js"
42
42
  },
43
43
  "./detect": {
44
44
  "development": "./src/detect.ts",
45
45
  "types": "./dist/detect.d.ts",
46
- "default": "./src/detect.ts"
46
+ "default": "./dist/detect.js"
47
47
  },
48
48
  "./lint/post-synth": {
49
49
  "development": "./src/lint/post-synth/index.ts",
50
50
  "types": "./dist/lint/post-synth/index.d.ts",
51
- "default": "./src/lint/post-synth/index.ts"
51
+ "default": "./dist/lint/post-synth/index.js"
52
52
  },
53
53
  "./manifest": "./dist/manifest.json",
54
54
  "./meta": "./dist/meta.json",
@@ -60,16 +60,16 @@
60
60
  "validate": "tsx src/validate-cli.ts",
61
61
  "docs": "tsx src/codegen/docs-cli.ts",
62
62
  "prepack": "npm run generate && npm run bundle && npm run validate && npm run build",
63
- "build": "../../scripts/tsc7.sh -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && find dist -type f \\( -name \"*.js\" -o -name \"*.js.map\" \\) -delete"
63
+ "build": "../../scripts/tsc7.sh -p tsconfig.build.json --sourceMap false && tsc-alias -p tsconfig.build.json"
64
64
  },
65
65
  "dependencies": {
66
- "@intentius/chant-lexicon-otel": "^0.124.31"
66
+ "@intentius/chant-lexicon-otel": "^0.124.32"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@intentius/chant": "*",
70
70
  "typescript": "^5.9.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@intentius/chant": "^0.124.31"
73
+ "@intentius/chant": "^0.124.32"
74
74
  }
75
75
  }
@@ -67,6 +67,16 @@ describe("generate()", () => {
67
67
  expect(result.typesDTS).toContain("declare const Service");
68
68
  });
69
69
 
70
+ test("every Compose entity's props accept x- extension fields, which import keeps (#4136)", async () => {
71
+ const result = await generate({ verbose: false });
72
+ for (const out of [result.typesDTS, result.indexTS]) {
73
+ for (const name of ["ServiceProps", "VolumeProps", "NetworkProps", "DockerConfigProps", "DockerSecretProps"]) {
74
+ const body = out.slice(out.indexOf(`export interface ${name} {`));
75
+ expect(body.slice(0, body.indexOf("\n}")), name).toContain("[extension: `x-${string}`]: unknown;");
76
+ }
77
+ }
78
+ });
79
+
70
80
  test("properties count is non-zero", async () => {
71
81
  const result = await generate({ verbose: false });
72
82
  expect(result.properties).toBeGreaterThan(0);
@@ -170,6 +170,9 @@ function composeTypeLines(compose: ComposeParseResult[]): string[] {
170
170
  if (p.description) lines.push(` /** ${p.description} */`);
171
171
  lines.push(` ${p.name}${opt}: ${p.type};`);
172
172
  }
173
+ // The Compose spec allows `x-` extension fields on every entity, and import keeps them (#4136).
174
+ lines.push(` /** A Compose extension field. The spec allows any \`x-\` key, and Compose itself ignores it. */`);
175
+ lines.push(" [extension: `x-${string}`]: unknown;");
173
176
  lines.push(`}`);
174
177
  lines.push("");
175
178
  }
@@ -5,7 +5,7 @@
5
5
  import { readFileSync } from "fs";
6
6
  import { dockerPlugin } from "../plugin";
7
7
  import { join, dirname } from "path";
8
- import { fileURLToPath } from "url";
8
+ import { moduleSourceDir } from "@intentius/chant/module-source";
9
9
  import type { IntrinsicDef } from "@intentius/chant/lexicon";
10
10
  import {
11
11
  packagePipeline,
@@ -17,7 +17,7 @@ import { generate } from "./generate";
17
17
 
18
18
  export type { PackageOptions, PackageResult };
19
19
 
20
- const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
20
+ const pkgDir = dirname(moduleSourceDir(import.meta.url));
21
21
 
22
22
  /**
23
23
  * Package the Docker lexicon into a distributable BundleSpec.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The key an entity takes in docker-compose.yml.
3
+ *
4
+ * A compose entity's key is its export name by default. A key that is not a
5
+ * TypeScript identifier (`db-password`, `nginx.conf`) cannot be an export
6
+ * name, so `composeKey` sets it explicitly (#3937):
7
+ *
8
+ * @example
9
+ * export const dbPassword = composeKey("db-password", new DockerSecret({ file: "./db-password.txt" }));
10
+ *
11
+ * References to the entity (`secrets: [dbPassword]`) serialize as the key.
12
+ */
13
+
14
+ import type { Declarable } from "@intentius/chant/declarable";
15
+
16
+ export const COMPOSE_KEY = Symbol.for("docker.composeKey");
17
+
18
+ /** Give a compose entity an explicit key in docker-compose.yml. Returns the entity. */
19
+ export function composeKey<T extends Declarable>(key: string, entity: T): T {
20
+ if (typeof key !== "string" || key === "") {
21
+ throw new Error("composeKey: the key must be a non-empty string");
22
+ }
23
+ Object.defineProperty(entity, COMPOSE_KEY, { value: key, enumerable: false, configurable: true });
24
+ return entity;
25
+ }
26
+
27
+ /** The key set by `composeKey`, or undefined. */
28
+ export function getComposeKey(entity: Declarable): string | undefined {
29
+ const key = (entity as unknown as Record<symbol, unknown>)[COMPOSE_KEY];
30
+ return typeof key === "string" ? key : undefined;
31
+ }
@@ -194,6 +194,8 @@ export interface ServiceProps {
194
194
  volumes_from?: Array<ComposeString>;
195
195
  /** The working directory in which the entrypoint or command will be run */
196
196
  working_dir?: ComposeString;
197
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
198
+ [extension: `x-${string}`]: unknown;
197
199
  }
198
200
 
199
201
  /** A named volume definition in Docker Compose */
@@ -208,6 +210,8 @@ export interface VolumeProps {
208
210
  external?: boolean | ComposeString | { name?: ComposeString; [key: `x-${string}`]: unknown };
209
211
  /** Add metadata to the volume using labels. */
210
212
  labels?: Record<string, ComposeString | number | boolean | null> | Array<ComposeString>;
213
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
214
+ [extension: `x-${string}`]: unknown;
211
215
  }
212
216
 
213
217
  /** A network definition in Docker Compose */
@@ -232,6 +236,8 @@ export interface NetworkProps {
232
236
  attachable?: boolean | ComposeString;
233
237
  /** Add metadata to the network using labels. */
234
238
  labels?: Record<string, ComposeString | number | boolean | null> | Array<ComposeString>;
239
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
240
+ [extension: `x-${string}`]: unknown;
235
241
  }
236
242
 
237
243
  /** A config definition in Docker Compose */
@@ -250,6 +256,8 @@ export interface DockerConfigProps {
250
256
  labels?: Record<string, ComposeString | number | boolean | null> | Array<ComposeString>;
251
257
  /** Driver to use for templating the config's value. */
252
258
  template_driver?: ComposeString;
259
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
260
+ [extension: `x-${string}`]: unknown;
253
261
  }
254
262
 
255
263
  /** A secret definition in Docker Compose */
@@ -270,6 +278,8 @@ export interface DockerSecretProps {
270
278
  driver_opts?: Record<string, ComposeString | number>;
271
279
  /** Driver to use for templating the secret's value. */
272
280
  template_driver?: ComposeString;
281
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
282
+ [extension: `x-${string}`]: unknown;
273
283
  }
274
284
 
275
285
  export declare const Service: new (props?: ServiceProps, attributes?: Record<string, unknown>) => Declarable;
@@ -196,6 +196,8 @@ export interface ServiceProps {
196
196
  volumes_from?: Array<ComposeString>;
197
197
  /** The working directory in which the entrypoint or command will be run */
198
198
  working_dir?: ComposeString;
199
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
200
+ [extension: `x-${string}`]: unknown;
199
201
  }
200
202
 
201
203
  /** A named volume definition in Docker Compose */
@@ -210,6 +212,8 @@ export interface VolumeProps {
210
212
  external?: boolean | ComposeString | { name?: ComposeString; [key: `x-${string}`]: unknown };
211
213
  /** Add metadata to the volume using labels. */
212
214
  labels?: Record<string, ComposeString | number | boolean | null> | Array<ComposeString>;
215
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
216
+ [extension: `x-${string}`]: unknown;
213
217
  }
214
218
 
215
219
  /** A network definition in Docker Compose */
@@ -234,6 +238,8 @@ export interface NetworkProps {
234
238
  attachable?: boolean | ComposeString;
235
239
  /** Add metadata to the network using labels. */
236
240
  labels?: Record<string, ComposeString | number | boolean | null> | Array<ComposeString>;
241
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
242
+ [extension: `x-${string}`]: unknown;
237
243
  }
238
244
 
239
245
  /** A config definition in Docker Compose */
@@ -252,6 +258,8 @@ export interface DockerConfigProps {
252
258
  labels?: Record<string, ComposeString | number | boolean | null> | Array<ComposeString>;
253
259
  /** Driver to use for templating the config's value. */
254
260
  template_driver?: ComposeString;
261
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
262
+ [extension: `x-${string}`]: unknown;
255
263
  }
256
264
 
257
265
  /** A secret definition in Docker Compose */
@@ -272,6 +280,8 @@ export interface DockerSecretProps {
272
280
  driver_opts?: Record<string, ComposeString | number>;
273
281
  /** Driver to use for templating the secret's value. */
274
282
  template_driver?: ComposeString;
283
+ /** A Compose extension field. The spec allows any `x-` key, and Compose itself ignores it. */
284
+ [extension: `x-${string}`]: unknown;
275
285
  }
276
286
 
277
287
  export const Service = createResource("Docker::Compose::Service", "docker", {}) as unknown as DockerResourceConstructor<ServiceProps>;
@@ -72,10 +72,11 @@ export class DockerTemplateParser implements TemplateParser {
72
72
  if (looksLikeDockerfile(content)) {
73
73
  return { resources: [dockerIrToResource(new DockerfileParser().parse("app", content))], parameters: [] };
74
74
  }
75
- const { entities } = new DockerParser().parse(content);
75
+ const { entities, warnings } = new DockerParser().parse(content);
76
76
  return {
77
77
  resources: entities.map(dockerIrToResource),
78
78
  parameters: [],
79
+ ...(warnings.length > 0 ? { warnings } : {}),
79
80
  };
80
81
  }
81
82
  }
@@ -36,8 +36,9 @@ describe("DockerGenerator", () => {
36
36
  { kind: "config", name: "app-config", props: { file: "./config/app.conf" } },
37
37
  ];
38
38
  const { source } = new DockerGenerator().generate(entities);
39
- expect(source).toContain("import { DockerConfig }");
40
- expect(source).toContain("new DockerConfig(");
39
+ expect(source).toContain("import { DockerConfig, composeKey }");
40
+ // app-config is not an identifier, so the key is set explicitly (#3937).
41
+ expect(source).toContain('export const appConfig = composeKey("app-config", new DockerConfig(');
41
42
  expect(source).toContain("app.conf");
42
43
  });
43
44
 
@@ -46,8 +47,8 @@ describe("DockerGenerator", () => {
46
47
  { kind: "secret", name: "db-password", props: { file: "./secrets/db.txt" } },
47
48
  ];
48
49
  const { source } = new DockerGenerator().generate(entities);
49
- expect(source).toContain("import { DockerSecret }");
50
- expect(source).toContain("new DockerSecret(");
50
+ expect(source).toContain("import { DockerSecret, composeKey }");
51
+ expect(source).toContain('export const dbPassword = composeKey("db-password", new DockerSecret(');
51
52
  });
52
53
 
53
54
  test("single-stage Dockerfile generates flat props (existing behaviour)", () => {