@docker-doctor/cli 0.2.0 → 0.3.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 +32 -4
- package/dist/cli.cjs +475 -62
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +427 -15
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +8 -17
- package/dist/index.d.mts +8 -17
- package/dist/index.mjs +1 -1
- package/dist/{src-Bt3AB-Xl.mjs → src-EqtziccA.mjs} +277 -111
- package/dist/src-EqtziccA.mjs.map +1 -0
- package/dist/{src-BUYNqQd8.cjs → src-TEhFWhpk.cjs} +277 -113
- package/dist/src-TEhFWhpk.cjs.map +1 -0
- package/package.json +2 -2
- package/dist/src-BUYNqQd8.cjs.map +0 -1
- package/dist/src-Bt3AB-Xl.mjs.map +0 -1
|
@@ -2,13 +2,11 @@
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parse } from "yaml";
|
|
5
|
-
import * as Data from "effect/Data";
|
|
6
|
-
import * as Schema from "effect/Schema";
|
|
7
5
|
|
|
8
6
|
//#region package.json
|
|
9
7
|
var package_default = {
|
|
10
8
|
name: "@docker-doctor/cli",
|
|
11
|
-
version: "0.
|
|
9
|
+
version: "0.3.0",
|
|
12
10
|
description: "Static analysis for Dockerfile and Docker Compose files",
|
|
13
11
|
keywords: [
|
|
14
12
|
"best-practices",
|
|
@@ -53,13 +51,13 @@ var package_default = {
|
|
|
53
51
|
scripts: {
|
|
54
52
|
"build": "NODE_OPTIONS='--max-old-space-size=4096' tsdown",
|
|
55
53
|
"dev": "NODE_OPTIONS='--max-old-space-size=4096' tsdown --watch",
|
|
54
|
+
"test": "bun test",
|
|
56
55
|
"typecheck": "tsc --noEmit",
|
|
57
56
|
"clean": "git clean -xdf .turbo node_modules dist"
|
|
58
57
|
},
|
|
59
58
|
dependencies: {
|
|
60
59
|
"chalk": "^5.4.1",
|
|
61
60
|
"commander": "^15.0.0",
|
|
62
|
-
"effect": "4.0.0-beta.70",
|
|
63
61
|
"yaml": "^2.7.0"
|
|
64
62
|
},
|
|
65
63
|
devDependencies: {
|
|
@@ -103,76 +101,122 @@ const discoverProject = async (rootDir) => {
|
|
|
103
101
|
|
|
104
102
|
//#endregion
|
|
105
103
|
//#region ../core/src/parsers/dockerfile-parser.ts
|
|
104
|
+
const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
105
|
+
"ADD",
|
|
106
|
+
"ARG",
|
|
107
|
+
"CMD",
|
|
108
|
+
"COPY",
|
|
109
|
+
"ENTRYPOINT",
|
|
110
|
+
"ENV",
|
|
111
|
+
"EXPOSE",
|
|
112
|
+
"FROM",
|
|
113
|
+
"HEALTHCHECK",
|
|
114
|
+
"LABEL",
|
|
115
|
+
"MAINTAINER",
|
|
116
|
+
"ONBUILD",
|
|
117
|
+
"RUN",
|
|
118
|
+
"SHELL",
|
|
119
|
+
"STOPSIGNAL",
|
|
120
|
+
"USER",
|
|
121
|
+
"VOLUME",
|
|
122
|
+
"WORKDIR"
|
|
123
|
+
]);
|
|
124
|
+
const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
|
|
125
|
+
const HEREDOC_OPENER_RE = /<<-?\s*(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
|
|
126
|
+
const createParserState = () => ({
|
|
127
|
+
currentArgs: "",
|
|
128
|
+
currentInstruction: "",
|
|
129
|
+
heredocQueue: [],
|
|
130
|
+
instructions: [],
|
|
131
|
+
rawAccumulator: [],
|
|
132
|
+
startLine: 0
|
|
133
|
+
});
|
|
134
|
+
const closeInstruction = (state) => {
|
|
135
|
+
if (state.currentInstruction) state.instructions.push({
|
|
136
|
+
args: state.currentArgs,
|
|
137
|
+
instruction: state.currentInstruction,
|
|
138
|
+
line: state.startLine,
|
|
139
|
+
raw: state.rawAccumulator.join("\n")
|
|
140
|
+
});
|
|
141
|
+
state.currentInstruction = "";
|
|
142
|
+
state.currentArgs = "";
|
|
143
|
+
state.rawAccumulator = [];
|
|
144
|
+
};
|
|
145
|
+
const matchInstructionKeyword = (lineContent) => {
|
|
146
|
+
const match = lineContent.match(INSTRUCTION_LINE_RE);
|
|
147
|
+
const matchedWord = match?.groups?.inst.toUpperCase();
|
|
148
|
+
if (matchedWord && DOCKERFILE_KEYWORDS.has(matchedWord)) return {
|
|
149
|
+
args: match?.groups?.args ?? "",
|
|
150
|
+
instruction: matchedWord
|
|
151
|
+
};
|
|
152
|
+
const word = lineContent.trim().toUpperCase();
|
|
153
|
+
if (DOCKERFILE_KEYWORDS.has(word)) return {
|
|
154
|
+
args: "",
|
|
155
|
+
instruction: word
|
|
156
|
+
};
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
const findHeredocDelimiters = (lineContent) => [...lineContent.matchAll(HEREDOC_OPENER_RE)].map((m) => m.groups?.delim ?? "");
|
|
160
|
+
const processHeredocLine = (state, trimmed) => {
|
|
161
|
+
if (trimmed === state.heredocQueue[0]) state.heredocQueue.shift();
|
|
162
|
+
else state.currentArgs += (state.currentArgs ? " " : "") + trimmed;
|
|
163
|
+
if (state.heredocQueue.length === 0) closeInstruction(state);
|
|
164
|
+
};
|
|
165
|
+
const processInstructionLine = (state, trimmed, lineNum) => {
|
|
166
|
+
let lineContent = trimmed;
|
|
167
|
+
if (lineContent.startsWith("#")) return;
|
|
168
|
+
const hasContinuation = lineContent.endsWith("\\");
|
|
169
|
+
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
170
|
+
if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
|
|
171
|
+
else {
|
|
172
|
+
state.startLine = lineNum;
|
|
173
|
+
const matched = matchInstructionKeyword(lineContent);
|
|
174
|
+
if (matched) {
|
|
175
|
+
state.currentInstruction = matched.instruction;
|
|
176
|
+
state.currentArgs = matched.args;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (state.currentInstruction) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
|
|
180
|
+
if (state.heredocQueue.length > 0) return;
|
|
181
|
+
if (!hasContinuation) closeInstruction(state);
|
|
182
|
+
};
|
|
106
183
|
const parseDockerfile = (content) => {
|
|
107
|
-
const
|
|
184
|
+
const state = createParserState();
|
|
108
185
|
const lines = content.split(/\r?\n/u);
|
|
109
|
-
let currentInstruction = "";
|
|
110
|
-
let currentArgs = "";
|
|
111
|
-
let startLine = 0;
|
|
112
|
-
let rawAccumulator = [];
|
|
113
186
|
for (const [i, rawLine] of lines.entries()) {
|
|
114
187
|
const trimmed = rawLine.trim();
|
|
115
188
|
const lineNum = i + 1;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
122
|
-
if (currentInstruction) currentArgs += (currentArgs ? " " : "") + lineContent;
|
|
123
|
-
else {
|
|
124
|
-
startLine = lineNum;
|
|
125
|
-
const match = lineContent.match(/^(?<inst>[A-Z]+)\s+(?<args>.*)$/iu);
|
|
126
|
-
if (match?.groups) {
|
|
127
|
-
currentInstruction = match.groups.inst.toUpperCase();
|
|
128
|
-
currentArgs = match.groups.args;
|
|
129
|
-
} else {
|
|
130
|
-
const word = lineContent.trim().toUpperCase();
|
|
131
|
-
if ([
|
|
132
|
-
"RUN",
|
|
133
|
-
"CMD",
|
|
134
|
-
"ENTRYPOINT",
|
|
135
|
-
"EXPOSE",
|
|
136
|
-
"USER",
|
|
137
|
-
"WORKDIR"
|
|
138
|
-
].includes(word)) {
|
|
139
|
-
currentInstruction = word;
|
|
140
|
-
currentArgs = "";
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (!hasContinuation) {
|
|
145
|
-
if (currentInstruction) instructions.push({
|
|
146
|
-
args: currentArgs,
|
|
147
|
-
instruction: currentInstruction,
|
|
148
|
-
line: startLine,
|
|
149
|
-
raw: rawAccumulator.join("\n")
|
|
150
|
-
});
|
|
151
|
-
currentInstruction = "";
|
|
152
|
-
currentArgs = "";
|
|
153
|
-
rawAccumulator = [];
|
|
154
|
-
}
|
|
189
|
+
const insideHeredoc = state.heredocQueue.length > 0;
|
|
190
|
+
if (!state.currentInstruction && !insideHeredoc && (trimmed === "" || trimmed.startsWith("#"))) continue;
|
|
191
|
+
state.rawAccumulator.push(rawLine);
|
|
192
|
+
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
193
|
+
else processInstructionLine(state, trimmed, lineNum);
|
|
155
194
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
instruction: currentInstruction,
|
|
159
|
-
line: startLine,
|
|
160
|
-
raw: rawAccumulator.join("\n")
|
|
161
|
-
});
|
|
162
|
-
return instructions;
|
|
195
|
+
closeInstruction(state);
|
|
196
|
+
return state.instructions;
|
|
163
197
|
};
|
|
164
198
|
|
|
165
199
|
//#endregion
|
|
166
200
|
//#region ../core/src/errors/config-error.ts
|
|
167
|
-
var ConfigError = class extends
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
201
|
+
var ConfigError = class extends Error {
|
|
202
|
+
_tag = "ConfigError";
|
|
203
|
+
constructor(options) {
|
|
204
|
+
super(options.message);
|
|
205
|
+
this.name = "ConfigError";
|
|
206
|
+
}
|
|
207
|
+
};
|
|
172
208
|
|
|
173
209
|
//#endregion
|
|
174
210
|
//#region ../core/src/errors/parse-error.ts
|
|
175
|
-
var ParseError = class extends
|
|
211
|
+
var ParseError = class extends Error {
|
|
212
|
+
_tag = "ParseError";
|
|
213
|
+
file;
|
|
214
|
+
constructor(options) {
|
|
215
|
+
super(options.message);
|
|
216
|
+
this.name = "ParseError";
|
|
217
|
+
this.file = options.file;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
176
220
|
|
|
177
221
|
//#endregion
|
|
178
222
|
//#region ../core/src/parsers/compose-parser.ts
|
|
@@ -215,7 +259,8 @@ const preferCopyOverAdd = {
|
|
|
215
259
|
check(instructions, file) {
|
|
216
260
|
const diagnostics = [];
|
|
217
261
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
218
|
-
const
|
|
262
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
263
|
+
if (!src) continue;
|
|
219
264
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
220
265
|
const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
|
|
221
266
|
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));
|
|
@@ -448,6 +493,54 @@ const composeRules = [
|
|
|
448
493
|
useDependsOnCondition
|
|
449
494
|
];
|
|
450
495
|
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region ../core/src/parsers/image-ref.ts
|
|
498
|
+
const parseImageRef = (ref) => {
|
|
499
|
+
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
500
|
+
isVariable: true,
|
|
501
|
+
name: ref
|
|
502
|
+
};
|
|
503
|
+
let remainder = ref;
|
|
504
|
+
let digest;
|
|
505
|
+
const atIndex = remainder.indexOf("@");
|
|
506
|
+
if (atIndex !== -1) {
|
|
507
|
+
digest = remainder.slice(atIndex + 1);
|
|
508
|
+
remainder = remainder.slice(0, atIndex);
|
|
509
|
+
}
|
|
510
|
+
let tag;
|
|
511
|
+
const lastColonIndex = remainder.lastIndexOf(":");
|
|
512
|
+
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
513
|
+
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
514
|
+
tag = remainder.slice(lastColonIndex + 1);
|
|
515
|
+
remainder = remainder.slice(0, lastColonIndex);
|
|
516
|
+
}
|
|
517
|
+
let registry;
|
|
518
|
+
const firstSlashIndex = remainder.indexOf("/");
|
|
519
|
+
if (firstSlashIndex !== -1) {
|
|
520
|
+
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
521
|
+
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
522
|
+
registry = firstSegment;
|
|
523
|
+
remainder = remainder.slice(firstSlashIndex + 1);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
digest,
|
|
528
|
+
isVariable: false,
|
|
529
|
+
name: remainder,
|
|
530
|
+
registry,
|
|
531
|
+
tag
|
|
532
|
+
};
|
|
533
|
+
};
|
|
534
|
+
const collectStageAliases = (instructions) => {
|
|
535
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
536
|
+
for (const inst of instructions) {
|
|
537
|
+
if (inst.instruction !== "FROM") continue;
|
|
538
|
+
const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
|
|
539
|
+
if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
|
|
540
|
+
}
|
|
541
|
+
return aliases;
|
|
542
|
+
};
|
|
543
|
+
|
|
451
544
|
//#endregion
|
|
452
545
|
//#region ../core/src/rules/image-size.ts
|
|
453
546
|
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -462,17 +555,16 @@ const preferSlimBase = {
|
|
|
462
555
|
category: "Image Size",
|
|
463
556
|
check(instructions, file) {
|
|
464
557
|
const diagnostics = [];
|
|
558
|
+
const stageAliases = collectStageAliases(instructions);
|
|
465
559
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
466
560
|
const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
467
561
|
if (!imagePart || imagePart === "scratch") continue;
|
|
468
|
-
const
|
|
469
|
-
if (
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
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));
|
|
475
|
-
}
|
|
562
|
+
const ref = parseImageRef(imagePart);
|
|
563
|
+
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
564
|
+
if (ref.digest) continue;
|
|
565
|
+
if (!ref.tag) continue;
|
|
566
|
+
const tag = ref.tag.toLowerCase();
|
|
567
|
+
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));
|
|
476
568
|
}
|
|
477
569
|
return diagnostics;
|
|
478
570
|
},
|
|
@@ -557,10 +649,12 @@ const orderLayers = {
|
|
|
557
649
|
const diagnostics = [];
|
|
558
650
|
let copyAllLine = -1;
|
|
559
651
|
for (const inst of instructions) {
|
|
652
|
+
if (inst.instruction === "FROM") copyAllLine = -1;
|
|
560
653
|
if (inst.instruction === "COPY" || inst.instruction === "ADD") {
|
|
561
654
|
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
562
655
|
if (!src) continue;
|
|
563
|
-
|
|
656
|
+
const normalized = src.replace(/^\.\//u, "");
|
|
657
|
+
if ((normalized === "." || normalized === "" || normalized === "*" || normalized === "src" || normalized.startsWith("src/")) && copyAllLine === -1) copyAllLine = inst.line;
|
|
564
658
|
}
|
|
565
659
|
if (inst.instruction === "RUN" && copyAllLine !== -1) {
|
|
566
660
|
const args = inst.args.toLowerCase();
|
|
@@ -600,7 +694,9 @@ const useDockerignore = {
|
|
|
600
694
|
check(instructions, file, context) {
|
|
601
695
|
if (instructions.some((inst) => {
|
|
602
696
|
if (inst.instruction === "COPY" || inst.instruction === "ADD") {
|
|
603
|
-
|
|
697
|
+
if (/(?:^|\s)--from=/u.test(inst.args)) return false;
|
|
698
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
699
|
+
if (!src) return false;
|
|
604
700
|
return src === "." || src === "./" || src === "*";
|
|
605
701
|
}
|
|
606
702
|
return false;
|
|
@@ -636,7 +732,10 @@ const noRootUser = {
|
|
|
636
732
|
check(instructions, file) {
|
|
637
733
|
let lastUser = "root";
|
|
638
734
|
let lastUserLine = 1;
|
|
639
|
-
for (const inst of instructions) if (inst.instruction === "
|
|
735
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
736
|
+
lastUser = "root";
|
|
737
|
+
lastUserLine = inst.line;
|
|
738
|
+
} else if (inst.instruction === "USER") {
|
|
640
739
|
lastUser = inst.args.trim().toLowerCase();
|
|
641
740
|
lastUserLine = inst.line;
|
|
642
741
|
}
|
|
@@ -653,12 +752,12 @@ const noSecretsInEnv = {
|
|
|
653
752
|
check(instructions, file) {
|
|
654
753
|
const diagnostics = [];
|
|
655
754
|
const secretKeywords = [
|
|
656
|
-
/password/iu,
|
|
657
|
-
/secret/iu,
|
|
658
|
-
/token/iu,
|
|
659
|
-
/api_key/iu,
|
|
660
|
-
/private_key/iu,
|
|
661
|
-
/auth/iu
|
|
755
|
+
/(?:^|[_-])password(?:[_-]|$)/iu,
|
|
756
|
+
/(?:^|[_-])secret(?:[_-]|$)/iu,
|
|
757
|
+
/(?:^|[_-])token(?:[_-]|$)/iu,
|
|
758
|
+
/(?:^|[_-])api_key(?:[_-]|$)/iu,
|
|
759
|
+
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
760
|
+
/(?:^|[_-])auth(?:[_-]|$)/iu
|
|
662
761
|
];
|
|
663
762
|
for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
|
|
664
763
|
const args = inst.args.trim();
|
|
@@ -693,15 +792,14 @@ const pinImageVersion = {
|
|
|
693
792
|
category: "Security",
|
|
694
793
|
check(instructions, file) {
|
|
695
794
|
const diagnostics = [];
|
|
795
|
+
const stageAliases = collectStageAliases(instructions);
|
|
696
796
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
697
797
|
const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
698
798
|
if (!imagePart || imagePart === "scratch") continue;
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
if (
|
|
702
|
-
else if (
|
|
703
|
-
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));
|
|
704
|
-
}
|
|
799
|
+
const ref = parseImageRef(imagePart);
|
|
800
|
+
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
801
|
+
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));
|
|
802
|
+
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));
|
|
705
803
|
}
|
|
706
804
|
return diagnostics;
|
|
707
805
|
},
|
|
@@ -715,7 +813,8 @@ const noAddRemote = {
|
|
|
715
813
|
check(instructions, file) {
|
|
716
814
|
const diagnostics = [];
|
|
717
815
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
718
|
-
const
|
|
816
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
817
|
+
if (!src) continue;
|
|
719
818
|
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));
|
|
720
819
|
}
|
|
721
820
|
return diagnostics;
|
|
@@ -774,23 +873,65 @@ const runComposeRules = (composeContent, file, rulesConfig) => {
|
|
|
774
873
|
|
|
775
874
|
//#endregion
|
|
776
875
|
//#region ../core/src/schemas/config.ts
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
]
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
});
|
|
876
|
+
const RULE_SEVERITIES = [
|
|
877
|
+
"error",
|
|
878
|
+
"warning",
|
|
879
|
+
"info",
|
|
880
|
+
"off"
|
|
881
|
+
];
|
|
882
|
+
const RULE_CATEGORIES = [
|
|
883
|
+
"Best Practices",
|
|
884
|
+
"Compose",
|
|
885
|
+
"Image Size",
|
|
886
|
+
"Performance",
|
|
887
|
+
"Security"
|
|
888
|
+
];
|
|
889
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
890
|
+
const isRuleSeverity = (value) => typeof value === "string" && RULE_SEVERITIES.includes(value);
|
|
891
|
+
const validateRules = (value) => {
|
|
892
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "rules" must be an object, got ${typeof value}`);
|
|
893
|
+
for (const [key, severity] of Object.entries(value)) if (!isRuleSeverity(severity)) throw new Error(`Invalid severity ${JSON.stringify(severity)} for rule "${key}"`);
|
|
894
|
+
return value;
|
|
895
|
+
};
|
|
896
|
+
const validateCategories = (value) => {
|
|
897
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "categories" must be an object, got ${typeof value}`);
|
|
898
|
+
const result = {};
|
|
899
|
+
for (const [key, severity] of Object.entries(value)) {
|
|
900
|
+
if (!RULE_CATEGORIES.includes(key)) continue;
|
|
901
|
+
if (!isRuleSeverity(severity)) throw new Error(`Invalid severity ${JSON.stringify(severity)} for category "${key}"`);
|
|
902
|
+
result[key] = severity;
|
|
903
|
+
}
|
|
904
|
+
return result;
|
|
905
|
+
};
|
|
906
|
+
const validateIgnore = (value) => {
|
|
907
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "ignore" must be an object, got ${typeof value}`);
|
|
908
|
+
const result = {};
|
|
909
|
+
if ("files" in value && value.files !== void 0) {
|
|
910
|
+
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");
|
|
911
|
+
result.files = value.files;
|
|
912
|
+
}
|
|
913
|
+
return result;
|
|
914
|
+
};
|
|
915
|
+
const describeInvalidTopLevel = (input) => {
|
|
916
|
+
if (input === null) return "null";
|
|
917
|
+
if (Array.isArray(input)) return "array";
|
|
918
|
+
return typeof input;
|
|
919
|
+
};
|
|
920
|
+
/**
|
|
921
|
+
* Validates and normalizes a raw config object, throwing on invalid input.
|
|
922
|
+
*
|
|
923
|
+
* Mirrors the legacy schema's behavior exactly, including silently
|
|
924
|
+
* dropping unknown top-level (and nested) keys rather than throwing
|
|
925
|
+
* or preserving them.
|
|
926
|
+
*/
|
|
927
|
+
const validateConfig = (input) => {
|
|
928
|
+
if (!isPlainObject(input)) throw new Error(`Invalid config: expected an object, got ${describeInvalidTopLevel(input)}`);
|
|
929
|
+
const result = {};
|
|
930
|
+
if ("rules" in input && input.rules !== void 0) result.rules = validateRules(input.rules);
|
|
931
|
+
if ("categories" in input && input.categories !== void 0) result.categories = validateCategories(input.categories);
|
|
932
|
+
if ("ignore" in input && input.ignore !== void 0) result.ignore = validateIgnore(input.ignore);
|
|
933
|
+
return result;
|
|
934
|
+
};
|
|
794
935
|
|
|
795
936
|
//#endregion
|
|
796
937
|
//#region ../core/src/config/loader.ts
|
|
@@ -847,7 +988,7 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
847
988
|
}
|
|
848
989
|
if (!configObject) return {};
|
|
849
990
|
try {
|
|
850
|
-
return
|
|
991
|
+
return validateConfig(configObject);
|
|
851
992
|
} catch (error) {
|
|
852
993
|
throw new ConfigError({ message: `Invalid configuration format: ${error instanceof Error ? error.message : String(error)}` });
|
|
853
994
|
}
|
|
@@ -855,24 +996,48 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
855
996
|
|
|
856
997
|
//#endregion
|
|
857
998
|
//#region ../core/src/scoring.ts
|
|
999
|
+
const SCORE_BUCKETS = [
|
|
1000
|
+
{
|
|
1001
|
+
emoji: "🏆",
|
|
1002
|
+
label: "Excellent",
|
|
1003
|
+
min: 90
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
emoji: "✅",
|
|
1007
|
+
label: "Good",
|
|
1008
|
+
min: 75
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
emoji: "⚠️",
|
|
1012
|
+
label: "Needs Work",
|
|
1013
|
+
min: 50
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
emoji: "🚨",
|
|
1017
|
+
label: "Critical",
|
|
1018
|
+
min: 0
|
|
1019
|
+
}
|
|
1020
|
+
];
|
|
1021
|
+
const getScoreBucket = (score) => {
|
|
1022
|
+
for (const bucket of SCORE_BUCKETS) if (score >= bucket.min) return bucket;
|
|
1023
|
+
return SCORE_BUCKETS.at(-1);
|
|
1024
|
+
};
|
|
858
1025
|
const calculateScore = (diagnostics) => {
|
|
859
1026
|
let penalty = 0;
|
|
860
1027
|
for (const diag of diagnostics) if (diag.severity === "error") penalty += 10;
|
|
861
1028
|
else if (diag.severity === "warning") penalty += 4;
|
|
862
1029
|
else if (diag.severity === "info") penalty += 1;
|
|
863
|
-
const score = Math.
|
|
864
|
-
|
|
865
|
-
if (score >= 90) label = "Excellent 🏆";
|
|
866
|
-
else if (score >= 75) label = "Good ✅";
|
|
867
|
-
else if (score >= 50) label = "Needs Work ⚠️";
|
|
1030
|
+
const score = Math.round(100 * Math.exp(-penalty / 70));
|
|
1031
|
+
const bucket = getScoreBucket(score);
|
|
868
1032
|
return {
|
|
869
|
-
label
|
|
1033
|
+
label: `${bucket.label} ${bucket.emoji}`,
|
|
870
1034
|
score
|
|
871
1035
|
};
|
|
872
1036
|
};
|
|
873
1037
|
|
|
874
1038
|
//#endregion
|
|
875
1039
|
//#region ../core/src/report.ts
|
|
1040
|
+
const REPORT_SCHEMA_VERSION = 2;
|
|
876
1041
|
const toJsonReport = (diagnostics, score, label, project) => ({
|
|
877
1042
|
diagnostics: diagnostics.map((d) => ({
|
|
878
1043
|
column: d.column,
|
|
@@ -885,10 +1050,11 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
885
1050
|
})),
|
|
886
1051
|
label,
|
|
887
1052
|
project,
|
|
1053
|
+
schemaVersion: 2,
|
|
888
1054
|
score,
|
|
889
1055
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
890
1056
|
});
|
|
891
1057
|
|
|
892
1058
|
//#endregion
|
|
893
1059
|
export { runDockerfileRules as a, parseCompose as c, package_default as d, runComposeRules as i, parseDockerfile as l, calculateScore as n, allRules as o, loadConfig as r, findRule as s, toJsonReport as t, discoverProject as u };
|
|
894
|
-
//# sourceMappingURL=src-
|
|
1060
|
+
//# sourceMappingURL=src-EqtziccA.mjs.map
|