@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.
@@ -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.2.1",
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: ["dist", "LICENSE"],
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 instructions = [];
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
- if (!currentInstruction && (trimmed === "" || trimmed.startsWith("#"))) continue;
148
- rawAccumulator.push(rawLine);
149
- let lineContent = trimmed;
150
- if (lineContent.startsWith("#")) continue;
151
- const hasContinuation = lineContent.endsWith("\\");
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
- if (currentInstruction) instructions.push({
188
- args: currentArgs,
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 effect_Data.TaggedError("ConfigError") {};
199
-
200
- //#endregion
201
- //#region ../core/src/errors/file-not-found-error.ts
202
- var FileNotFoundError = class extends effect_Data.TaggedError("FileNotFoundError") {};
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 effect_Data.TaggedError("ParseError") {};
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 [src] = inst.args.split(/\s+/u);
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 colonIndex = imagePart.indexOf(":");
500
- if (colonIndex !== -1) {
501
- const tag = imagePart.slice(colonIndex + 1).toLowerCase();
502
- const isSlim = tag.includes("alpine") || tag.includes("slim") || tag.includes("distroless");
503
- const isSha = tag.startsWith("sha256:");
504
- const isStageReference = instructions.some((other) => other.line < inst.line && other.instruction === "FROM" && other.args.toLowerCase().includes(` as ${imagePart.toLowerCase()}`));
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
- if ((src === "." || src === "./" || src === "*" || src.includes("src")) && copyAllLine === -1) copyAllLine = inst.line;
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
- const [src] = inst.args.split(/\s+/u);
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 === "USER") {
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 colonIndex = imagePart.indexOf(":");
731
- const atIndex = imagePart.indexOf("@");
732
- if (colonIndex === -1 && atIndex === -1) 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));
733
- else if (colonIndex !== -1) {
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 [src] = inst.args.split(/\s+/u);
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 RuleSeveritySchema = effect_Schema.Union([
809
- effect_Schema.Literal("error"),
810
- effect_Schema.Literal("warning"),
811
- effect_Schema.Literal("info"),
812
- effect_Schema.Literal("off")
813
- ]);
814
- const DockerDoctorConfigSchema = effect_Schema.Struct({
815
- categories: effect_Schema.optional(effect_Schema.Struct({
816
- "Best Practices": effect_Schema.optional(RuleSeveritySchema),
817
- Compose: effect_Schema.optional(RuleSeveritySchema),
818
- "Image Size": effect_Schema.optional(RuleSeveritySchema),
819
- Performance: effect_Schema.optional(RuleSeveritySchema),
820
- Security: effect_Schema.optional(RuleSeveritySchema)
821
- })),
822
- ignore: effect_Schema.optional(effect_Schema.Struct({ files: effect_Schema.optional(effect_Schema.Array(effect_Schema.String)) })),
823
- rules: effect_Schema.optional(effect_Schema.Record(effect_Schema.String, RuleSeveritySchema))
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 importConfig = async (filePath) => {
837
- if (filePath.endsWith(".json")) try {
838
- const content = await node_fs_promises.default.readFile(filePath, "utf-8");
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 JSON: ${error instanceof Error ? error.message : String(error)}` });
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 effect_Schema.decodeSync(DockerDoctorConfigSchema)(configObject);
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.max(0, 100 - penalty);
895
- let label = "Critical 🚨";
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-O8FXMq8O.cjs.map
1170
+ //# sourceMappingURL=src-38C8Tk2b.cjs.map