@intentius/chant-lexicon-docker 0.124.22 → 0.124.24

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 (35) hide show
  1. package/dist/codegen/generate.d.ts.map +1 -1
  2. package/dist/generated/index.d.ts +582 -6
  3. package/dist/generated/index.d.ts.map +1 -1
  4. package/dist/integrity.json +7 -7
  5. package/dist/lint/post-synth/cache-busting-copy.d.ts.map +1 -1
  6. package/dist/manifest.json +1 -1
  7. package/dist/meta.json +438 -86
  8. package/dist/okf/types/DockerConfig.md +7 -5
  9. package/dist/okf/types/DockerSecret.md +8 -4
  10. package/dist/okf/types/Network.md +10 -6
  11. package/dist/okf/types/Service.md +93 -15
  12. package/dist/okf/types/Volume.md +5 -5
  13. package/dist/rules/cache-busting-copy.ts +2 -1
  14. package/dist/skills/chant-docker-patterns.md +2 -2
  15. package/dist/skills/chant-docker.md +1 -0
  16. package/dist/spec/parse-compose.d.ts +29 -2
  17. package/dist/spec/parse-compose.d.ts.map +1 -1
  18. package/dist/types/index.d.ts +263 -41
  19. package/package.json +3 -3
  20. package/src/codegen/__snapshots__/okf.test.ts.snap +123 -35
  21. package/src/codegen/generate.ts +53 -14
  22. package/src/codegen/okf.test.ts +1 -1
  23. package/src/generated/index.d.ts +263 -41
  24. package/src/generated/index.ts +279 -6
  25. package/src/generated/lexicon-docker.json +438 -86
  26. package/src/import/generator.test.ts +4 -4
  27. package/src/import/generator.ts +4 -4
  28. package/src/import/roundtrip.test.ts +62 -2
  29. package/src/import/testdata/configs-secrets.yaml +15 -0
  30. package/src/lint/post-synth/cache-busting-copy.ts +2 -1
  31. package/src/skills/chant-docker-patterns.md +2 -2
  32. package/src/skills/chant-docker.md +1 -0
  33. package/src/spec/parse-compose.ts +167 -95
  34. package/src/typecheck.e2e.test.ts +107 -0
  35. package/src/typed-props.test.ts +60 -0
@@ -35,11 +35,11 @@ export class DockerGenerator {
35
35
  lines.push(generateNetwork(entity));
36
36
  break;
37
37
  case "config":
38
- imports.add("Config");
38
+ imports.add("DockerConfig");
39
39
  lines.push(generateConfig(entity));
40
40
  break;
41
41
  case "secret":
42
- imports.add("Secret");
42
+ imports.add("DockerSecret");
43
43
  lines.push(generateSecret(entity));
44
44
  break;
45
45
  case "dockerfile":
@@ -74,12 +74,12 @@ function generateNetwork(net: NetworkIR): string {
74
74
 
75
75
  function generateConfig(cfg: ConfigIR): string {
76
76
  const hasProps = Object.keys(cfg.props).length > 0;
77
- return `export const ${sanitizeName(cfg.name)} = new Config(${hasProps ? JSON.stringify(cfg.props) : "{}"});`;
77
+ return `export const ${sanitizeName(cfg.name)} = new DockerConfig(${hasProps ? JSON.stringify(cfg.props) : "{}"});`;
78
78
  }
79
79
 
80
80
  function generateSecret(sec: SecretIR): string {
81
81
  const hasProps = Object.keys(sec.props).length > 0;
82
- return `export const ${sanitizeName(sec.name)} = new Secret(${hasProps ? JSON.stringify(sec.props) : "{}"});`;
82
+ return `export const ${sanitizeName(sec.name)} = new DockerSecret(${hasProps ? JSON.stringify(sec.props) : "{}"});`;
83
83
  }
84
84
 
85
85
  /** The instructions a step can name; anything else (MAINTAINER) is left as a comment. */
@@ -7,6 +7,8 @@ import { DockerTemplateGenerator, DockerTemplateParser } from "./adapter";
7
7
  import { Dockerfile } from "../generated/index";
8
8
  import { dockerSerializer } from "../serializer";
9
9
  import type { Declarable } from "@intentius/chant/declarable";
10
+ import { parseYAML } from "@intentius/chant/yaml";
11
+ import * as lexicon from "../index";
10
12
 
11
13
  /**
12
14
  * Dockerfile -> TypeScript -> Dockerfile. The generated source is run (its
@@ -46,8 +48,8 @@ describe("roundtrip: parse → generate", () => {
46
48
  test("full.yaml → Config / Secret / Network constructors present", () => {
47
49
  const { entities } = new DockerParser().parse(testdata("full.yaml"));
48
50
  const { source } = new DockerGenerator().generate(entities);
49
- expect(source).toContain("new Config(");
50
- expect(source).toContain("new Secret(");
51
+ expect(source).toContain("new DockerConfig(");
52
+ expect(source).toContain("new DockerSecret(");
51
53
  expect(source).toContain("new Network(");
52
54
  });
53
55
 
@@ -152,3 +154,61 @@ describe("roundtrip: Dockerfile → TypeScript → Dockerfile keeps the instruct
152
154
  expect(roundTrip(content, file!.content).dockerfile).toBe(content);
153
155
  });
154
156
  });
157
+
158
+ /** The names the generated source imports from the lexicon package. */
159
+ function importedNames(source: string): string[] {
160
+ const m = source.match(/^import \{ ([^}]+) \} from "@intentius\/chant-lexicon-docker";$/m);
161
+ return m ? m[1]!.split(",").map((n) => n.trim()) : [];
162
+ }
163
+
164
+ /**
165
+ * Run imported source against the lexicon package and serialize what it
166
+ * exports. Each name on its import line is bound from the package's real
167
+ * exports, so a name the package lacks fails here (#3933).
168
+ */
169
+ function buildImport(source: string): string {
170
+ const names = importedNames(source);
171
+ const body = source.replace(/^import .*$/m, "").replace(/export const (\w+) =/g, "out.$1 =");
172
+ const out: Record<string, Declarable> = {};
173
+ const exports = lexicon as Record<string, unknown>;
174
+ new Function(...names, "out", body)(...names.map((n) => exports[n]), out);
175
+ const result = dockerSerializer.serialize(new Map(Object.entries(out)));
176
+ return typeof result === "string" ? result : result.primary ?? "";
177
+ }
178
+
179
+ describe("roundtrip: compose → TypeScript → compose", () => {
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
+ const content = testdata("configs-secrets.yaml");
184
+ const { entities } = new DockerParser().parse(content);
185
+ const { source } = new DockerGenerator().generate(entities);
186
+ expect(importedNames(source)).toEqual(["DockerConfig", "DockerSecret", "Service"]);
187
+ expect(source).toContain("new DockerConfig(");
188
+ expect(source).toContain("new DockerSecret(");
189
+ expect(parseYAML(buildImport(source))).toEqual(parseYAML(content));
190
+ });
191
+ });
192
+
193
+ describe("every name the importers emit is exported by the lexicon", () => {
194
+ test("compose and Dockerfile imports name only exported constructors", () => {
195
+ const entities = [
196
+ ...new DockerParser().parse(testdata("full.yaml")).entities,
197
+ new DockerfileParser().parse("app", "FROM alpine:3.20\nRUN true\n"),
198
+ ];
199
+ expect(new Set(entities.map((e) => e.kind))).toEqual(new Set(["service", "volume", "network", "config", "secret", "dockerfile"]));
200
+ const names = importedNames(new DockerGenerator().generate(entities).source);
201
+ expect(names.length).toBe(6);
202
+ for (const name of names) {
203
+ expect(typeof (lexicon as Record<string, unknown>)[name], name).toBe("function");
204
+ }
205
+ // Through the `chant import` adapter too.
206
+ for (const content of [testdata("full.yaml"), "FROM alpine:3.20\nRUN true\n"]) {
207
+ for (const file of new DockerTemplateGenerator().generate(new DockerTemplateParser().parse(content))) {
208
+ for (const name of importedNames(file.content)) {
209
+ expect(typeof (lexicon as Record<string, unknown>)[name], `${file.path}: ${name}`).toBe("function");
210
+ }
211
+ }
212
+ }
213
+ });
214
+ });
@@ -0,0 +1,15 @@
1
+ services:
2
+ api:
3
+ image: myapp:latest
4
+ ports:
5
+ - "8080:8080"
6
+ configs:
7
+ - settings
8
+ secrets:
9
+ - token
10
+ configs:
11
+ settings:
12
+ file: ./config/app.conf
13
+ secrets:
14
+ token:
15
+ file: ./secrets/token.txt
@@ -23,7 +23,8 @@ const INSTALL = [
23
23
  /\bgo\s+mod\s+download\b/,
24
24
  /\bbundle\s+install\b/,
25
25
  /\bcomposer\s+install\b/,
26
- /\bcargo\s+fetch\b/,
26
+ // A string, not a regex literal: the egress scan reads a bare `fetch` in code as a call.
27
+ new RegExp("\\bcargo\\s+fetch\\b"),
27
28
  /\bdotnet\s+restore\b/,
28
29
  /\bmvn\b[^&;|]*\bdependency:go-offline\b/,
29
30
  ];
@@ -5,7 +5,7 @@ Common Docker Compose and Dockerfile patterns using `@intentius/chant-lexicon-do
5
5
  ## Database service with volume
6
6
 
7
7
  ```typescript
8
- import { Service, Volume } from "@intentius/chant-lexicon-docker";
8
+ import { Service, Volume, env } from "@intentius/chant-lexicon-docker";
9
9
 
10
10
  export const postgres = new Service({
11
11
  image: "postgres:16-alpine",
@@ -207,7 +207,7 @@ export const app = new Service({
207
207
  ## Environment interpolation patterns
208
208
 
209
209
  ```typescript
210
- import { env } from "@intentius/chant-lexicon-docker";
210
+ import { Service, env } from "@intentius/chant-lexicon-docker";
211
211
 
212
212
  // Dev vs prod toggle:
213
213
  export const api = new Service({
@@ -90,6 +90,7 @@ Step keys: `arg`, `env`, `workdir`, `user`, `run`, `copy`, `add`, `expose`, `vol
90
90
  Rules:
91
91
  - Use `steps`. The older per-kind fields (`run: [...]`, `copy: [...]`, `user`) are written in a fixed order (ARG, ENV, WORKDIR, USER, RUN, COPY, ADD, EXPOSE, VOLUME, LABEL, ENTRYPOINT, CMD, HEALTHCHECK), not the order written, so every RUN lands before every COPY. Lint rule DKRS002 flags this, and `chant lint --fix` rewrites them as steps.
92
92
  - A stage can't have both `steps` and per-kind fields, and a step can't name two instructions. Both are build errors.
93
+ - Props are type-checked: `Dockerfile` takes `DockerfileProps` and the Compose constructors take `ServiceProps`, `VolumeProps`, `NetworkProps`, `DockerConfigProps` and `DockerSecretProps`. A misspelled key or a wrong value type is a TypeScript error, so run `tsc` (or read the editor's errors) before `chant build`.
93
94
  - To share stages or steps between Dockerfiles in different modules, export a `const` stage (`DockerfileStage`) or step list (`DockerfileStep[]`) and import it. List the stage in `stages`, or spread the steps: `steps: [...nodeRuntime, { cmd: ["node", "dist/server.js"] }]`. This is chant's equivalent of an INCLUDE. Spreading a function call's result is a lint error (EVL004); put parameterized Dockerfiles in a `Composite`.
94
95
 
95
96
  ## Variable interpolation with env():
@@ -22,102 +22,174 @@ export interface ComposeProperty {
22
22
  enum?: string[];
23
23
  }
24
24
 
25
+ /**
26
+ * The TypeScript type of a Compose string slot. Compose interpolates every
27
+ * value, so `env()` fits where a string does; an entity (a service, network,
28
+ * volume, config, secret or Dockerfile) is written as its name.
29
+ */
30
+ export const COMPOSE_STRING = "ComposeString";
31
+
32
+ interface JsonSchema {
33
+ $ref?: string;
34
+ type?: string | string[];
35
+ description?: string;
36
+ enum?: unknown[];
37
+ const?: unknown;
38
+ properties?: Record<string, JsonSchema>;
39
+ patternProperties?: Record<string, JsonSchema>;
40
+ additionalProperties?: boolean | JsonSchema;
41
+ required?: string[];
42
+ items?: JsonSchema;
43
+ oneOf?: JsonSchema[];
44
+ anyOf?: JsonSchema[];
45
+ allOf?: JsonSchema[];
46
+ }
47
+
48
+ const ENTITIES: Array<{ def: string; shortName: string; description: string }> = [
49
+ { def: "service", shortName: "Service", description: "A containerized service definition in Docker Compose" },
50
+ { def: "volume", shortName: "Volume", description: "A named volume definition in Docker Compose" },
51
+ { def: "network", shortName: "Network", description: "A network definition in Docker Compose" },
52
+ { def: "config", shortName: "Config", description: "A config definition in Docker Compose" },
53
+ { def: "secret", shortName: "Secret", description: "A secret definition in Docker Compose" },
54
+ ];
55
+
25
56
  /**
26
57
  * Parse the Compose Spec JSON Schema buffer into ParsedResult entries.
27
- * Returns one entry per top-level Compose resource type.
58
+ * Returns one entry per top-level Compose resource type, with a property for
59
+ * every field the spec defines on it (#3934). Each property's `type` is a
60
+ * TypeScript type converted from the schema; string slots use
61
+ * {@link COMPOSE_STRING}.
28
62
  */
29
- export function parseComposeSpec(_data: Buffer): ComposeParseResult[] {
30
- // The Compose spec is complex; we hand-define the five core resource types
31
- // and extract key properties from the schema for documentation/type generation.
32
- // Full deep parsing happens during codegen; here we return the structural outline.
33
-
34
- return [
35
- {
36
- typeName: "Docker::Compose::Service",
37
- shortName: "Service",
38
- description: "A containerized service definition in Docker Compose",
39
- isResource: true,
40
- properties: [
41
- { name: "image", type: "string", description: "Container image to use" },
42
- { name: "build", type: "object", description: "Build configuration" },
43
- { name: "command", type: "string | string[]", description: "Override the default command" },
44
- { name: "entrypoint", type: "string | string[]", description: "Override the default entrypoint" },
45
- { name: "environment", type: "Record<string, string>", description: "Environment variables" },
46
- { name: "ports", type: "string[]", description: "Published ports" },
47
- { name: "volumes", type: "string[]", description: "Volume mounts" },
48
- { name: "networks", type: "string[]", description: "Networks to attach" },
49
- {
50
- name: "depends_on",
51
- // Compose `depends_on` is a oneOf: short list-form, or long form with a
52
- // wait condition (service_started/healthy/completed_successfully).
53
- type: 'string[] | Record<string, { condition: "service_started" | "service_healthy" | "service_completed_successfully"; restart?: boolean; required?: boolean }>',
54
- description: "Service dependencies (short list-form, or long form with a wait condition)",
55
- },
56
- { name: "restart", type: "string", description: "Restart policy" },
57
- { name: "labels", type: "Record<string, string>", description: "Container labels" },
58
- { name: "healthcheck", type: "object", description: "Health check configuration" },
59
- { name: "deploy", type: "object", description: "Swarm deployment configuration" },
60
- { name: "secrets", type: "string[]", description: "Secrets to expose" },
61
- {
62
- name: "configs",
63
- // Short form names a top-level config; long form also sets where it
64
- // is mounted. `source` may be the DockerConfig entity itself.
65
- type: "Array<string | { source: string | Declarable; target?: string; uid?: string; gid?: string; mode?: number }>",
66
- description: "Configs to expose (short form, or long form with a mount target)",
67
- },
68
- ],
69
- },
70
- {
71
- typeName: "Docker::Compose::Volume",
72
- shortName: "Volume",
73
- description: "A named volume definition in Docker Compose",
74
- isResource: true,
75
- properties: [
76
- { name: "driver", type: "string", description: "Volume driver" },
77
- { name: "driver_opts", type: "Record<string, string>", description: "Driver options" },
78
- { name: "external", type: "boolean", description: "Whether the volume is external" },
79
- { name: "labels", type: "Record<string, string>", description: "Volume labels" },
80
- { name: "name", type: "string", description: "Custom volume name" },
81
- ],
82
- },
83
- {
84
- typeName: "Docker::Compose::Network",
85
- shortName: "Network",
86
- description: "A network definition in Docker Compose",
87
- isResource: true,
88
- properties: [
89
- { name: "driver", type: "string", description: "Network driver" },
90
- { name: "driver_opts", type: "Record<string, string>", description: "Driver options" },
91
- { name: "external", type: "boolean", description: "Whether the network is external" },
92
- { name: "labels", type: "Record<string, string>", description: "Network labels" },
93
- { name: "internal", type: "boolean", description: "Restrict external access" },
94
- { name: "ipam", type: "object", description: "IP address management" },
95
- ],
96
- },
97
- {
98
- typeName: "Docker::Compose::Config",
99
- shortName: "Config",
100
- description: "A config definition in Docker Compose",
101
- isResource: true,
102
- properties: [
103
- { name: "file", type: "string", description: "Path to the config file" },
104
- { name: "content", type: "string", description: "Inline config content" },
105
- { name: "external", type: "boolean", description: "Whether the config is external" },
106
- { name: "labels", type: "Record<string, string>", description: "Config labels" },
107
- { name: "name", type: "string", description: "Custom config name" },
108
- ],
109
- },
110
- {
111
- typeName: "Docker::Compose::Secret",
112
- shortName: "Secret",
113
- description: "A secret definition in Docker Compose",
114
- isResource: true,
115
- properties: [
116
- { name: "file", type: "string", description: "Path to the secret file" },
117
- { name: "external", type: "boolean", description: "Whether the secret is external" },
118
- { name: "labels", type: "Record<string, string>", description: "Secret labels" },
119
- { name: "name", type: "string", description: "Custom secret name" },
120
- ],
121
- },
122
- ];
63
+ export function parseComposeSpec(data: Buffer): ComposeParseResult[] {
64
+ const spec = JSON.parse(data.toString("utf-8")) as { $defs?: Record<string, JsonSchema>; definitions?: Record<string, JsonSchema> };
65
+ const defs = spec.$defs ?? spec.definitions ?? {};
66
+
67
+ return ENTITIES.map(({ def, shortName, description }) => {
68
+ const schema = defs[def];
69
+ const required = new Set(schema?.required ?? []);
70
+ const properties: ComposeProperty[] = Object.entries(schema?.properties ?? {}).map(([name, prop]) => ({
71
+ name,
72
+ type: toTsType(prop, defs, new Set([def])),
73
+ ...(prop.description ? { description: oneLine(prop.description) } : {}),
74
+ ...(required.has(name) ? { required: true } : {}),
75
+ }));
76
+ return { typeName: `Docker::Compose::${shortName}`, shortName, description, isResource: true, properties };
77
+ });
78
+ }
79
+
80
+ function oneLine(text: string): string {
81
+ return text.replace(/\s+/g, " ").replace(/\*\//g, "* /").trim();
82
+ }
83
+
84
+ /** A property name as a TypeScript key: bare when it is an identifier. */
85
+ function key(name: string): string {
86
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
87
+ }
88
+
89
+ /** Convert one JSON Schema node to a TypeScript type. `seen` guards $ref cycles. */
90
+ export function toTsType(schema: JsonSchema | boolean | undefined, defs: Record<string, JsonSchema>, seen: Set<string> = new Set()): string {
91
+ if (schema === undefined || schema === true) return "unknown";
92
+ if (schema === false) return "never";
93
+
94
+ if (schema.$ref) {
95
+ const name = schema.$ref.replace(/^#\/(\$defs|definitions)\//, "");
96
+ const target = defs[name];
97
+ if (!target || seen.has(name)) return "unknown";
98
+ return toTsType(target, defs, new Set([...seen, name]));
99
+ }
100
+
101
+ const alts = schema.oneOf ?? schema.anyOf;
102
+ if (alts) {
103
+ // A oneOf beside a type or properties narrows them; the alternatives carry the shape.
104
+ return union(alts.map((a) => toTsType(a, defs, seen)));
105
+ }
106
+ if (schema.allOf) {
107
+ const parts = schema.allOf.map((a) => toTsType(a, defs, seen)).filter((t) => t !== "unknown");
108
+ return parts.length ? parts.map(paren).join(" & ") : "unknown";
109
+ }
110
+
111
+ if (schema.const !== undefined) return JSON.stringify(schema.const);
112
+ if (schema.enum) return union(schema.enum.map((v) => JSON.stringify(v)));
113
+
114
+ const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
115
+ if (types.length === 0) {
116
+ if (schema.properties || schema.patternProperties || schema.additionalProperties !== undefined) return objectType(schema, defs, seen);
117
+ if (schema.items) return `Array<${toTsType(schema.items, defs, seen)}>`;
118
+ return "unknown";
119
+ }
120
+ return union(
121
+ types.map((t) => {
122
+ switch (t) {
123
+ case "string":
124
+ return COMPOSE_STRING;
125
+ case "number":
126
+ case "integer":
127
+ return "number";
128
+ case "boolean":
129
+ return "boolean";
130
+ case "null":
131
+ return "null";
132
+ case "array":
133
+ return `Array<${schema.items ? toTsType(schema.items, defs, seen) : "unknown"}>`;
134
+ case "object":
135
+ return objectType(schema, defs, seen);
136
+ default:
137
+ return "unknown";
138
+ }
139
+ }),
140
+ );
141
+ }
142
+
143
+ function objectType(schema: JsonSchema, defs: Record<string, JsonSchema>, seen: Set<string>): string {
144
+ const props = Object.entries(schema.properties ?? {});
145
+ const patterns = Object.entries(schema.patternProperties ?? {});
146
+ const extensions = patterns.some(([p]) => p === "^x-");
147
+ const others = patterns.filter(([p]) => p !== "^x-");
148
+ const additional = schema.additionalProperties;
149
+
150
+ if (props.length === 0) {
151
+ const values = others.map(([, v]) => toTsType(v, defs, seen));
152
+ if (additional && additional !== true) values.push(toTsType(additional, defs, seen));
153
+ if (values.length) return `Record<string, ${union(values)}>`;
154
+ return additional === false && !extensions ? "Record<string, never>" : "Record<string, unknown>";
155
+ }
156
+
157
+ const required = new Set(schema.required ?? []);
158
+ const fields = props.map(([name, p]) => `${key(name)}${required.has(name) ? "" : "?"}: ${toTsType(p, defs, seen)}`);
159
+ if (extensions) fields.push("[key: `x-${string}`]: unknown");
160
+ // Free-form keys beside the named ones: the named ones keep their types.
161
+ if (others.length || (additional && additional !== true)) fields.push("[key: string]: unknown");
162
+ return `{ ${fields.join("; ")} }`;
163
+ }
164
+
165
+ function paren(t: string): string {
166
+ return /[|&]/.test(t) && !/^[{(]/.test(t) ? `(${t})` : t;
167
+ }
168
+
169
+ function union(types: string[]): string {
170
+ const unique = [...new Set(types.flatMap(splitTop))];
171
+ if (unique.includes("unknown")) return "unknown";
172
+ return unique.join(" | ");
173
+ }
174
+
175
+ /** The members of a union type, split only at the top level. */
176
+ function splitTop(type: string): string[] {
177
+ const out: string[] = [];
178
+ let depth = 0;
179
+ let quote = false;
180
+ let start = 0;
181
+ for (let i = 0; i < type.length; i++) {
182
+ const c = type[i];
183
+ if (c === '"') quote = !quote;
184
+ if (quote) continue;
185
+ if (c === "{" || c === "(" || c === "<" || c === "[") depth++;
186
+ else if (c === "}" || c === ")" || c === ">" || c === "]") depth--;
187
+ else if (depth === 0 && type.startsWith(" | ", i)) {
188
+ out.push(type.slice(start, i));
189
+ start = i + 3;
190
+ i += 2;
191
+ }
192
+ }
193
+ out.push(type.slice(start));
194
+ return out;
123
195
  }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Docker TypeScript that the repo typecheck can't see type-checks against the
3
+ * lexicon (#3934): the examples in the docs and skills, and the source
4
+ * `chant import` writes for the compose fixtures. The constructors take their
5
+ * props types, so an example with a misspelled field or a wrong value type
6
+ * fails here rather than in a reader's editor. Compiling them takes longer
7
+ * than the unit-test budget allows, so this runs with the e2e tests.
8
+ *
9
+ * A snippet that leaves out its import line is checked as if it imported the
10
+ * lexicon names it uses; a titled block or a `// src/x.ts` module block is
11
+ * placed at that path so the page's relative imports resolve.
12
+ */
13
+
14
+ import { describe, test, expect } from "vitest";
15
+ import { readFileSync, readdirSync } from "fs";
16
+ import { join } from "path";
17
+ import * as ts from "typescript";
18
+ import * as lexicon from "./index";
19
+ import { DockerParser } from "./import/parser";
20
+ import { DockerGenerator } from "./import/generator";
21
+
22
+ const root = join(import.meta.dirname, "..");
23
+ const repoRoot = join(root, "..", "..");
24
+ const virtualRoot = join(root, ".typecheck");
25
+
26
+ interface Block {
27
+ lang: string;
28
+ title?: string;
29
+ code: string;
30
+ }
31
+
32
+ function blocks(file: string): Block[] {
33
+ const text = readFileSync(join(root, file), "utf8");
34
+ return [...text.matchAll(/^```(\w+)([^\n]*)\n([\s\S]*?)^```$/gm)].map((m) => {
35
+ const title = m[2]!.match(/title="([^"]+)"/)?.[1];
36
+ return { lang: m[1]!, ...(title ? { title } : {}), code: m[3]! };
37
+ });
38
+ }
39
+
40
+ /** Type errors in in-memory files, compiled against the lexicon's source with the repo typecheck's options. */
41
+ function typeErrors(files: Map<string, string>): string[] {
42
+ const config = ts.readConfigFile(join(repoRoot, "tsconfig.typecheck.json"), ts.sys.readFile);
43
+ const { options } = ts.parseJsonConfigFileContent(config.config, ts.sys, repoRoot);
44
+ const checkOptions = { ...options, noEmit: true, types: [], skipLibCheck: true, declaration: false, declarationMap: false, sourceMap: false };
45
+ const host = ts.createCompilerHost(checkOptions);
46
+ const getSourceFile = host.getSourceFile.bind(host);
47
+ host.getSourceFile = (name, lang, ...rest) =>
48
+ files.has(name) ? ts.createSourceFile(name, files.get(name)!, lang) : getSourceFile(name, lang, ...rest);
49
+ const fileExists = host.fileExists.bind(host);
50
+ host.fileExists = (name) => files.has(name) || fileExists(name);
51
+ const directoryExists = host.directoryExists?.bind(host);
52
+ host.directoryExists = (dir) => [...files.keys()].some((f) => f.startsWith(`${dir}/`)) || (directoryExists?.(dir) ?? false);
53
+ const readFile = host.readFile.bind(host);
54
+ host.readFile = (name) => files.get(name) ?? readFile(name);
55
+
56
+ const program = ts.createProgram([...files.keys()], checkOptions, host);
57
+ // Only the in-memory files are checked; the lexicon itself is the repo typecheck's job.
58
+ return [...files.keys()]
59
+ .flatMap((f) => {
60
+ const sf = program.getSourceFile(f)!;
61
+ return [...program.getSyntacticDiagnostics(sf), ...program.getSemanticDiagnostics(sf)];
62
+ })
63
+ .map((d) => {
64
+ const { line } = d.file!.getLineAndCharacterOfPosition(d.start ?? 0);
65
+ return `${d.file!.fileName.slice(virtualRoot.length + 1)}:${line + 1}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`;
66
+ });
67
+ }
68
+
69
+ describe("docker TypeScript outside the repo typecheck type-checks", () => {
70
+ test("every docker example in the docs and skills", () => {
71
+ const pages = [
72
+ ...readdirSync(join(root, "docs/pages")).filter((f) => f.endsWith(".mdx")).map((f) => `docs/pages/${f}`),
73
+ ...readdirSync(join(root, "src/skills")).filter((f) => f.endsWith(".md")).map((f) => `src/skills/${f}`),
74
+ ];
75
+ const lexiconNames = Object.keys(lexicon);
76
+ const typeNames = ["DockerfileProps", "DockerfileStage", "DockerfileStep", "DockerfileInstructionName", "ServiceProps", "VolumeProps", "NetworkProps", "DockerConfigProps", "DockerSecretProps"];
77
+ const files = new Map<string, string>();
78
+ for (const page of pages) {
79
+ blocks(page).forEach((b, i) => {
80
+ if (b.lang !== "typescript") return;
81
+ const modulePath = b.title ?? b.code.match(/^\/\/ (src\/\S+\.ts)\s*$/m)?.[1];
82
+ if (!modulePath && !/@intentius\/chant-lexicon-docker|new (Dockerfile|Service|Volume|Network|DockerConfig|DockerSecret)\(/.test(b.code)) return;
83
+ // A snippet sits one directory below src/, so `../web/main` and `./shared/node` reach the page's modules.
84
+ const file = join(virtualRoot, page.replace(/[/.]/g, "_"), modulePath ?? `src/snippets/block-${i + 1}.ts`);
85
+ let code = b.code;
86
+ if (!code.includes("@intentius/chant-lexicon-docker")) {
87
+ const used = [...lexiconNames, ...typeNames].filter((n) => new RegExp(`\\b${n}\\b`).test(code));
88
+ if (used.length) code = `import { ${used.join(", ")} } from "@intentius/chant-lexicon-docker";\n${code}`;
89
+ }
90
+ files.set(file, `${code}\nexport {};\n`);
91
+ });
92
+ }
93
+ expect(files.size).toBeGreaterThan(20);
94
+ expect(typeErrors(files)).toEqual([]);
95
+ }, 120_000);
96
+
97
+ test("what importing the compose fixtures writes", () => {
98
+ const testdata = join(root, "src/import/testdata");
99
+ const files = new Map<string, string>();
100
+ for (const fixture of readdirSync(testdata).filter((f) => f.endsWith(".yaml"))) {
101
+ const { source } = new DockerGenerator().generate(new DockerParser().parse(readFileSync(join(testdata, fixture), "utf8")).entities);
102
+ files.set(join(virtualRoot, "import", `${fixture.replace(/\.yaml$/, "")}.ts`), source);
103
+ }
104
+ expect(files.size).toBeGreaterThanOrEqual(4);
105
+ expect(typeErrors(files)).toEqual([]);
106
+ }, 120_000);
107
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The docker constructors take their props types (#3934). The repo typecheck
3
+ * (scripts/typecheck.ts) compiles this file, so each `@ts-expect-error` below
4
+ * fails it if the line it marks stops being a type error: a misspelled key or
5
+ * a wrong-typed field must not compile. The tests themselves only run the
6
+ * well-typed declarations.
7
+ */
8
+
9
+ import { describe, test, expect } from "vitest";
10
+ import { Dockerfile, Service, Volume, Network, DockerConfig, DockerSecret, env } from "./index";
11
+ import type { DockerfileStep } from "./index";
12
+
13
+ describe("docker props are typed", () => {
14
+ test("well-typed props construct", () => {
15
+ const settings = new DockerConfig({ file: "./config/app.conf" });
16
+ const token = new DockerSecret({ file: "./secrets/token.txt" });
17
+ const data = new Volume({ driver: "local" });
18
+ const backend = new Network({ internal: true });
19
+ const app = new Dockerfile({ from: "node:20-alpine", steps: [{ workdir: "/app" }, { run: "npm ci" }] });
20
+ const api = new Service({
21
+ build: { context: ".", dockerfile: app },
22
+ image: env("API_IMAGE", { default: "api:latest" }),
23
+ ports: ["8080:8080", { target: 8080, published: "8080" }],
24
+ configs: [{ source: settings, target: "/etc/app.conf" }],
25
+ secrets: [token],
26
+ networks: [backend],
27
+ volumes: ["data:/var/lib/data"],
28
+ depends_on: { db: { condition: "service_healthy" } },
29
+ });
30
+ for (const entity of [settings, token, data, backend, app, api]) {
31
+ expect(entity).toHaveProperty("entityType");
32
+ }
33
+ });
34
+
35
+ test("misspelled keys and wrong-typed fields are type errors", () => {
36
+ // Only the type checker reads these; they are never constructed.
37
+ const declare = (): unknown[] => [
38
+ // @ts-expect-error `rnu` is not a Dockerfile instruction
39
+ new Dockerfile({ from: "alpine:3.20", steps: [{ rnu: "npm ci" }] }),
40
+ // @ts-expect-error RUN takes a string
41
+ new Dockerfile({ from: "alpine:3.20", steps: [{ run: 42 }] }),
42
+ // @ts-expect-error `form` is not a Dockerfile field
43
+ new Dockerfile({ form: "alpine:3.20" }),
44
+ // @ts-expect-error `imgae` is not a service field
45
+ new Service({ imgae: "nginx:1.27" }),
46
+ // @ts-expect-error `restart` is a string
47
+ new Service({ image: "nginx:1.27", restart: 3 }),
48
+ // @ts-expect-error `condition` is one of the wait conditions
49
+ new Service({ depends_on: { db: { condition: "healthy" } } }),
50
+ // @ts-expect-error `internal` is a boolean
51
+ new Network({ internal: [] }),
52
+ // @ts-expect-error `fiel` is not a config field
53
+ new DockerConfig({ fiel: "./app.conf" }),
54
+ ];
55
+ // @ts-expect-error a step names one instruction it knows
56
+ const step: DockerfileStep = { from: "alpine" };
57
+ expect(typeof declare).toBe("function");
58
+ expect(step).toBeDefined();
59
+ });
60
+ });