@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.
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/generated/index.d.ts +582 -6
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/integrity.json +7 -7
- package/dist/lint/post-synth/cache-busting-copy.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +438 -86
- package/dist/okf/types/DockerConfig.md +7 -5
- package/dist/okf/types/DockerSecret.md +8 -4
- package/dist/okf/types/Network.md +10 -6
- package/dist/okf/types/Service.md +93 -15
- package/dist/okf/types/Volume.md +5 -5
- package/dist/rules/cache-busting-copy.ts +2 -1
- package/dist/skills/chant-docker-patterns.md +2 -2
- package/dist/skills/chant-docker.md +1 -0
- package/dist/spec/parse-compose.d.ts +29 -2
- package/dist/spec/parse-compose.d.ts.map +1 -1
- package/dist/types/index.d.ts +263 -41
- package/package.json +3 -3
- package/src/codegen/__snapshots__/okf.test.ts.snap +123 -35
- package/src/codegen/generate.ts +53 -14
- package/src/codegen/okf.test.ts +1 -1
- package/src/generated/index.d.ts +263 -41
- package/src/generated/index.ts +279 -6
- package/src/generated/lexicon-docker.json +438 -86
- package/src/import/generator.test.ts +4 -4
- package/src/import/generator.ts +4 -4
- package/src/import/roundtrip.test.ts +62 -2
- package/src/import/testdata/configs-secrets.yaml +15 -0
- package/src/lint/post-synth/cache-busting-copy.ts +2 -1
- package/src/skills/chant-docker-patterns.md +2 -2
- package/src/skills/chant-docker.md +1 -0
- package/src/spec/parse-compose.ts +167 -95
- package/src/typecheck.e2e.test.ts +107 -0
- package/src/typed-props.test.ts +60 -0
package/src/import/generator.ts
CHANGED
|
@@ -35,11 +35,11 @@ export class DockerGenerator {
|
|
|
35
35
|
lines.push(generateNetwork(entity));
|
|
36
36
|
break;
|
|
37
37
|
case "config":
|
|
38
|
-
imports.add("
|
|
38
|
+
imports.add("DockerConfig");
|
|
39
39
|
lines.push(generateConfig(entity));
|
|
40
40
|
break;
|
|
41
41
|
case "secret":
|
|
42
|
-
imports.add("
|
|
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
|
|
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
|
|
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
|
|
50
|
-
expect(source).toContain("new
|
|
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
|
+
});
|
|
@@ -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
|
-
|
|
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(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
});
|