@docker-doctor/cli 0.2.1 → 0.3.1
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 +36 -5
- package/dist/cli.cjs +851 -62
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +802 -15
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +6 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -17
- package/dist/index.d.mts +11 -17
- package/dist/index.mjs +6 -2
- package/dist/index.mjs.map +1 -1
- package/dist/{src-O8FXMq8O.cjs → src-38C8Tk2b.cjs} +295 -121
- package/dist/src-38C8Tk2b.cjs.map +1 -0
- package/dist/{src-DvNFg6Nz.mjs → src-BOzcDwm-.mjs} +295 -119
- package/dist/src-BOzcDwm-.mjs.map +1 -0
- package/package.json +5 -3
- package/skill/docker-doctor/SKILL.md +60 -0
- package/skill/docker-doctor/references/explain.md +72 -0
- package/dist/src-DvNFg6Nz.mjs.map +0 -1
- package/dist/src-O8FXMq8O.cjs.map +0 -1
|
@@ -31,15 +31,11 @@ node_fs_promises = __toESM(node_fs_promises, 1);
|
|
|
31
31
|
let node_path = require("node:path");
|
|
32
32
|
node_path = __toESM(node_path, 1);
|
|
33
33
|
let yaml = require("yaml");
|
|
34
|
-
let effect_Data = require("effect/Data");
|
|
35
|
-
effect_Data = __toESM(effect_Data, 1);
|
|
36
|
-
let effect_Schema = require("effect/Schema");
|
|
37
|
-
effect_Schema = __toESM(effect_Schema, 1);
|
|
38
34
|
|
|
39
35
|
//#region package.json
|
|
40
36
|
var package_default = {
|
|
41
37
|
name: "@docker-doctor/cli",
|
|
42
|
-
version: "0.
|
|
38
|
+
version: "0.3.1",
|
|
43
39
|
description: "Static analysis for Dockerfile and Docker Compose files",
|
|
44
40
|
keywords: [
|
|
45
41
|
"best-practices",
|
|
@@ -64,7 +60,11 @@ var package_default = {
|
|
|
64
60
|
"directory": "packages/docker-doctor"
|
|
65
61
|
},
|
|
66
62
|
bin: { "docker-doctor": "dist/cli.mjs" },
|
|
67
|
-
files: [
|
|
63
|
+
files: [
|
|
64
|
+
"dist",
|
|
65
|
+
"skill",
|
|
66
|
+
"LICENSE"
|
|
67
|
+
],
|
|
68
68
|
type: "module",
|
|
69
69
|
sideEffects: false,
|
|
70
70
|
exports: { ".": {
|
|
@@ -84,13 +84,14 @@ var package_default = {
|
|
|
84
84
|
scripts: {
|
|
85
85
|
"build": "NODE_OPTIONS='--max-old-space-size=4096' tsdown",
|
|
86
86
|
"dev": "NODE_OPTIONS='--max-old-space-size=4096' tsdown --watch",
|
|
87
|
+
"test": "bun test",
|
|
87
88
|
"typecheck": "tsc --noEmit",
|
|
88
|
-
"clean": "git clean -xdf .turbo node_modules dist"
|
|
89
|
+
"clean": "git clean -xdf .turbo node_modules dist skill"
|
|
89
90
|
},
|
|
90
91
|
dependencies: {
|
|
92
|
+
"agent-install": "0.0.8",
|
|
91
93
|
"chalk": "^5.4.1",
|
|
92
94
|
"commander": "^15.0.0",
|
|
93
|
-
"effect": "4.0.0-beta.70",
|
|
94
95
|
"yaml": "^2.7.0"
|
|
95
96
|
},
|
|
96
97
|
devDependencies: {
|
|
@@ -134,76 +135,122 @@ const discoverProject = async (rootDir) => {
|
|
|
134
135
|
|
|
135
136
|
//#endregion
|
|
136
137
|
//#region ../core/src/parsers/dockerfile-parser.ts
|
|
138
|
+
const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
139
|
+
"ADD",
|
|
140
|
+
"ARG",
|
|
141
|
+
"CMD",
|
|
142
|
+
"COPY",
|
|
143
|
+
"ENTRYPOINT",
|
|
144
|
+
"ENV",
|
|
145
|
+
"EXPOSE",
|
|
146
|
+
"FROM",
|
|
147
|
+
"HEALTHCHECK",
|
|
148
|
+
"LABEL",
|
|
149
|
+
"MAINTAINER",
|
|
150
|
+
"ONBUILD",
|
|
151
|
+
"RUN",
|
|
152
|
+
"SHELL",
|
|
153
|
+
"STOPSIGNAL",
|
|
154
|
+
"USER",
|
|
155
|
+
"VOLUME",
|
|
156
|
+
"WORKDIR"
|
|
157
|
+
]);
|
|
158
|
+
const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
|
|
159
|
+
const HEREDOC_OPENER_RE = /<<-?\s*(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
|
|
160
|
+
const createParserState = () => ({
|
|
161
|
+
currentArgs: "",
|
|
162
|
+
currentInstruction: "",
|
|
163
|
+
heredocQueue: [],
|
|
164
|
+
instructions: [],
|
|
165
|
+
rawAccumulator: [],
|
|
166
|
+
startLine: 0
|
|
167
|
+
});
|
|
168
|
+
const closeInstruction = (state) => {
|
|
169
|
+
if (state.currentInstruction) state.instructions.push({
|
|
170
|
+
args: state.currentArgs,
|
|
171
|
+
instruction: state.currentInstruction,
|
|
172
|
+
line: state.startLine,
|
|
173
|
+
raw: state.rawAccumulator.join("\n")
|
|
174
|
+
});
|
|
175
|
+
state.currentInstruction = "";
|
|
176
|
+
state.currentArgs = "";
|
|
177
|
+
state.rawAccumulator = [];
|
|
178
|
+
};
|
|
179
|
+
const matchInstructionKeyword = (lineContent) => {
|
|
180
|
+
const match = lineContent.match(INSTRUCTION_LINE_RE);
|
|
181
|
+
const matchedWord = match?.groups?.inst.toUpperCase();
|
|
182
|
+
if (matchedWord && DOCKERFILE_KEYWORDS.has(matchedWord)) return {
|
|
183
|
+
args: match?.groups?.args ?? "",
|
|
184
|
+
instruction: matchedWord
|
|
185
|
+
};
|
|
186
|
+
const word = lineContent.trim().toUpperCase();
|
|
187
|
+
if (DOCKERFILE_KEYWORDS.has(word)) return {
|
|
188
|
+
args: "",
|
|
189
|
+
instruction: word
|
|
190
|
+
};
|
|
191
|
+
return null;
|
|
192
|
+
};
|
|
193
|
+
const findHeredocDelimiters = (lineContent) => [...lineContent.matchAll(HEREDOC_OPENER_RE)].map((m) => m.groups?.delim ?? "");
|
|
194
|
+
const processHeredocLine = (state, trimmed) => {
|
|
195
|
+
if (trimmed === state.heredocQueue[0]) state.heredocQueue.shift();
|
|
196
|
+
else state.currentArgs += (state.currentArgs ? " " : "") + trimmed;
|
|
197
|
+
if (state.heredocQueue.length === 0) closeInstruction(state);
|
|
198
|
+
};
|
|
199
|
+
const processInstructionLine = (state, trimmed, lineNum) => {
|
|
200
|
+
let lineContent = trimmed;
|
|
201
|
+
if (lineContent.startsWith("#")) return;
|
|
202
|
+
const hasContinuation = lineContent.endsWith("\\");
|
|
203
|
+
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
204
|
+
if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
|
|
205
|
+
else {
|
|
206
|
+
state.startLine = lineNum;
|
|
207
|
+
const matched = matchInstructionKeyword(lineContent);
|
|
208
|
+
if (matched) {
|
|
209
|
+
state.currentInstruction = matched.instruction;
|
|
210
|
+
state.currentArgs = matched.args;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (state.currentInstruction) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
|
|
214
|
+
if (state.heredocQueue.length > 0) return;
|
|
215
|
+
if (!hasContinuation) closeInstruction(state);
|
|
216
|
+
};
|
|
137
217
|
const parseDockerfile = (content) => {
|
|
138
|
-
const
|
|
218
|
+
const state = createParserState();
|
|
139
219
|
const lines = content.split(/\r?\n/u);
|
|
140
|
-
let currentInstruction = "";
|
|
141
|
-
let currentArgs = "";
|
|
142
|
-
let startLine = 0;
|
|
143
|
-
let rawAccumulator = [];
|
|
144
220
|
for (const [i, rawLine] of lines.entries()) {
|
|
145
221
|
const trimmed = rawLine.trim();
|
|
146
222
|
const lineNum = i + 1;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
153
|
-
if (currentInstruction) currentArgs += (currentArgs ? " " : "") + lineContent;
|
|
154
|
-
else {
|
|
155
|
-
startLine = lineNum;
|
|
156
|
-
const match = lineContent.match(/^(?<inst>[A-Z]+)\s+(?<args>.*)$/iu);
|
|
157
|
-
if (match?.groups) {
|
|
158
|
-
currentInstruction = match.groups.inst.toUpperCase();
|
|
159
|
-
currentArgs = match.groups.args;
|
|
160
|
-
} else {
|
|
161
|
-
const word = lineContent.trim().toUpperCase();
|
|
162
|
-
if ([
|
|
163
|
-
"RUN",
|
|
164
|
-
"CMD",
|
|
165
|
-
"ENTRYPOINT",
|
|
166
|
-
"EXPOSE",
|
|
167
|
-
"USER",
|
|
168
|
-
"WORKDIR"
|
|
169
|
-
].includes(word)) {
|
|
170
|
-
currentInstruction = word;
|
|
171
|
-
currentArgs = "";
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (!hasContinuation) {
|
|
176
|
-
if (currentInstruction) instructions.push({
|
|
177
|
-
args: currentArgs,
|
|
178
|
-
instruction: currentInstruction,
|
|
179
|
-
line: startLine,
|
|
180
|
-
raw: rawAccumulator.join("\n")
|
|
181
|
-
});
|
|
182
|
-
currentInstruction = "";
|
|
183
|
-
currentArgs = "";
|
|
184
|
-
rawAccumulator = [];
|
|
185
|
-
}
|
|
223
|
+
const insideHeredoc = state.heredocQueue.length > 0;
|
|
224
|
+
if (!state.currentInstruction && !insideHeredoc && (trimmed === "" || trimmed.startsWith("#"))) continue;
|
|
225
|
+
state.rawAccumulator.push(rawLine);
|
|
226
|
+
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
227
|
+
else processInstructionLine(state, trimmed, lineNum);
|
|
186
228
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
instruction: currentInstruction,
|
|
190
|
-
line: startLine,
|
|
191
|
-
raw: rawAccumulator.join("\n")
|
|
192
|
-
});
|
|
193
|
-
return instructions;
|
|
229
|
+
closeInstruction(state);
|
|
230
|
+
return state.instructions;
|
|
194
231
|
};
|
|
195
232
|
|
|
196
233
|
//#endregion
|
|
197
234
|
//#region ../core/src/errors/config-error.ts
|
|
198
|
-
var ConfigError = class extends
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
235
|
+
var ConfigError = class extends Error {
|
|
236
|
+
_tag = "ConfigError";
|
|
237
|
+
constructor(options) {
|
|
238
|
+
super(options.message);
|
|
239
|
+
this.name = "ConfigError";
|
|
240
|
+
}
|
|
241
|
+
};
|
|
203
242
|
|
|
204
243
|
//#endregion
|
|
205
244
|
//#region ../core/src/errors/parse-error.ts
|
|
206
|
-
var ParseError = class extends
|
|
245
|
+
var ParseError = class extends Error {
|
|
246
|
+
_tag = "ParseError";
|
|
247
|
+
file;
|
|
248
|
+
constructor(options) {
|
|
249
|
+
super(options.message);
|
|
250
|
+
this.name = "ParseError";
|
|
251
|
+
this.file = options.file;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
207
254
|
|
|
208
255
|
//#endregion
|
|
209
256
|
//#region ../core/src/parsers/compose-parser.ts
|
|
@@ -246,7 +293,8 @@ const preferCopyOverAdd = {
|
|
|
246
293
|
check(instructions, file) {
|
|
247
294
|
const diagnostics = [];
|
|
248
295
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
249
|
-
const
|
|
296
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
297
|
+
if (!src) continue;
|
|
250
298
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
251
299
|
const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
|
|
252
300
|
if (!isRemote && !isArchive) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `ADD instruction used for regular files: '${inst.args}'. COPY is simpler and less prone to magic side effects.`, this.help, inst.line));
|
|
@@ -479,6 +527,54 @@ const composeRules = [
|
|
|
479
527
|
useDependsOnCondition
|
|
480
528
|
];
|
|
481
529
|
|
|
530
|
+
//#endregion
|
|
531
|
+
//#region ../core/src/parsers/image-ref.ts
|
|
532
|
+
const parseImageRef = (ref) => {
|
|
533
|
+
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
534
|
+
isVariable: true,
|
|
535
|
+
name: ref
|
|
536
|
+
};
|
|
537
|
+
let remainder = ref;
|
|
538
|
+
let digest;
|
|
539
|
+
const atIndex = remainder.indexOf("@");
|
|
540
|
+
if (atIndex !== -1) {
|
|
541
|
+
digest = remainder.slice(atIndex + 1);
|
|
542
|
+
remainder = remainder.slice(0, atIndex);
|
|
543
|
+
}
|
|
544
|
+
let tag;
|
|
545
|
+
const lastColonIndex = remainder.lastIndexOf(":");
|
|
546
|
+
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
547
|
+
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
548
|
+
tag = remainder.slice(lastColonIndex + 1);
|
|
549
|
+
remainder = remainder.slice(0, lastColonIndex);
|
|
550
|
+
}
|
|
551
|
+
let registry;
|
|
552
|
+
const firstSlashIndex = remainder.indexOf("/");
|
|
553
|
+
if (firstSlashIndex !== -1) {
|
|
554
|
+
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
555
|
+
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
556
|
+
registry = firstSegment;
|
|
557
|
+
remainder = remainder.slice(firstSlashIndex + 1);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
digest,
|
|
562
|
+
isVariable: false,
|
|
563
|
+
name: remainder,
|
|
564
|
+
registry,
|
|
565
|
+
tag
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
const collectStageAliases = (instructions) => {
|
|
569
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
570
|
+
for (const inst of instructions) {
|
|
571
|
+
if (inst.instruction !== "FROM") continue;
|
|
572
|
+
const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
|
|
573
|
+
if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
|
|
574
|
+
}
|
|
575
|
+
return aliases;
|
|
576
|
+
};
|
|
577
|
+
|
|
482
578
|
//#endregion
|
|
483
579
|
//#region ../core/src/rules/image-size.ts
|
|
484
580
|
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -493,17 +589,16 @@ const preferSlimBase = {
|
|
|
493
589
|
category: "Image Size",
|
|
494
590
|
check(instructions, file) {
|
|
495
591
|
const diagnostics = [];
|
|
592
|
+
const stageAliases = collectStageAliases(instructions);
|
|
496
593
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
497
594
|
const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
498
595
|
if (!imagePart || imagePart === "scratch") continue;
|
|
499
|
-
const
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (!isSlim && !isSha && !isStageReference) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Base image '${imagePart}' may be a full-OS distribution. Consider using a slim or alpine alternative.`, this.help, inst.line));
|
|
506
|
-
}
|
|
596
|
+
const ref = parseImageRef(imagePart);
|
|
597
|
+
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
598
|
+
if (ref.digest) continue;
|
|
599
|
+
if (!ref.tag) continue;
|
|
600
|
+
const tag = ref.tag.toLowerCase();
|
|
601
|
+
if (!(tag.includes("alpine") || tag.includes("slim") || tag.includes("distroless"))) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Base image '${imagePart}' may be a full-OS distribution. Consider using a slim or alpine alternative.`, this.help, inst.line));
|
|
507
602
|
}
|
|
508
603
|
return diagnostics;
|
|
509
604
|
},
|
|
@@ -588,10 +683,12 @@ const orderLayers = {
|
|
|
588
683
|
const diagnostics = [];
|
|
589
684
|
let copyAllLine = -1;
|
|
590
685
|
for (const inst of instructions) {
|
|
686
|
+
if (inst.instruction === "FROM") copyAllLine = -1;
|
|
591
687
|
if (inst.instruction === "COPY" || inst.instruction === "ADD") {
|
|
592
688
|
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
593
689
|
if (!src) continue;
|
|
594
|
-
|
|
690
|
+
const normalized = src.replace(/^\.\//u, "");
|
|
691
|
+
if ((normalized === "." || normalized === "" || normalized === "*" || normalized === "src" || normalized.startsWith("src/")) && copyAllLine === -1) copyAllLine = inst.line;
|
|
595
692
|
}
|
|
596
693
|
if (inst.instruction === "RUN" && copyAllLine !== -1) {
|
|
597
694
|
const args = inst.args.toLowerCase();
|
|
@@ -631,7 +728,9 @@ const useDockerignore = {
|
|
|
631
728
|
check(instructions, file, context) {
|
|
632
729
|
if (instructions.some((inst) => {
|
|
633
730
|
if (inst.instruction === "COPY" || inst.instruction === "ADD") {
|
|
634
|
-
|
|
731
|
+
if (/(?:^|\s)--from=/u.test(inst.args)) return false;
|
|
732
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
733
|
+
if (!src) return false;
|
|
635
734
|
return src === "." || src === "./" || src === "*";
|
|
636
735
|
}
|
|
637
736
|
return false;
|
|
@@ -667,7 +766,10 @@ const noRootUser = {
|
|
|
667
766
|
check(instructions, file) {
|
|
668
767
|
let lastUser = "root";
|
|
669
768
|
let lastUserLine = 1;
|
|
670
|
-
for (const inst of instructions) if (inst.instruction === "
|
|
769
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
770
|
+
lastUser = "root";
|
|
771
|
+
lastUserLine = inst.line;
|
|
772
|
+
} else if (inst.instruction === "USER") {
|
|
671
773
|
lastUser = inst.args.trim().toLowerCase();
|
|
672
774
|
lastUserLine = inst.line;
|
|
673
775
|
}
|
|
@@ -684,12 +786,12 @@ const noSecretsInEnv = {
|
|
|
684
786
|
check(instructions, file) {
|
|
685
787
|
const diagnostics = [];
|
|
686
788
|
const secretKeywords = [
|
|
687
|
-
/password/iu,
|
|
688
|
-
/secret/iu,
|
|
689
|
-
/token/iu,
|
|
690
|
-
/api_key/iu,
|
|
691
|
-
/private_key/iu,
|
|
692
|
-
/auth/iu
|
|
789
|
+
/(?:^|[_-])password(?:[_-]|$)/iu,
|
|
790
|
+
/(?:^|[_-])secret(?:[_-]|$)/iu,
|
|
791
|
+
/(?:^|[_-])token(?:[_-]|$)/iu,
|
|
792
|
+
/(?:^|[_-])api_key(?:[_-]|$)/iu,
|
|
793
|
+
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
794
|
+
/(?:^|[_-])auth(?:[_-]|$)/iu
|
|
693
795
|
];
|
|
694
796
|
for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
|
|
695
797
|
const args = inst.args.trim();
|
|
@@ -724,15 +826,14 @@ const pinImageVersion = {
|
|
|
724
826
|
category: "Security",
|
|
725
827
|
check(instructions, file) {
|
|
726
828
|
const diagnostics = [];
|
|
829
|
+
const stageAliases = collectStageAliases(instructions);
|
|
727
830
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
728
831
|
const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
729
832
|
if (!imagePart || imagePart === "scratch") continue;
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
if (
|
|
733
|
-
else if (
|
|
734
|
-
if (imagePart.slice(colonIndex + 1) === "latest") diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' uses the mutable 'latest' tag. This makes builds non-deterministic.`, this.help, inst.line));
|
|
735
|
-
}
|
|
833
|
+
const ref = parseImageRef(imagePart);
|
|
834
|
+
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
835
|
+
if (!(ref.tag || ref.digest)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' does not specify a tag. This makes builds non-deterministic.`, this.help, inst.line));
|
|
836
|
+
else if (ref.tag === "latest" && !ref.digest) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Base image '${imagePart}' uses the mutable 'latest' tag. This makes builds non-deterministic.`, this.help, inst.line));
|
|
736
837
|
}
|
|
737
838
|
return diagnostics;
|
|
738
839
|
},
|
|
@@ -746,7 +847,8 @@ const noAddRemote = {
|
|
|
746
847
|
check(instructions, file) {
|
|
747
848
|
const diagnostics = [];
|
|
748
849
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
749
|
-
const
|
|
850
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
851
|
+
if (!src) continue;
|
|
750
852
|
if (src.startsWith("http://") || src.startsWith("https://")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `ADD instruction uses a remote URL '${src}'. Remote files added via ADD cannot be cleaned up in later layers, increasing image size.`, this.help, inst.line));
|
|
751
853
|
}
|
|
752
854
|
return diagnostics;
|
|
@@ -805,23 +907,65 @@ const runComposeRules = (composeContent, file, rulesConfig) => {
|
|
|
805
907
|
|
|
806
908
|
//#endregion
|
|
807
909
|
//#region ../core/src/schemas/config.ts
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
]
|
|
814
|
-
const
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
});
|
|
910
|
+
const RULE_SEVERITIES = [
|
|
911
|
+
"error",
|
|
912
|
+
"warning",
|
|
913
|
+
"info",
|
|
914
|
+
"off"
|
|
915
|
+
];
|
|
916
|
+
const RULE_CATEGORIES = [
|
|
917
|
+
"Best Practices",
|
|
918
|
+
"Compose",
|
|
919
|
+
"Image Size",
|
|
920
|
+
"Performance",
|
|
921
|
+
"Security"
|
|
922
|
+
];
|
|
923
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
924
|
+
const isRuleSeverity = (value) => typeof value === "string" && RULE_SEVERITIES.includes(value);
|
|
925
|
+
const validateRules = (value) => {
|
|
926
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "rules" must be an object, got ${typeof value}`);
|
|
927
|
+
for (const [key, severity] of Object.entries(value)) if (!isRuleSeverity(severity)) throw new Error(`Invalid severity ${JSON.stringify(severity)} for rule "${key}"`);
|
|
928
|
+
return value;
|
|
929
|
+
};
|
|
930
|
+
const validateCategories = (value) => {
|
|
931
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "categories" must be an object, got ${typeof value}`);
|
|
932
|
+
const result = {};
|
|
933
|
+
for (const [key, severity] of Object.entries(value)) {
|
|
934
|
+
if (!RULE_CATEGORIES.includes(key)) continue;
|
|
935
|
+
if (!isRuleSeverity(severity)) throw new Error(`Invalid severity ${JSON.stringify(severity)} for category "${key}"`);
|
|
936
|
+
result[key] = severity;
|
|
937
|
+
}
|
|
938
|
+
return result;
|
|
939
|
+
};
|
|
940
|
+
const validateIgnore = (value) => {
|
|
941
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "ignore" must be an object, got ${typeof value}`);
|
|
942
|
+
const result = {};
|
|
943
|
+
if ("files" in value && value.files !== void 0) {
|
|
944
|
+
if (!Array.isArray(value.files) || !value.files.every((item) => typeof item === "string")) throw new Error("Invalid config: \"ignore.files\" must be an array of strings");
|
|
945
|
+
result.files = value.files;
|
|
946
|
+
}
|
|
947
|
+
return result;
|
|
948
|
+
};
|
|
949
|
+
const describeInvalidTopLevel = (input) => {
|
|
950
|
+
if (input === null) return "null";
|
|
951
|
+
if (Array.isArray(input)) return "array";
|
|
952
|
+
return typeof input;
|
|
953
|
+
};
|
|
954
|
+
/**
|
|
955
|
+
* Validates and normalizes a raw config object, throwing on invalid input.
|
|
956
|
+
*
|
|
957
|
+
* Mirrors the legacy schema's behavior exactly, including silently
|
|
958
|
+
* dropping unknown top-level (and nested) keys rather than throwing
|
|
959
|
+
* or preserving them.
|
|
960
|
+
*/
|
|
961
|
+
const validateConfig = (input) => {
|
|
962
|
+
if (!isPlainObject(input)) throw new Error(`Invalid config: expected an object, got ${describeInvalidTopLevel(input)}`);
|
|
963
|
+
const result = {};
|
|
964
|
+
if ("rules" in input && input.rules !== void 0) result.rules = validateRules(input.rules);
|
|
965
|
+
if ("categories" in input && input.categories !== void 0) result.categories = validateCategories(input.categories);
|
|
966
|
+
if ("ignore" in input && input.ignore !== void 0) result.ignore = validateIgnore(input.ignore);
|
|
967
|
+
return result;
|
|
968
|
+
};
|
|
825
969
|
|
|
826
970
|
//#endregion
|
|
827
971
|
//#region ../core/src/config/loader.ts
|
|
@@ -833,13 +977,16 @@ const fileExists = async (filePath) => {
|
|
|
833
977
|
return false;
|
|
834
978
|
}
|
|
835
979
|
};
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
return JSON.parse(content);
|
|
980
|
+
const parseConfigFile = async (filePath, format, parse) => {
|
|
981
|
+
try {
|
|
982
|
+
return parse(await node_fs_promises.default.readFile(filePath, "utf-8"));
|
|
840
983
|
} catch (error) {
|
|
841
|
-
throw new ConfigError({ message: `Failed to parse config
|
|
984
|
+
throw new ConfigError({ message: `Failed to parse config ${format}: ${error instanceof Error ? error.message : String(error)}` });
|
|
842
985
|
}
|
|
986
|
+
};
|
|
987
|
+
const importConfig = async (filePath) => {
|
|
988
|
+
if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
|
|
989
|
+
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", yaml.parse);
|
|
843
990
|
try {
|
|
844
991
|
const configModule = await import(filePath);
|
|
845
992
|
return configModule.default || configModule;
|
|
@@ -859,7 +1006,9 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
859
1006
|
"docker-doctor.config.js",
|
|
860
1007
|
"docker-doctor.config.mjs",
|
|
861
1008
|
"docker-doctor.config.cjs",
|
|
862
|
-
"docker-doctor.config.json"
|
|
1009
|
+
"docker-doctor.config.json",
|
|
1010
|
+
"docker-doctor.config.yaml",
|
|
1011
|
+
"docker-doctor.config.yml"
|
|
863
1012
|
]) {
|
|
864
1013
|
const fullPath = node_path.default.join(rootDir, cand);
|
|
865
1014
|
if (await fileExists(fullPath)) {
|
|
@@ -878,7 +1027,7 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
878
1027
|
}
|
|
879
1028
|
if (!configObject) return {};
|
|
880
1029
|
try {
|
|
881
|
-
return
|
|
1030
|
+
return validateConfig(configObject);
|
|
882
1031
|
} catch (error) {
|
|
883
1032
|
throw new ConfigError({ message: `Invalid configuration format: ${error instanceof Error ? error.message : String(error)}` });
|
|
884
1033
|
}
|
|
@@ -886,24 +1035,48 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
886
1035
|
|
|
887
1036
|
//#endregion
|
|
888
1037
|
//#region ../core/src/scoring.ts
|
|
1038
|
+
const SCORE_BUCKETS = [
|
|
1039
|
+
{
|
|
1040
|
+
emoji: "🏆",
|
|
1041
|
+
label: "Excellent",
|
|
1042
|
+
min: 90
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
emoji: "✅",
|
|
1046
|
+
label: "Good",
|
|
1047
|
+
min: 75
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
emoji: "⚠️",
|
|
1051
|
+
label: "Needs Work",
|
|
1052
|
+
min: 50
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
emoji: "🚨",
|
|
1056
|
+
label: "Critical",
|
|
1057
|
+
min: 0
|
|
1058
|
+
}
|
|
1059
|
+
];
|
|
1060
|
+
const getScoreBucket = (score) => {
|
|
1061
|
+
for (const bucket of SCORE_BUCKETS) if (score >= bucket.min) return bucket;
|
|
1062
|
+
return SCORE_BUCKETS.at(-1);
|
|
1063
|
+
};
|
|
889
1064
|
const calculateScore = (diagnostics) => {
|
|
890
1065
|
let penalty = 0;
|
|
891
1066
|
for (const diag of diagnostics) if (diag.severity === "error") penalty += 10;
|
|
892
1067
|
else if (diag.severity === "warning") penalty += 4;
|
|
893
1068
|
else if (diag.severity === "info") penalty += 1;
|
|
894
|
-
const score = Math.
|
|
895
|
-
|
|
896
|
-
if (score >= 90) label = "Excellent 🏆";
|
|
897
|
-
else if (score >= 75) label = "Good ✅";
|
|
898
|
-
else if (score >= 50) label = "Needs Work ⚠️";
|
|
1069
|
+
const score = Math.round(100 * Math.exp(-penalty / 70));
|
|
1070
|
+
const bucket = getScoreBucket(score);
|
|
899
1071
|
return {
|
|
900
|
-
label
|
|
1072
|
+
label: `${bucket.label} ${bucket.emoji}`,
|
|
901
1073
|
score
|
|
902
1074
|
};
|
|
903
1075
|
};
|
|
904
1076
|
|
|
905
1077
|
//#endregion
|
|
906
1078
|
//#region ../core/src/report.ts
|
|
1079
|
+
const REPORT_SCHEMA_VERSION = 2;
|
|
907
1080
|
const toJsonReport = (diagnostics, score, label, project) => ({
|
|
908
1081
|
diagnostics: diagnostics.map((d) => ({
|
|
909
1082
|
column: d.column,
|
|
@@ -916,6 +1089,7 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
916
1089
|
})),
|
|
917
1090
|
label,
|
|
918
1091
|
project,
|
|
1092
|
+
schemaVersion: 2,
|
|
919
1093
|
score,
|
|
920
1094
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
921
1095
|
});
|
|
@@ -993,4 +1167,4 @@ Object.defineProperty(exports, 'toJsonReport', {
|
|
|
993
1167
|
return toJsonReport;
|
|
994
1168
|
}
|
|
995
1169
|
});
|
|
996
|
-
//# sourceMappingURL=src-
|
|
1170
|
+
//# sourceMappingURL=src-38C8Tk2b.cjs.map
|