@intentius/chant-lexicon-docker 0.124.21 → 0.124.23

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 (119) hide show
  1. package/dist/codegen/docs.d.ts.map +1 -1
  2. package/dist/codegen/generate.d.ts.map +1 -1
  3. package/dist/dockerfile-order.d.ts +13 -0
  4. package/dist/dockerfile-order.d.ts.map +1 -0
  5. package/dist/generated/index.d.ts +703 -6
  6. package/dist/generated/index.d.ts.map +1 -1
  7. package/dist/import/adapter.d.ts.map +1 -1
  8. package/dist/import/parser.d.ts +23 -0
  9. package/dist/import/parser.d.ts.map +1 -1
  10. package/dist/integrity.json +17 -8
  11. package/dist/lint/audit-catalog.d.ts.map +1 -1
  12. package/dist/lint/audit-lineage.d.ts.map +1 -1
  13. package/dist/lint/post-synth/add-url-checksum.d.ts +9 -0
  14. package/dist/lint/post-synth/add-url-checksum.d.ts.map +1 -0
  15. package/dist/lint/post-synth/apt-update-alone.d.ts +10 -0
  16. package/dist/lint/post-synth/apt-update-alone.d.ts.map +1 -0
  17. package/dist/lint/post-synth/cache-busting-copy.d.ts +11 -0
  18. package/dist/lint/post-synth/cache-busting-copy.d.ts.map +1 -0
  19. package/dist/lint/post-synth/copy-from-stage.d.ts +10 -0
  20. package/dist/lint/post-synth/copy-from-stage.d.ts.map +1 -0
  21. package/dist/lint/post-synth/docker-helpers.d.ts +35 -0
  22. package/dist/lint/post-synth/docker-helpers.d.ts.map +1 -1
  23. package/dist/lint/post-synth/index.d.ts.map +1 -1
  24. package/dist/lint/post-synth/no-root-user.d.ts +3 -2
  25. package/dist/lint/post-synth/no-root-user.d.ts.map +1 -1
  26. package/dist/lint/post-synth/relative-workdir.d.ts +10 -0
  27. package/dist/lint/post-synth/relative-workdir.d.ts.map +1 -0
  28. package/dist/lint/post-synth/repeated-cmd.d.ts +9 -0
  29. package/dist/lint/post-synth/repeated-cmd.d.ts.map +1 -0
  30. package/dist/lint/post-synth/root-after-user.d.ts +10 -0
  31. package/dist/lint/post-synth/root-after-user.d.ts.map +1 -0
  32. package/dist/lint/post-synth/unused-global-arg.d.ts +10 -0
  33. package/dist/lint/post-synth/unused-global-arg.d.ts.map +1 -0
  34. package/dist/lint/rules/index.d.ts +1 -0
  35. package/dist/lint/rules/index.d.ts.map +1 -1
  36. package/dist/lint/rules/per-kind-order.d.ts +15 -0
  37. package/dist/lint/rules/per-kind-order.d.ts.map +1 -0
  38. package/dist/manifest.json +1 -1
  39. package/dist/meta.json +444 -86
  40. package/dist/okf/index.md +10 -1
  41. package/dist/okf/rules/DKRD012.md +2 -2
  42. package/dist/okf/rules/DKRD013.md +11 -0
  43. package/dist/okf/rules/DKRD014.md +11 -0
  44. package/dist/okf/rules/DKRD015.md +11 -0
  45. package/dist/okf/rules/DKRD016.md +11 -0
  46. package/dist/okf/rules/DKRD017.md +11 -0
  47. package/dist/okf/rules/DKRD018.md +11 -0
  48. package/dist/okf/rules/DKRD019.md +11 -0
  49. package/dist/okf/rules/DKRD020.md +11 -0
  50. package/dist/okf/rules/DKRS002.md +15 -0
  51. package/dist/okf/types/DockerConfig.md +7 -5
  52. package/dist/okf/types/DockerSecret.md +8 -4
  53. package/dist/okf/types/Dockerfile.md +4 -1
  54. package/dist/okf/types/Network.md +10 -6
  55. package/dist/okf/types/Service.md +93 -15
  56. package/dist/okf/types/Volume.md +5 -5
  57. package/dist/plugin.d.ts.map +1 -1
  58. package/dist/rules/add-url-checksum.ts +38 -0
  59. package/dist/rules/apt-update-alone.ts +39 -0
  60. package/dist/rules/cache-busting-copy.ts +65 -0
  61. package/dist/rules/copy-from-stage.ts +54 -0
  62. package/dist/rules/docker-helpers.ts +80 -0
  63. package/dist/rules/no-root-user.ts +19 -17
  64. package/dist/rules/per-kind-order.ts +130 -0
  65. package/dist/rules/relative-workdir.ts +38 -0
  66. package/dist/rules/repeated-cmd.ts +35 -0
  67. package/dist/rules/root-after-user.ts +44 -0
  68. package/dist/rules/unused-global-arg.ts +38 -0
  69. package/dist/serializer.d.ts.map +1 -1
  70. package/dist/skills/chant-docker-patterns.md +93 -24
  71. package/dist/skills/chant-docker.md +43 -16
  72. package/dist/spec/parse-compose.d.ts +29 -2
  73. package/dist/spec/parse-compose.d.ts.map +1 -1
  74. package/dist/spec/parse-engine.d.ts +9 -0
  75. package/dist/spec/parse-engine.d.ts.map +1 -1
  76. package/dist/types/index.d.ts +344 -41
  77. package/package.json +3 -3
  78. package/src/codegen/__snapshots__/okf.test.ts.snap +278 -39
  79. package/src/codegen/docs.ts +14 -0
  80. package/src/codegen/generate.ts +128 -31
  81. package/src/codegen/okf.test.ts +1 -1
  82. package/src/dockerfile-order.ts +20 -0
  83. package/src/docs-examples.test.ts +141 -0
  84. package/src/generated/index.d.ts +344 -41
  85. package/src/generated/index.ts +396 -6
  86. package/src/generated/lexicon-docker.json +444 -86
  87. package/src/import/adapter.ts +8 -2
  88. package/src/import/generator.test.ts +4 -4
  89. package/src/import/generator.ts +53 -37
  90. package/src/import/parser.ts +82 -13
  91. package/src/import/roundtrip.test.ts +167 -2
  92. package/src/import/testdata/configs-secrets.yaml +15 -0
  93. package/src/lint/audit-catalog.ts +9 -1
  94. package/src/lint/audit-lineage.ts +14 -0
  95. package/src/lint/post-synth/add-url-checksum.ts +38 -0
  96. package/src/lint/post-synth/apt-update-alone.ts +39 -0
  97. package/src/lint/post-synth/cache-busting-copy.ts +65 -0
  98. package/src/lint/post-synth/copy-from-stage.ts +54 -0
  99. package/src/lint/post-synth/docker-helpers.ts +80 -0
  100. package/src/lint/post-synth/index.ts +16 -0
  101. package/src/lint/post-synth/no-root-user.ts +19 -17
  102. package/src/lint/post-synth/post-synth.test.ts +149 -0
  103. package/src/lint/post-synth/relative-workdir.ts +38 -0
  104. package/src/lint/post-synth/repeated-cmd.ts +35 -0
  105. package/src/lint/post-synth/root-after-user.ts +44 -0
  106. package/src/lint/post-synth/unused-global-arg.ts +38 -0
  107. package/src/lint/rules/index.ts +1 -0
  108. package/src/lint/rules/per-kind-order.ts +130 -0
  109. package/src/lint/rules/rules.test.ts +88 -0
  110. package/src/plugin.test.ts +4 -0
  111. package/src/plugin.ts +2 -1
  112. package/src/serializer.test.ts +112 -0
  113. package/src/serializer.ts +58 -11
  114. package/src/skills/chant-docker-patterns.md +93 -24
  115. package/src/skills/chant-docker.md +43 -16
  116. package/src/spec/parse-compose.ts +167 -95
  117. package/src/spec/parse-engine.ts +17 -5
  118. package/src/typecheck.e2e.test.ts +107 -0
  119. package/src/typed-props.test.ts +60 -0
@@ -10,8 +10,9 @@ import { join } from "path";
10
10
  import { generateComposePipeline } from "./generate-compose";
11
11
  import { generateDockerfilePipeline } from "./generate-dockerfile";
12
12
  import { entityTypeToTsName } from "./naming";
13
- import type { ComposeParseResult } from "../spec/parse-compose";
14
- import type { DockerfileParseResult } from "../spec/parse-engine";
13
+ import { PER_KIND_ORDER } from "../dockerfile-order";
14
+ import { COMPOSE_STRING, type ComposeParseResult } from "../spec/parse-compose";
15
+ import type { DockerfileParseResult, DockerfileInstruction } from "../spec/parse-engine";
15
16
 
16
17
  export interface DockerGenerateResult {
17
18
  resources: number;
@@ -72,9 +73,13 @@ function generateLexiconJSON(compose: ComposeParseResult[], dockerfile: Dockerfi
72
73
  resourceType: dockerfile.typeName,
73
74
  kind: "resource",
74
75
  description: dockerfile.description ?? "",
75
- properties: Object.fromEntries(
76
- dockerfile.instructions.map((i) => [i.name, { description: i.description ?? "" }]),
77
- ),
76
+ properties: {
77
+ ...Object.fromEntries(
78
+ dockerfile.instructions.filter((i) => !i.stepOnly).map((i) => [i.name, { description: i.description ?? "" }]),
79
+ ),
80
+ globalArgs: { description: "ARG instructions written before the first FROM" },
81
+ steps: { description: "Instructions after FROM, written in the order given; one instruction per step" },
82
+ },
78
83
  };
79
84
 
80
85
  return JSON.stringify(entries, null, 2);
@@ -85,37 +90,23 @@ function generateTypesDTS(compose: ComposeParseResult[], dockerfile: DockerfileP
85
90
  "// Code generated by chant generate. DO NOT EDIT.",
86
91
  'import type { Declarable } from "@intentius/chant/declarable";',
87
92
  "",
93
+ // This file is type-checked on its own (validate's types-compile), so it
94
+ // can't import the interpolation module; an env() value is matched by shape.
95
+ "/** An `env()` interpolation, as returned by the lexicon's `env`. */",
96
+ "interface EnvIntrinsic { toJSON(): string; toString(): string; }",
97
+ "",
88
98
  ];
89
99
 
100
+ lines.push(...composeTypeLines(compose));
90
101
  for (const c of compose) {
91
102
  const tsName = entityTypeToTsName(c.typeName);
92
- lines.push(`export interface ${tsName}Props {`);
93
- for (const p of c.properties) {
94
- const opt = p.required ? "" : "?";
95
- lines.push(` ${p.name}${opt}: ${p.type};`);
96
- }
97
- lines.push(`}`);
98
- lines.push(`export declare const ${tsName}: new (props?: ${tsName}Props) => Declarable;`);
99
- lines.push("");
103
+ lines.push(`export declare const ${tsName}: new (props?: ${tsName}Props, attributes?: Record<string, unknown>) => Declarable;`);
100
104
  }
105
+ lines.push("");
101
106
 
102
107
  const dfName = entityTypeToTsName(dockerfile.typeName);
103
- lines.push(`export interface DockerfileStage {`);
104
- lines.push(` from: string;`);
105
- lines.push(` as?: string;`);
106
- for (const i of dockerfile.instructions.filter((i) => i.name !== "from")) {
107
- lines.push(` ${i.name}?: ${i.multi ? "string[]" : "string"};`);
108
- }
109
- lines.push(`}`);
110
- lines.push(`export interface DockerfileProps {`);
111
- lines.push(` name?: string;`);
112
- lines.push(` from?: string;`);
113
- lines.push(` stages?: DockerfileStage[];`);
114
- for (const i of dockerfile.instructions.filter((i) => i.name !== "from")) {
115
- lines.push(` ${i.name}?: ${i.multi ? "string[]" : "string"};`);
116
- }
117
- lines.push(`}`);
118
- lines.push(`export declare const ${dfName}: new (props?: DockerfileProps) => Declarable;`);
108
+ lines.push(...dockerfileTypeLines(dockerfile));
109
+ lines.push(`export declare const ${dfName}: new (props?: DockerfileProps, attributes?: Record<string, unknown>) => Declarable;`);
119
110
  lines.push("");
120
111
 
121
112
  return lines.join("\n");
@@ -125,22 +116,128 @@ function generateIndexTS(compose: ComposeParseResult[], dockerfile: DockerfilePa
125
116
  const lines: string[] = [
126
117
  "// Code generated by chant generate. DO NOT EDIT.",
127
118
  'import { createResource } from "./runtime";',
119
+ 'import type { Declarable } from "@intentius/chant/declarable";',
120
+ 'import type { EnvIntrinsic } from "../interpolation";',
121
+ "",
122
+ // createResource returns a constructor with untyped props. Each constructor
123
+ // below is cast to one that takes its props type, so a misspelled key or a
124
+ // wrong field type is a compile error where the resource is written, and
125
+ // the editor completes the fields (#3934).
126
+ "/** A docker resource constructor whose props are type-checked. */",
127
+ "export type DockerResourceConstructor<P> = new (props?: P, attributes?: Record<string, unknown>) => InstanceType<ReturnType<typeof createResource>>;",
128
128
  "",
129
129
  ];
130
130
 
131
+ lines.push(...composeTypeLines(compose));
131
132
  for (const c of compose) {
132
133
  const tsName = entityTypeToTsName(c.typeName);
133
- lines.push(`export const ${tsName} = createResource("${c.typeName}", "docker", {});`);
134
+ lines.push(
135
+ `export const ${tsName} = createResource("${c.typeName}", "docker", {}) as unknown as DockerResourceConstructor<${tsName}Props>;`,
136
+ );
134
137
  }
135
138
 
139
+ lines.push("");
140
+ // The Dockerfile prop types are exported from here too (not only from
141
+ // index.d.ts, which index.ts shadows) so a module can type a shared stage
142
+ // or step list: `export const install: DockerfileStep[] = [...]`.
143
+ lines.push(...dockerfileTypeLines(dockerfile));
136
144
  lines.push("");
137
145
  const dfName = entityTypeToTsName(dockerfile.typeName);
138
- lines.push(`export const ${dfName} = createResource("${dockerfile.typeName}", "docker", {});`);
146
+ lines.push(
147
+ `export const ${dfName} = createResource("${dockerfile.typeName}", "docker", {}) as unknown as DockerResourceConstructor<DockerfileProps>;`,
148
+ );
139
149
  lines.push("");
140
150
 
141
151
  return lines.join("\n");
142
152
  }
143
153
 
154
+ /** One props interface per Compose entity. */
155
+ function composeTypeLines(compose: ComposeParseResult[]): string[] {
156
+ const lines: string[] = [
157
+ "/**",
158
+ " * A Compose string value: a string, an `env()` interpolation, or an entity",
159
+ " * (service, network, volume, config, secret or Dockerfile), which is written as its name.",
160
+ " */",
161
+ `export type ${COMPOSE_STRING} = string | EnvIntrinsic | Declarable;`,
162
+ "",
163
+ ];
164
+ for (const c of compose) {
165
+ const tsName = entityTypeToTsName(c.typeName);
166
+ if (c.description) lines.push(`/** ${c.description} */`);
167
+ lines.push(`export interface ${tsName}Props {`);
168
+ for (const p of c.properties) {
169
+ const opt = p.required ? "" : "?";
170
+ if (p.description) lines.push(` /** ${p.description} */`);
171
+ lines.push(` ${p.name}${opt}: ${p.type};`);
172
+ }
173
+ lines.push(`}`);
174
+ lines.push("");
175
+ }
176
+ return lines;
177
+ }
178
+
179
+ /**
180
+ * The Dockerfile prop types: one `DockerfileStep` variant per instruction,
181
+ * the stage, and the props. The per-kind fields keep the instructions they
182
+ * always had; SHELL, STOPSIGNAL and ONBUILD are steps only.
183
+ */
184
+ function dockerfileTypeLines(dockerfile: DockerfileParseResult): string[] {
185
+ const lines: string[] = [];
186
+ const stepInstructions = dockerfile.instructions.filter((i) => i.name !== "from");
187
+ const perKind = stepInstructions.filter((i) => !i.stepOnly);
188
+ const stepValue = (i: DockerfileInstruction): string =>
189
+ ["string", ...(i.numeric ? ["number"] : []), ...(i.execForm ? ["string[]"] : [])].join(" | ");
190
+ const fixed = PER_KIND_ORDER.map((k) => k.toUpperCase()).join(", ");
191
+
192
+ lines.push(`/** The Dockerfile instructions a step can name. FROM is the stage's own \`from\` field. */`);
193
+ lines.push(`export type DockerfileInstructionName = ${stepInstructions.map((i) => JSON.stringify(i.name)).join(" | ")};`);
194
+ lines.push(`type DockerfileStepOf<K extends DockerfileInstructionName, V> = { [P in K]: V } & { [P in Exclude<DockerfileInstructionName, K>]?: never };`);
195
+ lines.push(`/**`);
196
+ lines.push(` * One Dockerfile instruction, named by its single key: \`{ run: "npm ci" }\` is \`RUN npm ci\`.`);
197
+ lines.push(` * A string[] value (run, cmd, entrypoint, shell) is written in JSON exec form.`);
198
+ lines.push(` */`);
199
+ lines.push(`export type DockerfileStep =`);
200
+ for (const i of stepInstructions) {
201
+ lines.push(` /** ${i.name.toUpperCase()}: ${i.description ?? ""} */`);
202
+ lines.push(` | DockerfileStepOf<${JSON.stringify(i.name)}, ${stepValue(i)}>`);
203
+ }
204
+ lines[lines.length - 1] += ";";
205
+
206
+ const fields = (): void => {
207
+ lines.push(` /** Instructions after FROM, written exactly in this order. Can't be combined with the per-kind fields. */`);
208
+ lines.push(` steps?: DockerfileStep[];`);
209
+ for (const i of perKind) {
210
+ lines.push(` /** ${i.name.toUpperCase()} as a per-kind field, written in the fixed order. Use \`steps\` to choose the order. */`);
211
+ lines.push(` ${i.name}?: ${i.multi ? "string[]" : "string"};`);
212
+ }
213
+ };
214
+ const fixedDoc = ` * Without \`steps\`, the per-kind fields are written in a fixed order: ${fixed}.`;
215
+
216
+ lines.push(`/**`);
217
+ lines.push(` * One build stage: FROM, then its instructions.`);
218
+ lines.push(fixedDoc);
219
+ lines.push(` */`);
220
+ lines.push(`export interface DockerfileStage {`);
221
+ lines.push(` from: string;`);
222
+ lines.push(` as?: string;`);
223
+ fields();
224
+ lines.push(`}`);
225
+ lines.push(`/**`);
226
+ lines.push(` * A Dockerfile: \`stages\` for a multi-stage build, or \`from\` and the stage fields for one stage.`);
227
+ lines.push(fixedDoc);
228
+ lines.push(` */`);
229
+ lines.push(`export interface DockerfileProps {`);
230
+ lines.push(` name?: string;`);
231
+ lines.push(` /** ARG instructions written before the first FROM, e.g. \`["NODE_VERSION=20"]\`. */`);
232
+ lines.push(` globalArgs?: string[];`);
233
+ lines.push(` from?: string;`);
234
+ lines.push(` as?: string;`);
235
+ lines.push(` stages?: DockerfileStage[];`);
236
+ fields();
237
+ lines.push(`}`);
238
+ return lines;
239
+ }
240
+
144
241
  /**
145
242
  * Write generated artifacts to disk under baseDir/src/generated/.
146
243
  */
@@ -25,7 +25,7 @@ describe("docker OKF bundle", () => {
25
25
  expect(front.type).toBe("resource-type");
26
26
  expect(front.resource_type).toBe("Docker::Compose::Service");
27
27
  // Registry-carried property description reaches the concept body.
28
- expect(service.body).toContain("- `image` (`string`): Container image to use");
28
+ expect(service.body).toContain("- `image` (`ComposeString`): Specify the image to start the container from.");
29
29
  // The no-latest-tag rule governs Service…
30
30
  expect(service.body).toContain("[DKRS001](/rules/DKRS001.md)");
31
31
  // …and points back at it.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The order a Dockerfile stage's per-kind fields (`run`, `copy`, `user`, ...)
3
+ * are written in when the stage has no `steps`. It never changes, so a
4
+ * Dockerfile declared with per-kind fields builds the same output it always
5
+ * has; `steps` is how a stage controls its own order.
6
+ */
7
+ export const PER_KIND_ORDER = [
8
+ "arg", "env", "workdir", "user", "run", "copy", "add",
9
+ "expose", "volume", "label", "entrypoint", "cmd", "healthcheck",
10
+ ] as const;
11
+
12
+ export type PerKindField = (typeof PER_KIND_ORDER)[number];
13
+
14
+ /** The step keys whose array value is written in JSON exec form. */
15
+ export const EXEC_FORM_STEPS: ReadonlySet<string> = new Set(["run", "cmd", "entrypoint", "shell"]);
16
+
17
+ /** Every instruction a step can name. FROM is the stage's own field. */
18
+ export const STEP_INSTRUCTIONS: ReadonlySet<string> = new Set([
19
+ ...PER_KIND_ORDER, "shell", "stopsignal", "onbuild",
20
+ ]);
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The Dockerfile examples in the docs and skills build, and build what the
3
+ * page says they do.
4
+ *
5
+ * - A ```typescript block with no title that declares a Dockerfile is run
6
+ * and serialized. When the next code block is an untitled ```dockerfile
7
+ * block, the built file must equal it.
8
+ * - A titled ```typescript block (`title="src/web.ts"`) must equal that file
9
+ * in examples/dockerfile-sharing, and a titled ```dockerfile block
10
+ * (`title="Dockerfile.web"`) must equal that file from building the
11
+ * example.
12
+ * - In importing.mdx, importing the ```dockerfile block titled "Dockerfile"
13
+ * must give the ```typescript block that follows it.
14
+ */
15
+
16
+ import { describe, test, expect } from "vitest";
17
+ import { readFileSync } from "fs";
18
+ import { join } from "path";
19
+ import * as ts from "typescript";
20
+ import { build } from "@intentius/chant/build";
21
+ import * as chant from "@intentius/chant";
22
+ import type { Declarable } from "@intentius/chant/declarable";
23
+ import * as lexicon from "./index";
24
+ import { dockerSerializer } from "./serializer";
25
+ import { DockerTemplateGenerator, DockerTemplateParser } from "./import/adapter";
26
+
27
+ const root = join(import.meta.dirname, "..");
28
+ const sharingExample = join(root, "examples", "dockerfile-sharing");
29
+
30
+ interface Block {
31
+ lang: string;
32
+ title?: string;
33
+ code: string;
34
+ /** The prose between the previous code block and this one. */
35
+ before: string;
36
+ }
37
+
38
+ function blocks(file: string): Block[] {
39
+ const text = readFileSync(join(root, file), "utf8");
40
+ const out: Block[] = [];
41
+ let last = 0;
42
+ for (const m of text.matchAll(/^```(\w+)([^\n]*)\n([\s\S]*?)^```$/gm)) {
43
+ const title = m[2]!.match(/title="([^"]+)"/)?.[1];
44
+ out.push({ lang: m[1]!, ...(title ? { title } : {}), code: m[3]!, before: text.slice(last, m.index) });
45
+ last = m.index! + m[0].length;
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Run one TypeScript block as a module and serialize the Dockerfiles it
52
+ * exports. A block whose first line is a path comment (`// src/web.ts`) is
53
+ * one module of a multi-file example: `modules` holds the exports of the
54
+ * page's earlier modules, by path, for its relative imports.
55
+ */
56
+ function buildBlock(code: string, pageModules: Map<string, Record<string, unknown>> = new Map()): Record<string, string> {
57
+ const js = ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
58
+ const modules: Record<string, unknown> = { "@intentius/chant-lexicon-docker": lexicon, "@intentius/chant": chant };
59
+ const path = code.match(/^\/\/ (src\/\S+)\.ts\s*$/m)?.[1];
60
+ const req = (name: string): unknown => {
61
+ if (name.startsWith("./") && path) {
62
+ const target = join(path, "..", name).replace(/\\/g, "/");
63
+ const found = pageModules.get(target);
64
+ if (found) return found;
65
+ }
66
+ if (!(name in modules)) throw new Error(`docs example imports ${name}, which this test does not provide`);
67
+ return modules[name];
68
+ };
69
+ const mod = { exports: {} as Record<string, unknown> };
70
+ // Snippets that leave out the import line still see the lexicon's exports.
71
+ const globals = Object.keys(lexicon).filter((k) => /^[A-Za-z_$][\w$]*$/.test(k));
72
+ new Function("require", "module", "exports", ...globals, js)(req, mod, mod.exports, ...globals.map((k) => (lexicon as Record<string, unknown>)[k]));
73
+ if (path) pageModules.set(path, mod.exports);
74
+ const entities = new Map<string, Declarable>();
75
+ for (const [name, value] of Object.entries(mod.exports)) {
76
+ if (value && typeof value === "object" && "entityType" in value) entities.set(name, value as Declarable);
77
+ }
78
+ const result = dockerSerializer.serialize(entities);
79
+ return typeof result === "string" ? {} : (result.files ?? {});
80
+ }
81
+
82
+ const PAGES = [
83
+ "docs/pages/dockerfiles.mdx",
84
+ "docs/pages/examples.mdx",
85
+ "docs/pages/lint-rules.mdx",
86
+ "src/skills/chant-docker.md",
87
+ "src/skills/chant-docker-patterns.md",
88
+ ];
89
+
90
+ describe("docs Dockerfile examples", () => {
91
+ for (const page of PAGES) {
92
+ test(`${page}: every Dockerfile example builds what the page shows`, () => {
93
+ const list = blocks(page);
94
+ const pageModules = new Map<string, Record<string, unknown>>();
95
+ let checked = 0;
96
+ list.forEach((b, i) => {
97
+ if (b.lang !== "typescript" || b.title) return;
98
+ const isModule = /^\/\/ src\/\S+\.ts\s*$/m.test(b.code);
99
+ if (!b.code.includes("new Dockerfile(") && !isModule) return;
100
+ const files = buildBlock(b.code, pageModules);
101
+ if (!b.code.includes("new Dockerfile(")) return;
102
+ expect(Object.keys(files).length, `${page} block ${i + 1} declares a Dockerfile`).toBeGreaterThan(0);
103
+ checked++;
104
+ // The output shown right after it, under a "`Dockerfile.name`:" line.
105
+ const next = list[i + 1];
106
+ if (next && next.lang === "dockerfile" && !next.title && /^\s*`Dockerfile\.\w+`:\s*$/.test(next.before)) {
107
+ expect(Object.values(files).join("---\n"), `${page} block ${i + 1}`).toBe(next.code);
108
+ }
109
+ });
110
+ expect(checked, `${page} has Dockerfile examples`).toBeGreaterThan(0);
111
+ });
112
+ }
113
+
114
+ test("dockerfiles.mdx sharing section matches the dockerfile-sharing example and its build", async () => {
115
+ const titled = blocks("docs/pages/dockerfiles.mdx").filter((b) => b.title);
116
+ const sources = titled.filter((b) => b.lang === "typescript");
117
+ const outputs = titled.filter((b) => b.lang === "dockerfile");
118
+ expect(sources.map((b) => b.title)).toEqual(["src/shared/node.ts", "src/web.ts", "src/worker.ts", "src/shared/node-image.ts", "src/api.ts"]);
119
+ for (const b of sources) {
120
+ expect(b.code, b.title).toBe(readFileSync(join(sharingExample, b.title!), "utf8"));
121
+ }
122
+
123
+ const result = await build(join(sharingExample, "src"), [dockerSerializer]);
124
+ expect(result.errors).toEqual([]);
125
+ const files = (result.outputs.get("docker") as unknown as { files: Record<string, string> }).files;
126
+ expect(outputs.map((b) => b.title).sort()).toEqual(Object.keys(files).sort());
127
+ for (const b of outputs) {
128
+ expect(b.code, b.title).toBe(files[b.title!]);
129
+ }
130
+ });
131
+
132
+ test("importing.mdx shows what importing its Dockerfile produces", () => {
133
+ const list = blocks("docs/pages/importing.mdx");
134
+ const at = list.findIndex((b) => b.lang === "dockerfile" && b.title === "Dockerfile");
135
+ expect(at).toBeGreaterThan(-1);
136
+ const [file] = new DockerTemplateGenerator().generate(new DockerTemplateParser().parse(list[at]!.code));
137
+ expect(list[at + 1]!.code).toBe(file!.content.endsWith("\n") ? file!.content : `${file!.content}\n`);
138
+ // And building the import gives the Dockerfile back.
139
+ expect(Object.values(buildBlock(list[at + 1]!.code))[0]).toBe(list[at]!.code.replace(/^#.*\n/gm, ""));
140
+ });
141
+ });