@intentius/chant-lexicon-docker 0.1.0
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/README.md +24 -0
- package/dist/integrity.json +19 -0
- package/dist/manifest.json +15 -0
- package/dist/meta.json +222 -0
- package/dist/rules/apt-no-recommends.ts +43 -0
- package/dist/rules/docker-helpers.ts +114 -0
- package/dist/rules/no-latest-image.ts +36 -0
- package/dist/rules/no-latest-tag.ts +63 -0
- package/dist/rules/no-root-user.ts +36 -0
- package/dist/rules/prefer-copy.ts +53 -0
- package/dist/rules/ssh-port-exposed.ts +68 -0
- package/dist/rules/unused-volume.ts +49 -0
- package/dist/skills/chant-docker-patterns.md +153 -0
- package/dist/skills/chant-docker.md +129 -0
- package/dist/types/index.d.ts +93 -0
- package/package.json +53 -0
- package/src/codegen/docs-cli.ts +10 -0
- package/src/codegen/docs.ts +12 -0
- package/src/codegen/generate-cli.ts +36 -0
- package/src/codegen/generate-compose.ts +21 -0
- package/src/codegen/generate-dockerfile.ts +21 -0
- package/src/codegen/generate.test.ts +105 -0
- package/src/codegen/generate.ts +158 -0
- package/src/codegen/naming.test.ts +81 -0
- package/src/codegen/naming.ts +54 -0
- package/src/codegen/package.ts +65 -0
- package/src/codegen/patches.ts +42 -0
- package/src/codegen/versions.ts +15 -0
- package/src/composites/index.ts +12 -0
- package/src/coverage.test.ts +33 -0
- package/src/coverage.ts +54 -0
- package/src/default-labels.test.ts +85 -0
- package/src/default-labels.ts +72 -0
- package/src/generated/index.d.ts +93 -0
- package/src/generated/index.ts +10 -0
- package/src/generated/lexicon-docker.json +222 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +133 -0
- package/src/import/generator.ts +127 -0
- package/src/import/parser.test.ts +137 -0
- package/src/import/parser.ts +190 -0
- package/src/import/roundtrip.test.ts +49 -0
- package/src/import/testdata/full.yaml +43 -0
- package/src/import/testdata/simple.yaml +9 -0
- package/src/import/testdata/webapp.yaml +41 -0
- package/src/index.ts +29 -0
- package/src/interpolation.test.ts +41 -0
- package/src/interpolation.ts +76 -0
- package/src/lint/post-synth/apt-no-recommends.ts +43 -0
- package/src/lint/post-synth/docker-helpers.ts +114 -0
- package/src/lint/post-synth/no-latest-image.ts +36 -0
- package/src/lint/post-synth/no-root-user.ts +36 -0
- package/src/lint/post-synth/post-synth.test.ts +181 -0
- package/src/lint/post-synth/prefer-copy.ts +53 -0
- package/src/lint/post-synth/ssh-port-exposed.ts +68 -0
- package/src/lint/post-synth/unused-volume.ts +49 -0
- package/src/lint/rules/data/deprecated-images.ts +28 -0
- package/src/lint/rules/data/known-base-images.ts +20 -0
- package/src/lint/rules/index.ts +5 -0
- package/src/lint/rules/no-latest-tag.ts +63 -0
- package/src/lint/rules/rules.test.ts +82 -0
- package/src/lsp/completions.test.ts +34 -0
- package/src/lsp/completions.ts +20 -0
- package/src/lsp/hover.test.ts +34 -0
- package/src/lsp/hover.ts +38 -0
- package/src/package-cli.ts +42 -0
- package/src/plugin.test.ts +117 -0
- package/src/plugin.ts +250 -0
- package/src/serializer.test.ts +294 -0
- package/src/serializer.ts +322 -0
- package/src/skills/chant-docker-patterns.md +153 -0
- package/src/skills/chant-docker.md +129 -0
- package/src/spec/fetch-compose.ts +35 -0
- package/src/spec/fetch-engine.ts +25 -0
- package/src/spec/parse-compose.ts +110 -0
- package/src/spec/parse-engine.ts +47 -0
- package/src/validate-cli.ts +19 -0
- package/src/validate.test.ts +16 -0
- package/src/validate.ts +44 -0
- package/src/variables.test.ts +32 -0
- package/src/variables.ts +47 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from "bun:test";
|
|
2
|
+
import { generate, writeGeneratedFiles } from "./generate";
|
|
3
|
+
import { mkdirSync, rmSync, existsSync, readFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
|
|
7
|
+
// Mock the network fetches so tests run offline and fast
|
|
8
|
+
mock.module("../spec/fetch-compose", () => ({
|
|
9
|
+
fetchComposeSpec: async () => Buffer.from("{}"),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
mock.module("../spec/fetch-engine", () => ({
|
|
13
|
+
fetchEngineApi: async () => Buffer.from("{}"),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe("generate()", () => {
|
|
17
|
+
test("returns a GenerateResult with correct shape", async () => {
|
|
18
|
+
const result = await generate({ verbose: false });
|
|
19
|
+
|
|
20
|
+
expect(result).toBeDefined();
|
|
21
|
+
expect(typeof result.resources).toBe("number");
|
|
22
|
+
expect(typeof result.properties).toBe("number");
|
|
23
|
+
expect(typeof result.enums).toBe("number");
|
|
24
|
+
expect(Array.isArray(result.warnings)).toBe(true);
|
|
25
|
+
expect(typeof result.lexiconJSON).toBe("string");
|
|
26
|
+
expect(typeof result.typesDTS).toBe("string");
|
|
27
|
+
expect(typeof result.indexTS).toBe("string");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("generates all 6 entity types", async () => {
|
|
31
|
+
const result = await generate({ verbose: false });
|
|
32
|
+
expect(result.resources).toBe(6); // 5 Compose + 1 Dockerfile
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("lexiconJSON includes all entity class names", async () => {
|
|
36
|
+
const result = await generate({ verbose: false });
|
|
37
|
+
const json = JSON.parse(result.lexiconJSON);
|
|
38
|
+
|
|
39
|
+
expect(json.Service).toBeDefined();
|
|
40
|
+
expect(json.Volume).toBeDefined();
|
|
41
|
+
expect(json.Network).toBeDefined();
|
|
42
|
+
expect(json.DockerConfig).toBeDefined();
|
|
43
|
+
expect(json.DockerSecret).toBeDefined();
|
|
44
|
+
expect(json.Dockerfile).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("lexiconJSON entries have resourceType and kind", async () => {
|
|
48
|
+
const result = await generate({ verbose: false });
|
|
49
|
+
const json = JSON.parse(result.lexiconJSON);
|
|
50
|
+
|
|
51
|
+
expect(json.Service.resourceType).toBe("Docker::Compose::Service");
|
|
52
|
+
expect(json.Service.kind).toBe("resource");
|
|
53
|
+
expect(json.Dockerfile.resourceType).toBe("Docker::Dockerfile");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("indexTS exports all entity names", async () => {
|
|
57
|
+
const result = await generate({ verbose: false });
|
|
58
|
+
expect(result.indexTS).toContain("export const Service");
|
|
59
|
+
expect(result.indexTS).toContain("export const Dockerfile");
|
|
60
|
+
expect(result.indexTS).toContain('createResource("Docker::Compose::Service"');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("typesDTS declares all entity interfaces", async () => {
|
|
64
|
+
const result = await generate({ verbose: false });
|
|
65
|
+
expect(result.typesDTS).toContain("interface ServiceProps");
|
|
66
|
+
expect(result.typesDTS).toContain("interface DockerfileProps");
|
|
67
|
+
expect(result.typesDTS).toContain("declare const Service");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("properties count is non-zero", async () => {
|
|
71
|
+
const result = await generate({ verbose: false });
|
|
72
|
+
expect(result.properties).toBeGreaterThan(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("writeGeneratedFiles()", () => {
|
|
77
|
+
const tmpDir = join(tmpdir(), `chant-docker-test-${Date.now()}`);
|
|
78
|
+
|
|
79
|
+
test("writes all expected files to src/generated/", async () => {
|
|
80
|
+
mkdirSync(join(tmpDir, "src", "generated"), { recursive: true });
|
|
81
|
+
|
|
82
|
+
const result = await generate({ verbose: false });
|
|
83
|
+
writeGeneratedFiles(result, tmpDir);
|
|
84
|
+
|
|
85
|
+
expect(existsSync(join(tmpDir, "src", "generated", "lexicon-docker.json"))).toBe(true);
|
|
86
|
+
expect(existsSync(join(tmpDir, "src", "generated", "index.ts"))).toBe(true);
|
|
87
|
+
expect(existsSync(join(tmpDir, "src", "generated", "index.d.ts"))).toBe(true);
|
|
88
|
+
expect(existsSync(join(tmpDir, "src", "generated", "runtime.ts"))).toBe(true);
|
|
89
|
+
|
|
90
|
+
// Clean up
|
|
91
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("lexicon-docker.json is valid JSON", async () => {
|
|
95
|
+
mkdirSync(join(tmpDir, "src", "generated"), { recursive: true });
|
|
96
|
+
|
|
97
|
+
const result = await generate({ verbose: false });
|
|
98
|
+
writeGeneratedFiles(result, tmpDir);
|
|
99
|
+
|
|
100
|
+
const json = readFileSync(join(tmpDir, "src", "generated", "lexicon-docker.json"), "utf-8");
|
|
101
|
+
expect(() => JSON.parse(json)).not.toThrow();
|
|
102
|
+
|
|
103
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker lexicon generation pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates both Compose and Dockerfile type generation,
|
|
5
|
+
* then writes merged artifacts to src/generated/.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { generateComposePipeline } from "./generate-compose";
|
|
11
|
+
import { generateDockerfilePipeline } from "./generate-dockerfile";
|
|
12
|
+
import { entityTypeToTsName } from "./naming";
|
|
13
|
+
import type { ComposeParseResult } from "../spec/parse-compose";
|
|
14
|
+
import type { DockerfileParseResult } from "../spec/parse-engine";
|
|
15
|
+
|
|
16
|
+
export interface DockerGenerateResult {
|
|
17
|
+
resources: number;
|
|
18
|
+
properties: number;
|
|
19
|
+
enums: number;
|
|
20
|
+
warnings: Array<{ file: string; error: string }>;
|
|
21
|
+
lexiconJSON: string;
|
|
22
|
+
typesDTS: string;
|
|
23
|
+
indexTS: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run the full Docker generation pipeline.
|
|
28
|
+
*/
|
|
29
|
+
export async function generate(opts: { verbose?: boolean; force?: boolean } = {}): Promise<DockerGenerateResult> {
|
|
30
|
+
const [composeResult, dockerfileResult] = await Promise.all([
|
|
31
|
+
generateComposePipeline(opts),
|
|
32
|
+
generateDockerfilePipeline(opts),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const allCompose = composeResult.results;
|
|
36
|
+
const dockerfile = dockerfileResult.result;
|
|
37
|
+
|
|
38
|
+
const lexiconJSON = generateLexiconJSON(allCompose, dockerfile);
|
|
39
|
+
const typesDTS = generateTypesDTS(allCompose, dockerfile);
|
|
40
|
+
const indexTS = generateIndexTS(allCompose, dockerfile);
|
|
41
|
+
|
|
42
|
+
const propertyCount = allCompose.reduce((n, c) => n + c.properties.length, 0) + dockerfile.instructions.length;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
resources: allCompose.length + 1, // +1 for Dockerfile
|
|
46
|
+
properties: propertyCount,
|
|
47
|
+
enums: 0,
|
|
48
|
+
warnings: [],
|
|
49
|
+
lexiconJSON,
|
|
50
|
+
typesDTS,
|
|
51
|
+
indexTS,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function generateLexiconJSON(compose: ComposeParseResult[], dockerfile: DockerfileParseResult): string {
|
|
56
|
+
const entries: Record<string, unknown> = {};
|
|
57
|
+
|
|
58
|
+
for (const c of compose) {
|
|
59
|
+
const tsName = entityTypeToTsName(c.typeName);
|
|
60
|
+
entries[tsName] = {
|
|
61
|
+
resourceType: c.typeName,
|
|
62
|
+
kind: "resource",
|
|
63
|
+
description: c.description ?? "",
|
|
64
|
+
properties: Object.fromEntries(
|
|
65
|
+
c.properties.map((p) => [p.name, { type: p.type, description: p.description ?? "" }]),
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dfName = entityTypeToTsName(dockerfile.typeName);
|
|
71
|
+
entries[dfName] = {
|
|
72
|
+
resourceType: dockerfile.typeName,
|
|
73
|
+
kind: "resource",
|
|
74
|
+
description: dockerfile.description ?? "",
|
|
75
|
+
properties: Object.fromEntries(
|
|
76
|
+
dockerfile.instructions.map((i) => [i.name, { description: i.description ?? "" }]),
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return JSON.stringify(entries, null, 2);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function generateTypesDTS(compose: ComposeParseResult[], dockerfile: DockerfileParseResult): string {
|
|
84
|
+
const lines: string[] = [
|
|
85
|
+
"// Code generated by chant generate. DO NOT EDIT.",
|
|
86
|
+
'import type { Declarable } from "@intentius/chant/declarable";',
|
|
87
|
+
"",
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
for (const c of compose) {
|
|
91
|
+
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("");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
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;`);
|
|
119
|
+
lines.push("");
|
|
120
|
+
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function generateIndexTS(compose: ComposeParseResult[], dockerfile: DockerfileParseResult): string {
|
|
125
|
+
const lines: string[] = [
|
|
126
|
+
"// Code generated by chant generate. DO NOT EDIT.",
|
|
127
|
+
'import { createResource } from "./runtime";',
|
|
128
|
+
"",
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
for (const c of compose) {
|
|
132
|
+
const tsName = entityTypeToTsName(c.typeName);
|
|
133
|
+
lines.push(`export const ${tsName} = createResource("${c.typeName}", "docker", {});`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
lines.push("");
|
|
137
|
+
const dfName = entityTypeToTsName(dockerfile.typeName);
|
|
138
|
+
lines.push(`export const ${dfName} = createResource("${dockerfile.typeName}", "docker", {});`);
|
|
139
|
+
lines.push("");
|
|
140
|
+
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Write generated artifacts to disk under baseDir/src/generated/.
|
|
146
|
+
*/
|
|
147
|
+
export function writeGeneratedFiles(result: DockerGenerateResult, baseDir: string): void {
|
|
148
|
+
const generatedDir = join(baseDir, "src", "generated");
|
|
149
|
+
mkdirSync(generatedDir, { recursive: true });
|
|
150
|
+
|
|
151
|
+
writeFileSync(join(generatedDir, "lexicon-docker.json"), result.lexiconJSON);
|
|
152
|
+
writeFileSync(join(generatedDir, "index.d.ts"), result.typesDTS);
|
|
153
|
+
writeFileSync(join(generatedDir, "index.ts"), result.indexTS);
|
|
154
|
+
writeFileSync(
|
|
155
|
+
join(generatedDir, "runtime.ts"),
|
|
156
|
+
`/**\n * Runtime factory constructors — re-exported from core.\n */\nexport { createResource, createProperty } from "@intentius/chant/runtime";\n`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
entityTypeToTsName,
|
|
4
|
+
tsNameToEntityType,
|
|
5
|
+
isComposeType,
|
|
6
|
+
isDockerfileType,
|
|
7
|
+
ALL_ENTITY_TYPES,
|
|
8
|
+
} from "./naming";
|
|
9
|
+
|
|
10
|
+
describe("entityTypeToTsName", () => {
|
|
11
|
+
test("maps Compose Service", () => {
|
|
12
|
+
expect(entityTypeToTsName("Docker::Compose::Service")).toBe("Service");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("maps Compose Volume", () => {
|
|
16
|
+
expect(entityTypeToTsName("Docker::Compose::Volume")).toBe("Volume");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("maps Compose Network", () => {
|
|
20
|
+
expect(entityTypeToTsName("Docker::Compose::Network")).toBe("Network");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("maps Compose Config with disambiguation", () => {
|
|
24
|
+
expect(entityTypeToTsName("Docker::Compose::Config")).toBe("DockerConfig");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("maps Compose Secret with disambiguation", () => {
|
|
28
|
+
expect(entityTypeToTsName("Docker::Compose::Secret")).toBe("DockerSecret");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("maps Dockerfile", () => {
|
|
32
|
+
expect(entityTypeToTsName("Docker::Dockerfile")).toBe("Dockerfile");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("falls back to last segment for unknown types", () => {
|
|
36
|
+
expect(entityTypeToTsName("Docker::Unknown::Foo")).toBe("Foo");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("tsNameToEntityType", () => {
|
|
41
|
+
test("reverse maps Service", () => {
|
|
42
|
+
expect(tsNameToEntityType("Service")).toBe("Docker::Compose::Service");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("reverse maps Dockerfile", () => {
|
|
46
|
+
expect(tsNameToEntityType("Dockerfile")).toBe("Docker::Dockerfile");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns undefined for unknown name", () => {
|
|
50
|
+
expect(tsNameToEntityType("Unknown")).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("isComposeType", () => {
|
|
55
|
+
test("true for Compose types", () => {
|
|
56
|
+
expect(isComposeType("Docker::Compose::Service")).toBe(true);
|
|
57
|
+
expect(isComposeType("Docker::Compose::Volume")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("false for Dockerfile", () => {
|
|
61
|
+
expect(isComposeType("Docker::Dockerfile")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("isDockerfileType", () => {
|
|
66
|
+
test("true for Dockerfile", () => {
|
|
67
|
+
expect(isDockerfileType("Docker::Dockerfile")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("false for Compose types", () => {
|
|
71
|
+
expect(isDockerfileType("Docker::Compose::Service")).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("ALL_ENTITY_TYPES", () => {
|
|
76
|
+
test("contains all six types", () => {
|
|
77
|
+
expect(ALL_ENTITY_TYPES).toHaveLength(6);
|
|
78
|
+
expect(ALL_ENTITY_TYPES).toContain("Docker::Compose::Service");
|
|
79
|
+
expect(ALL_ENTITY_TYPES).toContain("Docker::Dockerfile");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naming strategy for Docker lexicon entities.
|
|
3
|
+
*
|
|
4
|
+
* Converts Docker/Compose entity type names to TypeScript class names.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Map entityType → TypeScript class name */
|
|
8
|
+
const ENTITY_TYPE_TO_TS_NAME: Record<string, string> = {
|
|
9
|
+
"Docker::Compose::Service": "Service",
|
|
10
|
+
"Docker::Compose::Volume": "Volume",
|
|
11
|
+
"Docker::Compose::Network": "Network",
|
|
12
|
+
"Docker::Compose::Config": "DockerConfig",
|
|
13
|
+
"Docker::Compose::Secret": "DockerSecret",
|
|
14
|
+
"Docker::Dockerfile": "Dockerfile",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Map TypeScript class name → entityType */
|
|
18
|
+
const TS_NAME_TO_ENTITY_TYPE: Record<string, string> = Object.fromEntries(
|
|
19
|
+
Object.entries(ENTITY_TYPE_TO_TS_NAME).map(([k, v]) => [v, k]),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert a Docker entity type name to a TypeScript class name.
|
|
24
|
+
* e.g. "Docker::Compose::Service" → "Service"
|
|
25
|
+
*/
|
|
26
|
+
export function entityTypeToTsName(entityType: string): string {
|
|
27
|
+
return ENTITY_TYPE_TO_TS_NAME[entityType] ?? entityType.split("::").pop() ?? entityType;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Convert a TypeScript class name back to an entity type.
|
|
32
|
+
*/
|
|
33
|
+
export function tsNameToEntityType(tsName: string): string | undefined {
|
|
34
|
+
return TS_NAME_TO_ENTITY_TYPE[tsName];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* All known Docker entity types, in declaration order.
|
|
39
|
+
*/
|
|
40
|
+
export const ALL_ENTITY_TYPES = Object.keys(ENTITY_TYPE_TO_TS_NAME);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check whether an entity type is a Compose resource.
|
|
44
|
+
*/
|
|
45
|
+
export function isComposeType(entityType: string): boolean {
|
|
46
|
+
return entityType.startsWith("Docker::Compose::");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check whether an entity type is the Dockerfile resource.
|
|
51
|
+
*/
|
|
52
|
+
export function isDockerfileType(entityType: string): boolean {
|
|
53
|
+
return entityType === "Docker::Dockerfile";
|
|
54
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker lexicon packaging — delegates to core packagePipeline.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
import { join, dirname } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import type { IntrinsicDef } from "@intentius/chant/lexicon";
|
|
11
|
+
import {
|
|
12
|
+
packagePipeline,
|
|
13
|
+
collectSkills,
|
|
14
|
+
type PackageOptions,
|
|
15
|
+
type PackageResult,
|
|
16
|
+
} from "@intentius/chant/codegen/package";
|
|
17
|
+
import { generate } from "./generate";
|
|
18
|
+
|
|
19
|
+
export type { PackageOptions, PackageResult };
|
|
20
|
+
|
|
21
|
+
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Package the Docker lexicon into a distributable BundleSpec.
|
|
25
|
+
*/
|
|
26
|
+
export async function packageLexicon(opts: PackageOptions = {}): Promise<PackageResult> {
|
|
27
|
+
const pkgJson = JSON.parse(readFileSync(join(pkgDir, "..", "package.json"), "utf-8"));
|
|
28
|
+
|
|
29
|
+
return packagePipeline(
|
|
30
|
+
{
|
|
31
|
+
generate: (genOpts) => generate({ verbose: genOpts.verbose, force: genOpts.force }),
|
|
32
|
+
|
|
33
|
+
buildManifest: (_genResult) => {
|
|
34
|
+
const intrinsics: IntrinsicDef[] = [
|
|
35
|
+
{
|
|
36
|
+
name: "env",
|
|
37
|
+
description: "Docker Compose variable interpolation — ${VAR}, ${VAR:-default}, ${VAR:?error}",
|
|
38
|
+
outputKey: "env",
|
|
39
|
+
isTag: false,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
name: "docker",
|
|
45
|
+
version: pkgJson.version ?? "0.0.0",
|
|
46
|
+
chantVersion: ">=0.1.0",
|
|
47
|
+
namespace: "Docker",
|
|
48
|
+
intrinsics,
|
|
49
|
+
pseudoParameters: {},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
srcDir: pkgDir,
|
|
54
|
+
|
|
55
|
+
collectSkills: () => {
|
|
56
|
+
const { dockerPlugin } = require("../plugin");
|
|
57
|
+
const skillDefs = dockerPlugin.skills?.() ?? [];
|
|
58
|
+
return collectSkills(skillDefs);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
version: pkgJson.version ?? "0.0.0",
|
|
62
|
+
},
|
|
63
|
+
opts,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual overrides for upstream Docker spec quirks.
|
|
3
|
+
*
|
|
4
|
+
* These patches adjust entity names, property types, or descriptions
|
|
5
|
+
* where the upstream spec is ambiguous, incorrect, or overly broad.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface EntityPatch {
|
|
9
|
+
/** Override entity description */
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Override specific property types */
|
|
12
|
+
propertyOverrides?: Record<string, { type?: string; description?: string }>;
|
|
13
|
+
/** Properties to exclude from type generation */
|
|
14
|
+
excludeProperties?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Patches keyed by entity typeName.
|
|
19
|
+
*/
|
|
20
|
+
export const ENTITY_PATCHES: Record<string, EntityPatch> = {
|
|
21
|
+
"Docker::Compose::Service": {
|
|
22
|
+
propertyOverrides: {
|
|
23
|
+
// The spec allows both string and object forms for these — normalize to object
|
|
24
|
+
depends_on: {
|
|
25
|
+
description: "Service dependencies (may be string[] or condition map)",
|
|
26
|
+
},
|
|
27
|
+
// healthcheck interval/timeout are duration strings in Docker syntax
|
|
28
|
+
healthcheck: {
|
|
29
|
+
description: "Health check configuration with Docker duration strings (e.g., '30s', '1m30s')",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
"Docker::Dockerfile": {
|
|
35
|
+
propertyOverrides: {
|
|
36
|
+
// Multi-stage builds use stages array
|
|
37
|
+
stages: {
|
|
38
|
+
description: "Multi-stage build stages — each stage has its own FROM and instructions",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned URLs and version tags for upstream Docker specs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Compose Spec JSON Schema — master branch */
|
|
6
|
+
export const COMPOSE_SPEC_URL =
|
|
7
|
+
"https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json";
|
|
8
|
+
|
|
9
|
+
/** Docker Engine API OpenAPI spec (v1.45) */
|
|
10
|
+
export const ENGINE_API_URL =
|
|
11
|
+
"https://raw.githubusercontent.com/moby/moby/v27.3.1/api/swagger.yaml";
|
|
12
|
+
|
|
13
|
+
/** Cache filenames under ~/.chant/ */
|
|
14
|
+
export const COMPOSE_SPEC_CACHE = "docker-compose-spec.json";
|
|
15
|
+
export const ENGINE_API_CACHE = "docker-engine-api.yaml";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker lexicon composites.
|
|
3
|
+
*
|
|
4
|
+
* Composites are factory functions that return pre-configured
|
|
5
|
+
* multi-resource bundles. Docker composites will be added here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Composites deferred — see plan for Tier 2 implementation.
|
|
9
|
+
// Example future composites:
|
|
10
|
+
// - PostgresService({ version, database, user, password }) → Service + Volume
|
|
11
|
+
// - RedisService({ version, persistence }) → Service + Volume
|
|
12
|
+
// - NginxService({ port, config }) → Service
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { analyzeDockerCoverage, computeCoverage } from "./coverage";
|
|
3
|
+
|
|
4
|
+
describe("analyzeDockerCoverage", () => {
|
|
5
|
+
test("is an async function", () => {
|
|
6
|
+
expect(typeof analyzeDockerCoverage).toBe("function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("throws when generated dir does not exist", async () => {
|
|
10
|
+
await expect(
|
|
11
|
+
analyzeDockerCoverage({ basePath: "/tmp/nonexistent-docker-lexicon-test" }),
|
|
12
|
+
).rejects.toThrow();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("computeCoverage", () => {
|
|
17
|
+
test("is a function", () => {
|
|
18
|
+
expect(typeof computeCoverage).toBe("function");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("handles a minimal lexicon JSON", () => {
|
|
22
|
+
const minimal = JSON.stringify({
|
|
23
|
+
Service: {
|
|
24
|
+
resourceType: "Docker::Compose::Service",
|
|
25
|
+
kind: "resource",
|
|
26
|
+
description: "A service",
|
|
27
|
+
properties: {},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const report = computeCoverage(minimal);
|
|
31
|
+
expect(report).toBeDefined();
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/coverage.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage analysis for Docker lexicon.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "fs";
|
|
6
|
+
import { join, dirname } from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import {
|
|
9
|
+
computeCoverage,
|
|
10
|
+
overallPct,
|
|
11
|
+
formatSummary,
|
|
12
|
+
formatVerbose,
|
|
13
|
+
checkThresholds,
|
|
14
|
+
type CoverageReport,
|
|
15
|
+
type CoverageThresholds,
|
|
16
|
+
} from "@intentius/chant/codegen/coverage";
|
|
17
|
+
|
|
18
|
+
export type { CoverageReport, CoverageThresholds };
|
|
19
|
+
export { computeCoverage, overallPct, formatSummary, formatVerbose, checkThresholds };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run coverage analysis for the Docker lexicon.
|
|
23
|
+
*/
|
|
24
|
+
export async function analyzeDockerCoverage(opts?: {
|
|
25
|
+
basePath?: string;
|
|
26
|
+
verbose?: boolean;
|
|
27
|
+
minOverall?: number;
|
|
28
|
+
}): Promise<CoverageReport> {
|
|
29
|
+
const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
|
|
30
|
+
const lexiconPath = join(basePath, "src", "generated", "lexicon-docker.json");
|
|
31
|
+
|
|
32
|
+
if (!existsSync(lexiconPath)) {
|
|
33
|
+
throw new Error(`Generated lexicon not found at ${lexiconPath}. Run "chant dev generate" first.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const content = readFileSync(lexiconPath, "utf-8");
|
|
37
|
+
const report = computeCoverage(content);
|
|
38
|
+
|
|
39
|
+
if (opts?.verbose) {
|
|
40
|
+
console.error(formatVerbose(report));
|
|
41
|
+
} else {
|
|
42
|
+
console.error(formatSummary(report));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof opts?.minOverall === "number") {
|
|
46
|
+
const result = checkThresholds(report, { minOverallPct: opts.minOverall });
|
|
47
|
+
if (!result.ok) {
|
|
48
|
+
for (const v of result.violations) console.error(` FAIL: ${v}`);
|
|
49
|
+
throw new Error("Coverage below threshold");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return report;
|
|
54
|
+
}
|