@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
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @intentius/chant-lexicon-docker
2
+
3
+ Docker lexicon for [chant](https://intentius.io/chant/) — declare Docker Compose services and Dockerfile build instructions as typed TypeScript that serializes to `docker-compose.yml` and `Dockerfile.*` files.
4
+
5
+ Provides typed constructors for Service, Volume, Network, Config, Secret, and multi-stage Dockerfile resources, plus variable interpolation, default labels, Docker-specific lint rules, LSP completions, and MCP server support.
6
+
7
+ ```bash
8
+ npm install --save-dev @intentius/chant @intentius/chant-lexicon-docker
9
+ ```
10
+
11
+ **[Documentation →](https://intentius.io/chant/lexicons/docker/)**
12
+
13
+ ## Related Packages
14
+
15
+ | Package | Role |
16
+ |---------|------|
17
+ | [@intentius/chant](https://www.npmjs.com/package/@intentius/chant) | Core type system, CLI, build pipeline |
18
+ | [@intentius/chant-lexicon-k8s](https://www.npmjs.com/package/@intentius/chant-lexicon-k8s) | Kubernetes lexicon |
19
+ | [@intentius/chant-lexicon-aws](https://www.npmjs.com/package/@intentius/chant-lexicon-aws) | AWS CloudFormation lexicon |
20
+ | [@intentius/chant-lexicon-flyway](https://www.npmjs.com/package/@intentius/chant-lexicon-flyway) | Flyway database migrations lexicon |
21
+
22
+ ## License
23
+
24
+ See the main project LICENSE file.
@@ -0,0 +1,19 @@
1
+ {
2
+ "algorithm": "xxhash64",
3
+ "artifacts": {
4
+ "manifest.json": "3621c2b241ba2cc1",
5
+ "meta.json": "6a8cbb16d88dd81",
6
+ "types/index.d.ts": "61a04a2aae8fb72d",
7
+ "rules/no-latest-tag.ts": "9a513a0d0801cfbd",
8
+ "rules/ssh-port-exposed.ts": "73a6e5d39eebcc28",
9
+ "rules/no-root-user.ts": "5aad839aa8ef0407",
10
+ "rules/unused-volume.ts": "b6101cbbfef97481",
11
+ "rules/apt-no-recommends.ts": "dce8c901982e2d4",
12
+ "rules/no-latest-image.ts": "1730d370550a5f5e",
13
+ "rules/docker-helpers.ts": "667d796e1dd6771f",
14
+ "rules/prefer-copy.ts": "c2eb8a86389a6b1",
15
+ "skills/chant-docker.md": "419ee2f3187d9527",
16
+ "skills/chant-docker-patterns.md": "cea0225dd8450081"
17
+ },
18
+ "composite": "86524aae9dcf961b"
19
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "docker",
3
+ "version": "0.1.0",
4
+ "chantVersion": ">=0.1.0",
5
+ "namespace": "Docker",
6
+ "intrinsics": [
7
+ {
8
+ "name": "env",
9
+ "description": "Docker Compose variable interpolation — ${VAR}, ${VAR:-default}, ${VAR:?error}",
10
+ "outputKey": "env",
11
+ "isTag": false
12
+ }
13
+ ],
14
+ "pseudoParameters": {}
15
+ }
package/dist/meta.json ADDED
@@ -0,0 +1,222 @@
1
+ {
2
+ "Service": {
3
+ "resourceType": "Docker::Compose::Service",
4
+ "kind": "resource",
5
+ "description": "A containerized service definition in Docker Compose",
6
+ "properties": {
7
+ "image": {
8
+ "type": "string",
9
+ "description": "Container image to use"
10
+ },
11
+ "build": {
12
+ "type": "object",
13
+ "description": "Build configuration"
14
+ },
15
+ "command": {
16
+ "type": "string | string[]",
17
+ "description": "Override the default command"
18
+ },
19
+ "entrypoint": {
20
+ "type": "string | string[]",
21
+ "description": "Override the default entrypoint"
22
+ },
23
+ "environment": {
24
+ "type": "Record<string, string>",
25
+ "description": "Environment variables"
26
+ },
27
+ "ports": {
28
+ "type": "string[]",
29
+ "description": "Published ports"
30
+ },
31
+ "volumes": {
32
+ "type": "string[]",
33
+ "description": "Volume mounts"
34
+ },
35
+ "networks": {
36
+ "type": "string[]",
37
+ "description": "Networks to attach"
38
+ },
39
+ "depends_on": {
40
+ "type": "string[]",
41
+ "description": "Service dependencies"
42
+ },
43
+ "restart": {
44
+ "type": "string",
45
+ "description": "Restart policy"
46
+ },
47
+ "labels": {
48
+ "type": "Record<string, string>",
49
+ "description": "Container labels"
50
+ },
51
+ "healthcheck": {
52
+ "type": "object",
53
+ "description": "Health check configuration"
54
+ },
55
+ "deploy": {
56
+ "type": "object",
57
+ "description": "Swarm deployment configuration"
58
+ },
59
+ "secrets": {
60
+ "type": "string[]",
61
+ "description": "Secrets to expose"
62
+ },
63
+ "configs": {
64
+ "type": "string[]",
65
+ "description": "Configs to expose"
66
+ }
67
+ }
68
+ },
69
+ "Volume": {
70
+ "resourceType": "Docker::Compose::Volume",
71
+ "kind": "resource",
72
+ "description": "A named volume definition in Docker Compose",
73
+ "properties": {
74
+ "driver": {
75
+ "type": "string",
76
+ "description": "Volume driver"
77
+ },
78
+ "driver_opts": {
79
+ "type": "Record<string, string>",
80
+ "description": "Driver options"
81
+ },
82
+ "external": {
83
+ "type": "boolean",
84
+ "description": "Whether the volume is external"
85
+ },
86
+ "labels": {
87
+ "type": "Record<string, string>",
88
+ "description": "Volume labels"
89
+ },
90
+ "name": {
91
+ "type": "string",
92
+ "description": "Custom volume name"
93
+ }
94
+ }
95
+ },
96
+ "Network": {
97
+ "resourceType": "Docker::Compose::Network",
98
+ "kind": "resource",
99
+ "description": "A network definition in Docker Compose",
100
+ "properties": {
101
+ "driver": {
102
+ "type": "string",
103
+ "description": "Network driver"
104
+ },
105
+ "driver_opts": {
106
+ "type": "Record<string, string>",
107
+ "description": "Driver options"
108
+ },
109
+ "external": {
110
+ "type": "boolean",
111
+ "description": "Whether the network is external"
112
+ },
113
+ "labels": {
114
+ "type": "Record<string, string>",
115
+ "description": "Network labels"
116
+ },
117
+ "internal": {
118
+ "type": "boolean",
119
+ "description": "Restrict external access"
120
+ },
121
+ "ipam": {
122
+ "type": "object",
123
+ "description": "IP address management"
124
+ }
125
+ }
126
+ },
127
+ "DockerConfig": {
128
+ "resourceType": "Docker::Compose::Config",
129
+ "kind": "resource",
130
+ "description": "A config definition in Docker Compose",
131
+ "properties": {
132
+ "file": {
133
+ "type": "string",
134
+ "description": "Path to the config file"
135
+ },
136
+ "external": {
137
+ "type": "boolean",
138
+ "description": "Whether the config is external"
139
+ },
140
+ "labels": {
141
+ "type": "Record<string, string>",
142
+ "description": "Config labels"
143
+ },
144
+ "name": {
145
+ "type": "string",
146
+ "description": "Custom config name"
147
+ }
148
+ }
149
+ },
150
+ "DockerSecret": {
151
+ "resourceType": "Docker::Compose::Secret",
152
+ "kind": "resource",
153
+ "description": "A secret definition in Docker Compose",
154
+ "properties": {
155
+ "file": {
156
+ "type": "string",
157
+ "description": "Path to the secret file"
158
+ },
159
+ "external": {
160
+ "type": "boolean",
161
+ "description": "Whether the secret is external"
162
+ },
163
+ "labels": {
164
+ "type": "Record<string, string>",
165
+ "description": "Secret labels"
166
+ },
167
+ "name": {
168
+ "type": "string",
169
+ "description": "Custom secret name"
170
+ }
171
+ }
172
+ },
173
+ "Dockerfile": {
174
+ "resourceType": "Docker::Dockerfile",
175
+ "kind": "resource",
176
+ "description": "A Dockerfile definition with ordered build instructions",
177
+ "properties": {
178
+ "from": {
179
+ "description": "Base image (required first instruction)"
180
+ },
181
+ "arg": {
182
+ "description": "Build-time argument"
183
+ },
184
+ "env": {
185
+ "description": "Environment variable"
186
+ },
187
+ "run": {
188
+ "description": "Run a command during build"
189
+ },
190
+ "copy": {
191
+ "description": "Copy files from build context"
192
+ },
193
+ "add": {
194
+ "description": "Add files (supports URLs and archives)"
195
+ },
196
+ "workdir": {
197
+ "description": "Set working directory"
198
+ },
199
+ "user": {
200
+ "description": "Set user for subsequent instructions"
201
+ },
202
+ "expose": {
203
+ "description": "Document exposed ports"
204
+ },
205
+ "volume": {
206
+ "description": "Create mount points"
207
+ },
208
+ "label": {
209
+ "description": "Add metadata labels"
210
+ },
211
+ "entrypoint": {
212
+ "description": "Set the entrypoint executable"
213
+ },
214
+ "cmd": {
215
+ "description": "Default command arguments"
216
+ },
217
+ "healthcheck": {
218
+ "description": "Health check instruction"
219
+ }
220
+ }
221
+ }
222
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * DKRD010: apt-get install without --no-install-recommends
3
+ *
4
+ * Detects RUN instructions with apt-get install missing
5
+ * --no-install-recommends, which leads to bloated images.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { extractDockerfiles } from "./docker-helpers";
10
+
11
+ export const dkrd010: PostSynthCheck = {
12
+ id: "DKRD010",
13
+ description: "apt-get install without --no-install-recommends adds unnecessary packages",
14
+
15
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
16
+ const diagnostics: PostSynthDiagnostic[] = [];
17
+
18
+ for (const [_outputName, output] of ctx.outputs) {
19
+ const dockerfiles = extractDockerfiles(output);
20
+
21
+ for (const [fileName, content] of dockerfiles) {
22
+ const lines = content.split("\n");
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const line = lines[i].trim();
25
+ if (
26
+ /^RUN\s+/.test(line) &&
27
+ line.includes("apt-get install") &&
28
+ !line.includes("--no-install-recommends")
29
+ ) {
30
+ diagnostics.push({
31
+ checkId: "DKRD010",
32
+ severity: "warning",
33
+ message: `${fileName}: RUN apt-get install should use --no-install-recommends to reduce image size.`,
34
+ lexicon: "docker",
35
+ });
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ return diagnostics;
42
+ },
43
+ };
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Shared YAML traversal utilities for Docker post-synth checks.
3
+ */
4
+
5
+ export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
6
+
7
+ export interface ParsedService {
8
+ name: string;
9
+ image?: string;
10
+ ports?: string[];
11
+ volumes?: string[];
12
+ build?: { dockerfile?: string; context?: string };
13
+ depends_on?: string[];
14
+ }
15
+
16
+ /**
17
+ * Extract services section from serialized docker-compose.yml.
18
+ */
19
+ export function extractServices(yaml: string): Map<string, ParsedService> {
20
+ const services = new Map<string, ParsedService>();
21
+
22
+ const servicesIdx = yaml.search(/^services:\s*$/m);
23
+ if (servicesIdx === -1) return services;
24
+
25
+ const afterServices = yaml.slice(servicesIdx + yaml.slice(servicesIdx).indexOf("\n") + 1);
26
+ // Stop at next top-level key
27
+ const endMatch = afterServices.search(/^[a-z]/m);
28
+ const servicesContent = endMatch === -1 ? afterServices : afterServices.slice(0, endMatch);
29
+
30
+ // Split by service entries (2-space indent + name:)
31
+ const sections = servicesContent.split(/\n(?= [a-z][a-z0-9_-]*:)/);
32
+
33
+ for (const section of sections) {
34
+ const nameMatch = section.match(/^\s{2}([a-z][a-z0-9_-]*):/);
35
+ if (!nameMatch) continue;
36
+
37
+ const name = nameMatch[1];
38
+ const svc: ParsedService = { name };
39
+
40
+ const imageMatch = section.match(/^\s{4}image:\s+(.+)$/m);
41
+ if (imageMatch) svc.image = imageMatch[1].trim().replace(/^['"]|['"]$/g, "");
42
+
43
+ const portsMatch = section.match(/^\s{4}ports:\n((?:\s{6}- .+\n?)+)/m);
44
+ if (portsMatch) {
45
+ svc.ports = [];
46
+ for (const line of portsMatch[1].split("\n")) {
47
+ const item = line.match(/^\s{6}-\s+['"]?(.+?)['"]?$/);
48
+ if (item) svc.ports.push(item[1].trim());
49
+ }
50
+ }
51
+
52
+ const volsMatch = section.match(/^\s{4}volumes:\n((?:\s{6}- .+\n?)+)/m);
53
+ if (volsMatch) {
54
+ svc.volumes = [];
55
+ for (const line of volsMatch[1].split("\n")) {
56
+ const item = line.match(/^\s{6}-\s+['"]?(.+?)['"]?$/);
57
+ if (item) svc.volumes.push(item[1].trim());
58
+ }
59
+ }
60
+
61
+ services.set(name, svc);
62
+ }
63
+
64
+ return services;
65
+ }
66
+
67
+ /**
68
+ * Extract named volumes from the top-level volumes: section.
69
+ */
70
+ export function extractNamedVolumes(yaml: string): Set<string> {
71
+ const volumes = new Set<string>();
72
+
73
+ const volumesIdx = yaml.search(/^volumes:\s*$/m);
74
+ if (volumesIdx === -1) return volumes;
75
+
76
+ const afterVolumes = yaml.slice(volumesIdx + yaml.slice(volumesIdx).indexOf("\n") + 1);
77
+ const endMatch = afterVolumes.search(/^[a-z]/m);
78
+ const volumesContent = endMatch === -1 ? afterVolumes : afterVolumes.slice(0, endMatch);
79
+
80
+ for (const line of volumesContent.split("\n")) {
81
+ const match = line.match(/^\s{2}([a-z][a-z0-9_-]*):/);
82
+ if (match) volumes.add(match[1]);
83
+ }
84
+
85
+ return volumes;
86
+ }
87
+
88
+ /**
89
+ * Check if an image tag represents :latest or is untagged.
90
+ */
91
+ export function isLatestOrUntagged(image: string): boolean {
92
+ if (!image || image.startsWith("${")) return false;
93
+ if (image.endsWith(":latest")) return true;
94
+ const parts = image.split("/");
95
+ const lastPart = parts[parts.length - 1];
96
+ return !lastPart.includes(":") && !lastPart.includes("@");
97
+ }
98
+
99
+ /**
100
+ * Get Dockerfile content from SerializerResult files map.
101
+ */
102
+ export function extractDockerfiles(output: unknown): Map<string, string> {
103
+ const files = new Map<string, string>();
104
+ if (typeof output !== "object" || output === null) return files;
105
+ if (!("files" in output)) return files;
106
+
107
+ const outputFiles = (output as { files: Record<string, string> }).files;
108
+ for (const [name, content] of Object.entries(outputFiles)) {
109
+ if (name.startsWith("Dockerfile")) {
110
+ files.set(name, content);
111
+ }
112
+ }
113
+ return files;
114
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * DKRD001: No Latest Image Tag
3
+ *
4
+ * Detects services using :latest or untagged images in docker-compose.yml.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { getPrimaryOutput, extractServices, isLatestOrUntagged } from "./docker-helpers";
9
+
10
+ export const dkrd001: PostSynthCheck = {
11
+ id: "DKRD001",
12
+ description: "Service uses :latest or untagged image — specify an explicit version tag",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ const diagnostics: PostSynthDiagnostic[] = [];
16
+
17
+ for (const [_outputName, output] of ctx.outputs) {
18
+ const yaml = getPrimaryOutput(output);
19
+ if (!yaml) continue;
20
+
21
+ const services = extractServices(yaml);
22
+ for (const [name, svc] of services) {
23
+ if (svc.image && isLatestOrUntagged(svc.image)) {
24
+ diagnostics.push({
25
+ checkId: "DKRD001",
26
+ severity: "warning",
27
+ message: `Service "${name}" uses image "${svc.image}" which is :latest or untagged. Use an explicit version tag for reproducible builds.`,
28
+ lexicon: "docker",
29
+ });
30
+ }
31
+ }
32
+ }
33
+
34
+ return diagnostics;
35
+ },
36
+ };
@@ -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,36 @@
1
+ /**
2
+ * DKRD012: No Root User
3
+ *
4
+ * Warns when a Dockerfile has no USER instruction,
5
+ * meaning the container runs as root by default.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { extractDockerfiles } from "./docker-helpers";
10
+
11
+ export const dkrd012: PostSynthCheck = {
12
+ id: "DKRD012",
13
+ description: "Dockerfile has no USER instruction — container runs as root",
14
+
15
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
16
+ const diagnostics: PostSynthDiagnostic[] = [];
17
+
18
+ for (const [_outputName, output] of ctx.outputs) {
19
+ const dockerfiles = extractDockerfiles(output);
20
+
21
+ for (const [fileName, content] of dockerfiles) {
22
+ const hasUser = /^USER\s+/m.test(content);
23
+ if (!hasUser) {
24
+ diagnostics.push({
25
+ checkId: "DKRD012",
26
+ severity: "warning",
27
+ message: `${fileName}: No USER instruction found. The container will run as root. Add a USER instruction to improve security.`,
28
+ lexicon: "docker",
29
+ });
30
+ }
31
+ }
32
+ }
33
+
34
+ return diagnostics;
35
+ },
36
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * DKRD011: Prefer COPY over ADD
3
+ *
4
+ * ADD has surprising behavior (auto-extracts archives, fetches URLs).
5
+ * Use COPY when you just need to copy local files.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { extractDockerfiles } from "./docker-helpers";
10
+
11
+ function looksLikeUrl(src: string): boolean {
12
+ return src.startsWith("http://") || src.startsWith("https://") || src.startsWith("ftp://");
13
+ }
14
+
15
+ function looksLikeArchive(src: string): boolean {
16
+ return /\.(tar|tar\.gz|tgz|tar\.bz2|tar\.xz|zip)$/i.test(src);
17
+ }
18
+
19
+ export const dkrd011: PostSynthCheck = {
20
+ id: "DKRD011",
21
+ description: "Prefer COPY over ADD when not fetching URLs or extracting archives",
22
+
23
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
24
+ const diagnostics: PostSynthDiagnostic[] = [];
25
+
26
+ for (const [_outputName, output] of ctx.outputs) {
27
+ const dockerfiles = extractDockerfiles(output);
28
+
29
+ for (const [fileName, content] of dockerfiles) {
30
+ const lines = content.split("\n");
31
+ for (const line of lines) {
32
+ const trimmed = line.trim();
33
+ if (/^ADD\s+/.test(trimmed)) {
34
+ // Extract src argument (first token after ADD)
35
+ const parts = trimmed.replace(/^ADD\s+/, "").split(/\s+/);
36
+ const src = parts[0];
37
+
38
+ if (!looksLikeUrl(src) && !looksLikeArchive(src)) {
39
+ diagnostics.push({
40
+ checkId: "DKRD011",
41
+ severity: "info",
42
+ message: `${fileName}: Use COPY instead of ADD when not fetching URLs or extracting archives. ADD "${src}" could be COPY.`,
43
+ lexicon: "docker",
44
+ });
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ return diagnostics;
52
+ },
53
+ };