@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.
Files changed (81) hide show
  1. package/README.md +24 -0
  2. package/dist/integrity.json +19 -0
  3. package/dist/manifest.json +15 -0
  4. package/dist/meta.json +222 -0
  5. package/dist/rules/apt-no-recommends.ts +43 -0
  6. package/dist/rules/docker-helpers.ts +114 -0
  7. package/dist/rules/no-latest-image.ts +36 -0
  8. package/dist/rules/no-latest-tag.ts +63 -0
  9. package/dist/rules/no-root-user.ts +36 -0
  10. package/dist/rules/prefer-copy.ts +53 -0
  11. package/dist/rules/ssh-port-exposed.ts +68 -0
  12. package/dist/rules/unused-volume.ts +49 -0
  13. package/dist/skills/chant-docker-patterns.md +153 -0
  14. package/dist/skills/chant-docker.md +129 -0
  15. package/dist/types/index.d.ts +93 -0
  16. package/package.json +53 -0
  17. package/src/codegen/docs-cli.ts +10 -0
  18. package/src/codegen/docs.ts +12 -0
  19. package/src/codegen/generate-cli.ts +36 -0
  20. package/src/codegen/generate-compose.ts +21 -0
  21. package/src/codegen/generate-dockerfile.ts +21 -0
  22. package/src/codegen/generate.test.ts +105 -0
  23. package/src/codegen/generate.ts +158 -0
  24. package/src/codegen/naming.test.ts +81 -0
  25. package/src/codegen/naming.ts +54 -0
  26. package/src/codegen/package.ts +65 -0
  27. package/src/codegen/patches.ts +42 -0
  28. package/src/codegen/versions.ts +15 -0
  29. package/src/composites/index.ts +12 -0
  30. package/src/coverage.test.ts +33 -0
  31. package/src/coverage.ts +54 -0
  32. package/src/default-labels.test.ts +85 -0
  33. package/src/default-labels.ts +72 -0
  34. package/src/generated/index.d.ts +93 -0
  35. package/src/generated/index.ts +10 -0
  36. package/src/generated/lexicon-docker.json +222 -0
  37. package/src/generated/runtime.ts +4 -0
  38. package/src/import/generator.test.ts +133 -0
  39. package/src/import/generator.ts +127 -0
  40. package/src/import/parser.test.ts +137 -0
  41. package/src/import/parser.ts +190 -0
  42. package/src/import/roundtrip.test.ts +49 -0
  43. package/src/import/testdata/full.yaml +43 -0
  44. package/src/import/testdata/simple.yaml +9 -0
  45. package/src/import/testdata/webapp.yaml +41 -0
  46. package/src/index.ts +29 -0
  47. package/src/interpolation.test.ts +41 -0
  48. package/src/interpolation.ts +76 -0
  49. package/src/lint/post-synth/apt-no-recommends.ts +43 -0
  50. package/src/lint/post-synth/docker-helpers.ts +114 -0
  51. package/src/lint/post-synth/no-latest-image.ts +36 -0
  52. package/src/lint/post-synth/no-root-user.ts +36 -0
  53. package/src/lint/post-synth/post-synth.test.ts +181 -0
  54. package/src/lint/post-synth/prefer-copy.ts +53 -0
  55. package/src/lint/post-synth/ssh-port-exposed.ts +68 -0
  56. package/src/lint/post-synth/unused-volume.ts +49 -0
  57. package/src/lint/rules/data/deprecated-images.ts +28 -0
  58. package/src/lint/rules/data/known-base-images.ts +20 -0
  59. package/src/lint/rules/index.ts +5 -0
  60. package/src/lint/rules/no-latest-tag.ts +63 -0
  61. package/src/lint/rules/rules.test.ts +82 -0
  62. package/src/lsp/completions.test.ts +34 -0
  63. package/src/lsp/completions.ts +20 -0
  64. package/src/lsp/hover.test.ts +34 -0
  65. package/src/lsp/hover.ts +38 -0
  66. package/src/package-cli.ts +42 -0
  67. package/src/plugin.test.ts +117 -0
  68. package/src/plugin.ts +250 -0
  69. package/src/serializer.test.ts +294 -0
  70. package/src/serializer.ts +322 -0
  71. package/src/skills/chant-docker-patterns.md +153 -0
  72. package/src/skills/chant-docker.md +129 -0
  73. package/src/spec/fetch-compose.ts +35 -0
  74. package/src/spec/fetch-engine.ts +25 -0
  75. package/src/spec/parse-compose.ts +110 -0
  76. package/src/spec/parse-engine.ts +47 -0
  77. package/src/validate-cli.ts +19 -0
  78. package/src/validate.test.ts +16 -0
  79. package/src/validate.ts +44 -0
  80. package/src/variables.test.ts +32 -0
  81. package/src/variables.ts +47 -0
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Well-known Docker base images for rule validation.
3
+ */
4
+
5
+ export const KNOWN_BASE_IMAGES = new Set([
6
+ "alpine", "ubuntu", "debian", "centos", "fedora", "amazonlinux",
7
+ "node", "python", "ruby", "golang", "rust", "java", "openjdk",
8
+ "nginx", "httpd", "caddy", "traefik",
9
+ "postgres", "mysql", "mariadb", "mongodb", "redis", "memcached",
10
+ "rabbitmq", "kafka", "zookeeper",
11
+ "elasticsearch", "kibana", "logstash",
12
+ "busybox", "scratch",
13
+ ]);
14
+
15
+ /**
16
+ * Images that are safe to use without a tag (they have meaningful `latest`).
17
+ */
18
+ export const IMAGES_SAFE_LATEST = new Set([
19
+ "scratch",
20
+ ]);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * All imperative lint rules for the Docker lexicon.
3
+ */
4
+
5
+ export { noLatestTagRule } from "./no-latest-tag";
@@ -0,0 +1,63 @@
1
+ /**
2
+ * DKRS001: No Latest Tag
3
+ *
4
+ * Warns when a Service's image prop is set to a literal string
5
+ * ending with `:latest` or has no tag (implies latest).
6
+ * Using `:latest` prevents reproducible builds.
7
+ */
8
+
9
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
10
+ import * as ts from "typescript";
11
+
12
+ function isLatestImage(imageValue: string): boolean {
13
+ const trimmed = imageValue.trim();
14
+ if (!trimmed || trimmed.startsWith("${")) return false;
15
+ // Has :latest tag
16
+ if (trimmed.endsWith(":latest")) return true;
17
+ // No tag at all (no colon after image name, no @sha256)
18
+ const parts = trimmed.split("/");
19
+ const lastPart = parts[parts.length - 1];
20
+ if (!lastPart.includes(":") && !lastPart.includes("@")) return true;
21
+ return false;
22
+ }
23
+
24
+ export const noLatestTagRule: LintRule = {
25
+ id: "DKRS001",
26
+ severity: "warning",
27
+ category: "correctness",
28
+ description: "Avoid :latest or untagged image references — use explicit version tags for reproducible builds",
29
+
30
+ check(context: LintContext): LintDiagnostic[] {
31
+ const { sourceFile } = context;
32
+ const diagnostics: LintDiagnostic[] = [];
33
+
34
+ function visit(node: ts.Node): void {
35
+ // Look for `image: "..."` in object literals (properties named "image")
36
+ if (
37
+ ts.isPropertyAssignment(node) &&
38
+ ts.isIdentifier(node.name) &&
39
+ node.name.text === "image"
40
+ ) {
41
+ const init = node.initializer;
42
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
43
+ const text = init.text;
44
+ if (isLatestImage(text)) {
45
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
46
+ diagnostics.push({
47
+ file: sourceFile.fileName,
48
+ line: line + 1,
49
+ column: character + 1,
50
+ ruleId: "DKRS001",
51
+ severity: "warning",
52
+ message: `Image "${text}" uses :latest or has no tag. Use an explicit version tag (e.g., "nginx:1.25-alpine") for reproducible builds.`,
53
+ });
54
+ }
55
+ }
56
+ }
57
+ ts.forEachChild(node, visit);
58
+ }
59
+
60
+ visit(sourceFile);
61
+ return diagnostics;
62
+ },
63
+ };
@@ -0,0 +1,82 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { noLatestTagRule } from "./no-latest-tag";
3
+ import type { LintContext } from "@intentius/chant/lint/rule";
4
+ import * as ts from "typescript";
5
+
6
+ function makeContext(code: string): LintContext {
7
+ const sourceFile = ts.createSourceFile(
8
+ "test.ts",
9
+ code,
10
+ ts.ScriptTarget.Latest,
11
+ true,
12
+ ts.ScriptKind.TS,
13
+ );
14
+ return {
15
+ sourceFile,
16
+ entities: [],
17
+ filePath: "test.ts",
18
+ lexicon: "docker",
19
+ };
20
+ }
21
+
22
+ // ── DKRS001: no-latest-tag ────────────────────────────────────────
23
+
24
+ describe("DKRS001: no-latest-tag", () => {
25
+ test("has correct id and severity", () => {
26
+ expect(noLatestTagRule.id).toBe("DKRS001");
27
+ expect(noLatestTagRule.severity).toBe("warning");
28
+ expect(noLatestTagRule.category).toBe("correctness");
29
+ });
30
+
31
+ test("flags :latest tag", () => {
32
+ const ctx = makeContext(`
33
+ import { Service } from "@intentius/chant-lexicon-docker";
34
+ export const api = new Service({ image: "nginx:latest" });
35
+ `);
36
+ const diags = noLatestTagRule.check(ctx);
37
+ expect(diags.length).toBeGreaterThanOrEqual(1);
38
+ expect(diags[0].ruleId).toBe("DKRS001");
39
+ expect(diags[0].message).toContain("nginx:latest");
40
+ });
41
+
42
+ test("flags untagged image (no colon)", () => {
43
+ const ctx = makeContext(`
44
+ const svc = { image: "nginx" };
45
+ `);
46
+ const diags = noLatestTagRule.check(ctx);
47
+ expect(diags.length).toBeGreaterThanOrEqual(1);
48
+ expect(diags[0].message).toContain("nginx");
49
+ });
50
+
51
+ test("does not flag explicitly versioned image", () => {
52
+ const ctx = makeContext(`
53
+ const svc = { image: "nginx:1.25-alpine" };
54
+ `);
55
+ const diags = noLatestTagRule.check(ctx);
56
+ expect(diags).toHaveLength(0);
57
+ });
58
+
59
+ test("does not flag digest-pinned image", () => {
60
+ const ctx = makeContext(`
61
+ const svc = { image: "nginx@sha256:abc123" };
62
+ `);
63
+ const diags = noLatestTagRule.check(ctx);
64
+ expect(diags).toHaveLength(0);
65
+ });
66
+
67
+ test("does not flag env() interpolation", () => {
68
+ const ctx = makeContext(`
69
+ const svc = { image: "\${APP_IMAGE:-myapp:latest}" };
70
+ `);
71
+ const diags = noLatestTagRule.check(ctx);
72
+ expect(diags).toHaveLength(0);
73
+ });
74
+
75
+ test("does not flag non-image string properties", () => {
76
+ const ctx = makeContext(`
77
+ const svc = { restart: "latest" };
78
+ `);
79
+ const diags = noLatestTagRule.check(ctx);
80
+ expect(diags).toHaveLength(0);
81
+ });
82
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { dockerCompletions } from "./completions";
3
+ import type { CompletionContext } from "@intentius/chant/lsp/types";
4
+
5
+ describe("dockerCompletions", () => {
6
+ test("is a function", () => {
7
+ expect(typeof dockerCompletions).toBe("function");
8
+ });
9
+
10
+ test("returns array for non-constructor context", () => {
11
+ const ctx: CompletionContext = {
12
+ word: "Ser",
13
+ isConstructorContext: false,
14
+ prefix: "",
15
+ fileContent: "",
16
+ position: { line: 0, character: 3 },
17
+ };
18
+ // May return [] if generated index not present, but should not throw
19
+ const result = dockerCompletions(ctx);
20
+ expect(Array.isArray(result)).toBe(true);
21
+ });
22
+
23
+ test("returns array for constructor context", () => {
24
+ const ctx: CompletionContext = {
25
+ word: "Service",
26
+ isConstructorContext: true,
27
+ prefix: "new ",
28
+ fileContent: "import { Service } from '@intentius/chant-lexicon-docker';",
29
+ position: { line: 0, character: 10 },
30
+ };
31
+ const result = dockerCompletions(ctx);
32
+ expect(Array.isArray(result)).toBe(true);
33
+ });
34
+ });
@@ -0,0 +1,20 @@
1
+ import { createRequire } from "module";
2
+ import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
3
+ import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
+ const require = createRequire(import.meta.url);
5
+
6
+ let cachedIndex: LexiconIndex | null = null;
7
+
8
+ function getIndex(): LexiconIndex {
9
+ if (cachedIndex) return cachedIndex;
10
+ const data = require("../generated/lexicon-docker.json") as Record<string, LexiconEntry>;
11
+ cachedIndex = new LexiconIndex(data);
12
+ return cachedIndex;
13
+ }
14
+
15
+ /**
16
+ * Provide Docker entity completions based on context.
17
+ */
18
+ export function dockerCompletions(ctx: CompletionContext): CompletionItem[] {
19
+ return lexiconCompletions(ctx, getIndex(), "Docker entity");
20
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { dockerHover } from "./hover";
3
+ import type { HoverContext } from "@intentius/chant/lsp/types";
4
+
5
+ describe("dockerHover", () => {
6
+ test("is a function", () => {
7
+ expect(typeof dockerHover).toBe("function");
8
+ });
9
+
10
+ test("returns undefined for unknown word", () => {
11
+ const ctx: HoverContext = {
12
+ word: "NotADockerThing",
13
+ fileContent: "",
14
+ position: { line: 0, character: 0 },
15
+ };
16
+ // Should not throw; returns undefined if not found
17
+ const result = dockerHover(ctx);
18
+ expect(result === undefined || typeof result === "object").toBe(true);
19
+ });
20
+
21
+ test("returns HoverInfo with content for known resource", () => {
22
+ const ctx: HoverContext = {
23
+ word: "Service",
24
+ fileContent: "import { Service } from '@intentius/chant-lexicon-docker';",
25
+ position: { line: 0, character: 10 },
26
+ };
27
+ const result = dockerHover(ctx);
28
+ // If generated index exists, will return content; otherwise undefined
29
+ if (result !== undefined) {
30
+ expect(result.contents).toBeTruthy();
31
+ expect(typeof result.contents).toBe("string");
32
+ }
33
+ });
34
+ });
@@ -0,0 +1,38 @@
1
+ import { createRequire } from "module";
2
+ import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
3
+ import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
+ const require = createRequire(import.meta.url);
5
+
6
+ let cachedIndex: LexiconIndex | null = null;
7
+
8
+ function getIndex(): LexiconIndex {
9
+ if (cachedIndex) return cachedIndex;
10
+ const data = require("../generated/lexicon-docker.json") as Record<string, LexiconEntry>;
11
+ cachedIndex = new LexiconIndex(data);
12
+ return cachedIndex;
13
+ }
14
+
15
+ /**
16
+ * Provide hover information for Docker entity types.
17
+ */
18
+ export function dockerHover(ctx: HoverContext): HoverInfo | undefined {
19
+ return lexiconHover(ctx, getIndex(), resourceHover);
20
+ }
21
+
22
+ function resourceHover(className: string, entry: LexiconEntry): HoverInfo | undefined {
23
+ const lines: string[] = [];
24
+
25
+ lines.push(`**${className}**`);
26
+ lines.push("");
27
+ lines.push(`Docker type: \`${entry.resourceType}\``);
28
+
29
+ if (entry.resourceType.startsWith("Docker::Compose::")) {
30
+ lines.push("");
31
+ lines.push("*Compose resource — serialized into docker-compose.yml*");
32
+ } else if (entry.resourceType === "Docker::Dockerfile") {
33
+ lines.push("");
34
+ lines.push("*Dockerfile resource — serialized as Dockerfile.{name}*");
35
+ }
36
+
37
+ return { contents: lines.join("\n") };
38
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Thin entry point for `bun run bundle` in lexicon-docker.
4
+ */
5
+ import { generate, writeGeneratedFiles } from "./codegen/generate";
6
+ import { packageLexicon } from "./codegen/package";
7
+ import { writeFileSync, mkdirSync } from "fs";
8
+ import { join, dirname } from "path";
9
+ import { fileURLToPath } from "url";
10
+
11
+ const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
12
+
13
+ // 1. Generate src/generated/ files
14
+ const genResult = await generate({ verbose: true });
15
+ writeGeneratedFiles(genResult, pkgDir);
16
+ console.error(`Generated ${genResult.resources} entities, ${genResult.properties} property types, ${genResult.enums} enums`);
17
+
18
+ // 2. Run package pipeline and write dist/
19
+ const { spec, stats } = await packageLexicon({ verbose: true });
20
+
21
+ const distDir = join(pkgDir, "dist");
22
+ mkdirSync(join(distDir, "types"), { recursive: true });
23
+ mkdirSync(join(distDir, "rules"), { recursive: true });
24
+ mkdirSync(join(distDir, "skills"), { recursive: true });
25
+
26
+ writeFileSync(join(distDir, "manifest.json"), JSON.stringify(spec.manifest, null, 2));
27
+ writeFileSync(join(distDir, "meta.json"), spec.registry);
28
+ writeFileSync(join(distDir, "types", "index.d.ts"), spec.typesDTS);
29
+
30
+ for (const [name, content] of spec.rules) {
31
+ writeFileSync(join(distDir, "rules", name), content);
32
+ }
33
+ for (const [name, content] of spec.skills) {
34
+ writeFileSync(join(distDir, "skills", name), content);
35
+ }
36
+
37
+ if (spec.integrity) {
38
+ writeFileSync(join(distDir, "integrity.json"), JSON.stringify(spec.integrity, null, 2));
39
+ }
40
+
41
+ console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
42
+ console.error(`dist/ written to ${distDir}`);
@@ -0,0 +1,117 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { dockerPlugin } from "./plugin";
3
+
4
+ describe("dockerPlugin", () => {
5
+ test("has correct name", () => {
6
+ expect(dockerPlugin.name).toBe("docker");
7
+ });
8
+
9
+ test("has serializer", () => {
10
+ expect(dockerPlugin.serializer).toBeDefined();
11
+ expect(dockerPlugin.serializer.name).toBe("docker");
12
+ });
13
+
14
+ test("provides lint rules", () => {
15
+ const rules = dockerPlugin.lintRules!();
16
+ expect(rules.length).toBeGreaterThanOrEqual(1);
17
+
18
+ const ruleIds = rules.map((r) => r.id);
19
+ expect(ruleIds).toContain("DKRS001");
20
+ });
21
+
22
+ test("provides post-synth checks", () => {
23
+ const checks = dockerPlugin.postSynthChecks!();
24
+ expect(checks.length).toBeGreaterThanOrEqual(1);
25
+
26
+ const checkIds = checks.map((c) => c.id);
27
+ expect(checkIds).toContain("DKRD001");
28
+ expect(checkIds).toContain("DKRD002");
29
+ expect(checkIds).toContain("DKRD003");
30
+ expect(checkIds).toContain("DKRD010");
31
+ expect(checkIds).toContain("DKRD011");
32
+ expect(checkIds).toContain("DKRD012");
33
+ });
34
+
35
+ test("provides intrinsics", () => {
36
+ const intrinsics = dockerPlugin.intrinsics!();
37
+ expect(intrinsics.length).toBe(1);
38
+ expect(intrinsics[0].name).toBe("env");
39
+ });
40
+
41
+ test("provides init templates — default", () => {
42
+ const template = dockerPlugin.initTemplates!();
43
+ expect(template.src).toBeDefined();
44
+ expect(template.src["compose.ts"]).toContain("Service");
45
+ });
46
+
47
+ test("provides init templates — webapp", () => {
48
+ const template = dockerPlugin.initTemplates!("webapp");
49
+ expect(template.src["compose.ts"]).toContain("postgres");
50
+ });
51
+
52
+ test("detects Docker Compose template", () => {
53
+ expect(dockerPlugin.detectTemplate!({ services: {}, volumes: {} })).toBe(true);
54
+ });
55
+
56
+ test("rejects non-Docker data", () => {
57
+ expect(dockerPlugin.detectTemplate!({ jobs: {}, on: {} })).toBe(false);
58
+ expect(dockerPlugin.detectTemplate!(null)).toBe(false);
59
+ });
60
+
61
+ test("has completionProvider", () => {
62
+ expect(dockerPlugin.completionProvider).toBeDefined();
63
+ });
64
+
65
+ test("has hoverProvider", () => {
66
+ expect(dockerPlugin.hoverProvider).toBeDefined();
67
+ });
68
+
69
+ test("has templateParser", () => {
70
+ expect(dockerPlugin.templateParser).toBeDefined();
71
+ });
72
+
73
+ test("has templateGenerator", () => {
74
+ expect(dockerPlugin.templateGenerator).toBeDefined();
75
+ });
76
+
77
+ test("has generate method", () => {
78
+ expect(dockerPlugin.generate).toBeDefined();
79
+ });
80
+
81
+ test("has validate method", () => {
82
+ expect(dockerPlugin.validate).toBeDefined();
83
+ });
84
+
85
+ test("has coverage method", () => {
86
+ expect(dockerPlugin.coverage).toBeDefined();
87
+ });
88
+
89
+ test("has package method", () => {
90
+ expect(dockerPlugin.package).toBeDefined();
91
+ });
92
+
93
+ test("has docs method", () => {
94
+ expect(dockerPlugin.docs).toBeDefined();
95
+ });
96
+
97
+ test("provides skills", () => {
98
+ const skills = dockerPlugin.skills!();
99
+ expect(skills.length).toBeGreaterThanOrEqual(2);
100
+ const names = skills.map((s) => s.name);
101
+ expect(names).toContain("chant-docker");
102
+ expect(names).toContain("chant-docker-patterns");
103
+ });
104
+
105
+ test("provides MCP tools", () => {
106
+ const tools = dockerPlugin.mcpTools!();
107
+ expect(tools.length).toBe(1);
108
+ expect(tools[0].name).toBe("diff");
109
+ });
110
+
111
+ test("provides MCP resources", () => {
112
+ const resources = dockerPlugin.mcpResources!();
113
+ expect(resources.length).toBe(2);
114
+ expect(resources[0].uri).toBe("resource-catalog");
115
+ expect(resources[1].uri).toBe("examples/basic-app");
116
+ });
117
+ });