@docker-doctor/cli 0.2.1 → 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 +22 -0
- package/dist/cli.cjs +468 -59
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +418 -10
- 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-DvNFg6Nz.mjs → src-EqtziccA.mjs} +277 -111
- package/dist/src-EqtziccA.mjs.map +1 -0
- package/dist/{src-O8FXMq8O.cjs → src-TEhFWhpk.cjs} +277 -113
- package/dist/src-TEhFWhpk.cjs.map +1 -0
- package/package.json +2 -2
- 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.0",
|
|
43
39
|
description: "Static analysis for Dockerfile and Docker Compose files",
|
|
44
40
|
keywords: [
|
|
45
41
|
"best-practices",
|
|
@@ -84,13 +80,13 @@ var package_default = {
|
|
|
84
80
|
scripts: {
|
|
85
81
|
"build": "NODE_OPTIONS='--max-old-space-size=4096' tsdown",
|
|
86
82
|
"dev": "NODE_OPTIONS='--max-old-space-size=4096' tsdown --watch",
|
|
83
|
+
"test": "bun test",
|
|
87
84
|
"typecheck": "tsc --noEmit",
|
|
88
85
|
"clean": "git clean -xdf .turbo node_modules dist"
|
|
89
86
|
},
|
|
90
87
|
dependencies: {
|
|
91
88
|
"chalk": "^5.4.1",
|
|
92
89
|
"commander": "^15.0.0",
|
|
93
|
-
"effect": "4.0.0-beta.70",
|
|
94
90
|
"yaml": "^2.7.0"
|
|
95
91
|
},
|
|
96
92
|
devDependencies: {
|
|
@@ -134,76 +130,122 @@ const discoverProject = async (rootDir) => {
|
|
|
134
130
|
|
|
135
131
|
//#endregion
|
|
136
132
|
//#region ../core/src/parsers/dockerfile-parser.ts
|
|
133
|
+
const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
134
|
+
"ADD",
|
|
135
|
+
"ARG",
|
|
136
|
+
"CMD",
|
|
137
|
+
"COPY",
|
|
138
|
+
"ENTRYPOINT",
|
|
139
|
+
"ENV",
|
|
140
|
+
"EXPOSE",
|
|
141
|
+
"FROM",
|
|
142
|
+
"HEALTHCHECK",
|
|
143
|
+
"LABEL",
|
|
144
|
+
"MAINTAINER",
|
|
145
|
+
"ONBUILD",
|
|
146
|
+
"RUN",
|
|
147
|
+
"SHELL",
|
|
148
|
+
"STOPSIGNAL",
|
|
149
|
+
"USER",
|
|
150
|
+
"VOLUME",
|
|
151
|
+
"WORKDIR"
|
|
152
|
+
]);
|
|
153
|
+
const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
|
|
154
|
+
const HEREDOC_OPENER_RE = /<<-?\s*(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
|
|
155
|
+
const createParserState = () => ({
|
|
156
|
+
currentArgs: "",
|
|
157
|
+
currentInstruction: "",
|
|
158
|
+
heredocQueue: [],
|
|
159
|
+
instructions: [],
|
|
160
|
+
rawAccumulator: [],
|
|
161
|
+
startLine: 0
|
|
162
|
+
});
|
|
163
|
+
const closeInstruction = (state) => {
|
|
164
|
+
if (state.currentInstruction) state.instructions.push({
|
|
165
|
+
args: state.currentArgs,
|
|
166
|
+
instruction: state.currentInstruction,
|
|
167
|
+
line: state.startLine,
|
|
168
|
+
raw: state.rawAccumulator.join("\n")
|
|
169
|
+
});
|
|
170
|
+
state.currentInstruction = "";
|
|
171
|
+
state.currentArgs = "";
|
|
172
|
+
state.rawAccumulator = [];
|
|
173
|
+
};
|
|
174
|
+
const matchInstructionKeyword = (lineContent) => {
|
|
175
|
+
const match = lineContent.match(INSTRUCTION_LINE_RE);
|
|
176
|
+
const matchedWord = match?.groups?.inst.toUpperCase();
|
|
177
|
+
if (matchedWord && DOCKERFILE_KEYWORDS.has(matchedWord)) return {
|
|
178
|
+
args: match?.groups?.args ?? "",
|
|
179
|
+
instruction: matchedWord
|
|
180
|
+
};
|
|
181
|
+
const word = lineContent.trim().toUpperCase();
|
|
182
|
+
if (DOCKERFILE_KEYWORDS.has(word)) return {
|
|
183
|
+
args: "",
|
|
184
|
+
instruction: word
|
|
185
|
+
};
|
|
186
|
+
return null;
|
|
187
|
+
};
|
|
188
|
+
const findHeredocDelimiters = (lineContent) => [...lineContent.matchAll(HEREDOC_OPENER_RE)].map((m) => m.groups?.delim ?? "");
|
|
189
|
+
const processHeredocLine = (state, trimmed) => {
|
|
190
|
+
if (trimmed === state.heredocQueue[0]) state.heredocQueue.shift();
|
|
191
|
+
else state.currentArgs += (state.currentArgs ? " " : "") + trimmed;
|
|
192
|
+
if (state.heredocQueue.length === 0) closeInstruction(state);
|
|
193
|
+
};
|
|
194
|
+
const processInstructionLine = (state, trimmed, lineNum) => {
|
|
195
|
+
let lineContent = trimmed;
|
|
196
|
+
if (lineContent.startsWith("#")) return;
|
|
197
|
+
const hasContinuation = lineContent.endsWith("\\");
|
|
198
|
+
if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
|
|
199
|
+
if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
|
|
200
|
+
else {
|
|
201
|
+
state.startLine = lineNum;
|
|
202
|
+
const matched = matchInstructionKeyword(lineContent);
|
|
203
|
+
if (matched) {
|
|
204
|
+
state.currentInstruction = matched.instruction;
|
|
205
|
+
state.currentArgs = matched.args;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (state.currentInstruction) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
|
|
209
|
+
if (state.heredocQueue.length > 0) return;
|
|
210
|
+
if (!hasContinuation) closeInstruction(state);
|
|
211
|
+
};
|
|
137
212
|
const parseDockerfile = (content) => {
|
|
138
|
-
const
|
|
213
|
+
const state = createParserState();
|
|
139
214
|
const lines = content.split(/\r?\n/u);
|
|
140
|
-
let currentInstruction = "";
|
|
141
|
-
let currentArgs = "";
|
|
142
|
-
let startLine = 0;
|
|
143
|
-
let rawAccumulator = [];
|
|
144
215
|
for (const [i, rawLine] of lines.entries()) {
|
|
145
216
|
const trimmed = rawLine.trim();
|
|
146
217
|
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
|
-
}
|
|
218
|
+
const insideHeredoc = state.heredocQueue.length > 0;
|
|
219
|
+
if (!state.currentInstruction && !insideHeredoc && (trimmed === "" || trimmed.startsWith("#"))) continue;
|
|
220
|
+
state.rawAccumulator.push(rawLine);
|
|
221
|
+
if (insideHeredoc) processHeredocLine(state, trimmed);
|
|
222
|
+
else processInstructionLine(state, trimmed, lineNum);
|
|
186
223
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
instruction: currentInstruction,
|
|
190
|
-
line: startLine,
|
|
191
|
-
raw: rawAccumulator.join("\n")
|
|
192
|
-
});
|
|
193
|
-
return instructions;
|
|
224
|
+
closeInstruction(state);
|
|
225
|
+
return state.instructions;
|
|
194
226
|
};
|
|
195
227
|
|
|
196
228
|
//#endregion
|
|
197
229
|
//#region ../core/src/errors/config-error.ts
|
|
198
|
-
var ConfigError = class extends
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
230
|
+
var ConfigError = class extends Error {
|
|
231
|
+
_tag = "ConfigError";
|
|
232
|
+
constructor(options) {
|
|
233
|
+
super(options.message);
|
|
234
|
+
this.name = "ConfigError";
|
|
235
|
+
}
|
|
236
|
+
};
|
|
203
237
|
|
|
204
238
|
//#endregion
|
|
205
239
|
//#region ../core/src/errors/parse-error.ts
|
|
206
|
-
var ParseError = class extends
|
|
240
|
+
var ParseError = class extends Error {
|
|
241
|
+
_tag = "ParseError";
|
|
242
|
+
file;
|
|
243
|
+
constructor(options) {
|
|
244
|
+
super(options.message);
|
|
245
|
+
this.name = "ParseError";
|
|
246
|
+
this.file = options.file;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
207
249
|
|
|
208
250
|
//#endregion
|
|
209
251
|
//#region ../core/src/parsers/compose-parser.ts
|
|
@@ -246,7 +288,8 @@ const preferCopyOverAdd = {
|
|
|
246
288
|
check(instructions, file) {
|
|
247
289
|
const diagnostics = [];
|
|
248
290
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
249
|
-
const
|
|
291
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
292
|
+
if (!src) continue;
|
|
250
293
|
const isRemote = src.startsWith("http://") || src.startsWith("https://");
|
|
251
294
|
const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
|
|
252
295
|
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 +522,54 @@ const composeRules = [
|
|
|
479
522
|
useDependsOnCondition
|
|
480
523
|
];
|
|
481
524
|
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region ../core/src/parsers/image-ref.ts
|
|
527
|
+
const parseImageRef = (ref) => {
|
|
528
|
+
if (ref.includes("${") || ref.startsWith("$")) return {
|
|
529
|
+
isVariable: true,
|
|
530
|
+
name: ref
|
|
531
|
+
};
|
|
532
|
+
let remainder = ref;
|
|
533
|
+
let digest;
|
|
534
|
+
const atIndex = remainder.indexOf("@");
|
|
535
|
+
if (atIndex !== -1) {
|
|
536
|
+
digest = remainder.slice(atIndex + 1);
|
|
537
|
+
remainder = remainder.slice(0, atIndex);
|
|
538
|
+
}
|
|
539
|
+
let tag;
|
|
540
|
+
const lastColonIndex = remainder.lastIndexOf(":");
|
|
541
|
+
const lastSlashIndex = remainder.lastIndexOf("/");
|
|
542
|
+
if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
|
|
543
|
+
tag = remainder.slice(lastColonIndex + 1);
|
|
544
|
+
remainder = remainder.slice(0, lastColonIndex);
|
|
545
|
+
}
|
|
546
|
+
let registry;
|
|
547
|
+
const firstSlashIndex = remainder.indexOf("/");
|
|
548
|
+
if (firstSlashIndex !== -1) {
|
|
549
|
+
const firstSegment = remainder.slice(0, firstSlashIndex);
|
|
550
|
+
if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
|
|
551
|
+
registry = firstSegment;
|
|
552
|
+
remainder = remainder.slice(firstSlashIndex + 1);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
digest,
|
|
557
|
+
isVariable: false,
|
|
558
|
+
name: remainder,
|
|
559
|
+
registry,
|
|
560
|
+
tag
|
|
561
|
+
};
|
|
562
|
+
};
|
|
563
|
+
const collectStageAliases = (instructions) => {
|
|
564
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
565
|
+
for (const inst of instructions) {
|
|
566
|
+
if (inst.instruction !== "FROM") continue;
|
|
567
|
+
const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
|
|
568
|
+
if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
|
|
569
|
+
}
|
|
570
|
+
return aliases;
|
|
571
|
+
};
|
|
572
|
+
|
|
482
573
|
//#endregion
|
|
483
574
|
//#region ../core/src/rules/image-size.ts
|
|
484
575
|
const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
|
|
@@ -493,17 +584,16 @@ const preferSlimBase = {
|
|
|
493
584
|
category: "Image Size",
|
|
494
585
|
check(instructions, file) {
|
|
495
586
|
const diagnostics = [];
|
|
587
|
+
const stageAliases = collectStageAliases(instructions);
|
|
496
588
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
497
589
|
const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
498
590
|
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
|
-
}
|
|
591
|
+
const ref = parseImageRef(imagePart);
|
|
592
|
+
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
593
|
+
if (ref.digest) continue;
|
|
594
|
+
if (!ref.tag) continue;
|
|
595
|
+
const tag = ref.tag.toLowerCase();
|
|
596
|
+
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
597
|
}
|
|
508
598
|
return diagnostics;
|
|
509
599
|
},
|
|
@@ -588,10 +678,12 @@ const orderLayers = {
|
|
|
588
678
|
const diagnostics = [];
|
|
589
679
|
let copyAllLine = -1;
|
|
590
680
|
for (const inst of instructions) {
|
|
681
|
+
if (inst.instruction === "FROM") copyAllLine = -1;
|
|
591
682
|
if (inst.instruction === "COPY" || inst.instruction === "ADD") {
|
|
592
683
|
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
593
684
|
if (!src) continue;
|
|
594
|
-
|
|
685
|
+
const normalized = src.replace(/^\.\//u, "");
|
|
686
|
+
if ((normalized === "." || normalized === "" || normalized === "*" || normalized === "src" || normalized.startsWith("src/")) && copyAllLine === -1) copyAllLine = inst.line;
|
|
595
687
|
}
|
|
596
688
|
if (inst.instruction === "RUN" && copyAllLine !== -1) {
|
|
597
689
|
const args = inst.args.toLowerCase();
|
|
@@ -631,7 +723,9 @@ const useDockerignore = {
|
|
|
631
723
|
check(instructions, file, context) {
|
|
632
724
|
if (instructions.some((inst) => {
|
|
633
725
|
if (inst.instruction === "COPY" || inst.instruction === "ADD") {
|
|
634
|
-
|
|
726
|
+
if (/(?:^|\s)--from=/u.test(inst.args)) return false;
|
|
727
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
728
|
+
if (!src) return false;
|
|
635
729
|
return src === "." || src === "./" || src === "*";
|
|
636
730
|
}
|
|
637
731
|
return false;
|
|
@@ -667,7 +761,10 @@ const noRootUser = {
|
|
|
667
761
|
check(instructions, file) {
|
|
668
762
|
let lastUser = "root";
|
|
669
763
|
let lastUserLine = 1;
|
|
670
|
-
for (const inst of instructions) if (inst.instruction === "
|
|
764
|
+
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
765
|
+
lastUser = "root";
|
|
766
|
+
lastUserLine = inst.line;
|
|
767
|
+
} else if (inst.instruction === "USER") {
|
|
671
768
|
lastUser = inst.args.trim().toLowerCase();
|
|
672
769
|
lastUserLine = inst.line;
|
|
673
770
|
}
|
|
@@ -684,12 +781,12 @@ const noSecretsInEnv = {
|
|
|
684
781
|
check(instructions, file) {
|
|
685
782
|
const diagnostics = [];
|
|
686
783
|
const secretKeywords = [
|
|
687
|
-
/password/iu,
|
|
688
|
-
/secret/iu,
|
|
689
|
-
/token/iu,
|
|
690
|
-
/api_key/iu,
|
|
691
|
-
/private_key/iu,
|
|
692
|
-
/auth/iu
|
|
784
|
+
/(?:^|[_-])password(?:[_-]|$)/iu,
|
|
785
|
+
/(?:^|[_-])secret(?:[_-]|$)/iu,
|
|
786
|
+
/(?:^|[_-])token(?:[_-]|$)/iu,
|
|
787
|
+
/(?:^|[_-])api_key(?:[_-]|$)/iu,
|
|
788
|
+
/(?:^|[_-])private_key(?:[_-]|$)/iu,
|
|
789
|
+
/(?:^|[_-])auth(?:[_-]|$)/iu
|
|
693
790
|
];
|
|
694
791
|
for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
|
|
695
792
|
const args = inst.args.trim();
|
|
@@ -724,15 +821,14 @@ const pinImageVersion = {
|
|
|
724
821
|
category: "Security",
|
|
725
822
|
check(instructions, file) {
|
|
726
823
|
const diagnostics = [];
|
|
824
|
+
const stageAliases = collectStageAliases(instructions);
|
|
727
825
|
for (const inst of instructions) if (inst.instruction === "FROM") {
|
|
728
826
|
const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
729
827
|
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
|
-
}
|
|
828
|
+
const ref = parseImageRef(imagePart);
|
|
829
|
+
if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
|
|
830
|
+
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));
|
|
831
|
+
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
832
|
}
|
|
737
833
|
return diagnostics;
|
|
738
834
|
},
|
|
@@ -746,7 +842,8 @@ const noAddRemote = {
|
|
|
746
842
|
check(instructions, file) {
|
|
747
843
|
const diagnostics = [];
|
|
748
844
|
for (const inst of instructions) if (inst.instruction === "ADD") {
|
|
749
|
-
const
|
|
845
|
+
const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
|
|
846
|
+
if (!src) continue;
|
|
750
847
|
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
848
|
}
|
|
752
849
|
return diagnostics;
|
|
@@ -805,23 +902,65 @@ const runComposeRules = (composeContent, file, rulesConfig) => {
|
|
|
805
902
|
|
|
806
903
|
//#endregion
|
|
807
904
|
//#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
|
-
});
|
|
905
|
+
const RULE_SEVERITIES = [
|
|
906
|
+
"error",
|
|
907
|
+
"warning",
|
|
908
|
+
"info",
|
|
909
|
+
"off"
|
|
910
|
+
];
|
|
911
|
+
const RULE_CATEGORIES = [
|
|
912
|
+
"Best Practices",
|
|
913
|
+
"Compose",
|
|
914
|
+
"Image Size",
|
|
915
|
+
"Performance",
|
|
916
|
+
"Security"
|
|
917
|
+
];
|
|
918
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
919
|
+
const isRuleSeverity = (value) => typeof value === "string" && RULE_SEVERITIES.includes(value);
|
|
920
|
+
const validateRules = (value) => {
|
|
921
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "rules" must be an object, got ${typeof value}`);
|
|
922
|
+
for (const [key, severity] of Object.entries(value)) if (!isRuleSeverity(severity)) throw new Error(`Invalid severity ${JSON.stringify(severity)} for rule "${key}"`);
|
|
923
|
+
return value;
|
|
924
|
+
};
|
|
925
|
+
const validateCategories = (value) => {
|
|
926
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "categories" must be an object, got ${typeof value}`);
|
|
927
|
+
const result = {};
|
|
928
|
+
for (const [key, severity] of Object.entries(value)) {
|
|
929
|
+
if (!RULE_CATEGORIES.includes(key)) continue;
|
|
930
|
+
if (!isRuleSeverity(severity)) throw new Error(`Invalid severity ${JSON.stringify(severity)} for category "${key}"`);
|
|
931
|
+
result[key] = severity;
|
|
932
|
+
}
|
|
933
|
+
return result;
|
|
934
|
+
};
|
|
935
|
+
const validateIgnore = (value) => {
|
|
936
|
+
if (!isPlainObject(value)) throw new Error(`Invalid config: "ignore" must be an object, got ${typeof value}`);
|
|
937
|
+
const result = {};
|
|
938
|
+
if ("files" in value && value.files !== void 0) {
|
|
939
|
+
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");
|
|
940
|
+
result.files = value.files;
|
|
941
|
+
}
|
|
942
|
+
return result;
|
|
943
|
+
};
|
|
944
|
+
const describeInvalidTopLevel = (input) => {
|
|
945
|
+
if (input === null) return "null";
|
|
946
|
+
if (Array.isArray(input)) return "array";
|
|
947
|
+
return typeof input;
|
|
948
|
+
};
|
|
949
|
+
/**
|
|
950
|
+
* Validates and normalizes a raw config object, throwing on invalid input.
|
|
951
|
+
*
|
|
952
|
+
* Mirrors the legacy schema's behavior exactly, including silently
|
|
953
|
+
* dropping unknown top-level (and nested) keys rather than throwing
|
|
954
|
+
* or preserving them.
|
|
955
|
+
*/
|
|
956
|
+
const validateConfig = (input) => {
|
|
957
|
+
if (!isPlainObject(input)) throw new Error(`Invalid config: expected an object, got ${describeInvalidTopLevel(input)}`);
|
|
958
|
+
const result = {};
|
|
959
|
+
if ("rules" in input && input.rules !== void 0) result.rules = validateRules(input.rules);
|
|
960
|
+
if ("categories" in input && input.categories !== void 0) result.categories = validateCategories(input.categories);
|
|
961
|
+
if ("ignore" in input && input.ignore !== void 0) result.ignore = validateIgnore(input.ignore);
|
|
962
|
+
return result;
|
|
963
|
+
};
|
|
825
964
|
|
|
826
965
|
//#endregion
|
|
827
966
|
//#region ../core/src/config/loader.ts
|
|
@@ -878,7 +1017,7 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
878
1017
|
}
|
|
879
1018
|
if (!configObject) return {};
|
|
880
1019
|
try {
|
|
881
|
-
return
|
|
1020
|
+
return validateConfig(configObject);
|
|
882
1021
|
} catch (error) {
|
|
883
1022
|
throw new ConfigError({ message: `Invalid configuration format: ${error instanceof Error ? error.message : String(error)}` });
|
|
884
1023
|
}
|
|
@@ -886,24 +1025,48 @@ const loadConfig = async (rootDir, customPath) => {
|
|
|
886
1025
|
|
|
887
1026
|
//#endregion
|
|
888
1027
|
//#region ../core/src/scoring.ts
|
|
1028
|
+
const SCORE_BUCKETS = [
|
|
1029
|
+
{
|
|
1030
|
+
emoji: "🏆",
|
|
1031
|
+
label: "Excellent",
|
|
1032
|
+
min: 90
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
emoji: "✅",
|
|
1036
|
+
label: "Good",
|
|
1037
|
+
min: 75
|
|
1038
|
+
},
|
|
1039
|
+
{
|
|
1040
|
+
emoji: "⚠️",
|
|
1041
|
+
label: "Needs Work",
|
|
1042
|
+
min: 50
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
emoji: "🚨",
|
|
1046
|
+
label: "Critical",
|
|
1047
|
+
min: 0
|
|
1048
|
+
}
|
|
1049
|
+
];
|
|
1050
|
+
const getScoreBucket = (score) => {
|
|
1051
|
+
for (const bucket of SCORE_BUCKETS) if (score >= bucket.min) return bucket;
|
|
1052
|
+
return SCORE_BUCKETS.at(-1);
|
|
1053
|
+
};
|
|
889
1054
|
const calculateScore = (diagnostics) => {
|
|
890
1055
|
let penalty = 0;
|
|
891
1056
|
for (const diag of diagnostics) if (diag.severity === "error") penalty += 10;
|
|
892
1057
|
else if (diag.severity === "warning") penalty += 4;
|
|
893
1058
|
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 ⚠️";
|
|
1059
|
+
const score = Math.round(100 * Math.exp(-penalty / 70));
|
|
1060
|
+
const bucket = getScoreBucket(score);
|
|
899
1061
|
return {
|
|
900
|
-
label
|
|
1062
|
+
label: `${bucket.label} ${bucket.emoji}`,
|
|
901
1063
|
score
|
|
902
1064
|
};
|
|
903
1065
|
};
|
|
904
1066
|
|
|
905
1067
|
//#endregion
|
|
906
1068
|
//#region ../core/src/report.ts
|
|
1069
|
+
const REPORT_SCHEMA_VERSION = 2;
|
|
907
1070
|
const toJsonReport = (diagnostics, score, label, project) => ({
|
|
908
1071
|
diagnostics: diagnostics.map((d) => ({
|
|
909
1072
|
column: d.column,
|
|
@@ -916,6 +1079,7 @@ const toJsonReport = (diagnostics, score, label, project) => ({
|
|
|
916
1079
|
})),
|
|
917
1080
|
label,
|
|
918
1081
|
project,
|
|
1082
|
+
schemaVersion: 2,
|
|
919
1083
|
score,
|
|
920
1084
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
921
1085
|
});
|
|
@@ -993,4 +1157,4 @@ Object.defineProperty(exports, 'toJsonReport', {
|
|
|
993
1157
|
return toJsonReport;
|
|
994
1158
|
}
|
|
995
1159
|
});
|
|
996
|
-
//# sourceMappingURL=src-
|
|
1160
|
+
//# sourceMappingURL=src-TEhFWhpk.cjs.map
|