@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.
@@ -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.2.1",
9
+ version: "0.3.1",
12
10
  description: "Static analysis for Dockerfile and Docker Compose files",
13
11
  keywords: [
14
12
  "best-practices",
@@ -33,7 +31,11 @@ var package_default = {
33
31
  "directory": "packages/docker-doctor"
34
32
  },
35
33
  bin: { "docker-doctor": "dist/cli.mjs" },
36
- files: ["dist", "LICENSE"],
34
+ files: [
35
+ "dist",
36
+ "skill",
37
+ "LICENSE"
38
+ ],
37
39
  type: "module",
38
40
  sideEffects: false,
39
41
  exports: { ".": {
@@ -53,13 +55,14 @@ var package_default = {
53
55
  scripts: {
54
56
  "build": "NODE_OPTIONS='--max-old-space-size=4096' tsdown",
55
57
  "dev": "NODE_OPTIONS='--max-old-space-size=4096' tsdown --watch",
58
+ "test": "bun test",
56
59
  "typecheck": "tsc --noEmit",
57
- "clean": "git clean -xdf .turbo node_modules dist"
60
+ "clean": "git clean -xdf .turbo node_modules dist skill"
58
61
  },
59
62
  dependencies: {
63
+ "agent-install": "0.0.8",
60
64
  "chalk": "^5.4.1",
61
65
  "commander": "^15.0.0",
62
- "effect": "4.0.0-beta.70",
63
66
  "yaml": "^2.7.0"
64
67
  },
65
68
  devDependencies: {
@@ -103,76 +106,122 @@ const discoverProject = async (rootDir) => {
103
106
 
104
107
  //#endregion
105
108
  //#region ../core/src/parsers/dockerfile-parser.ts
109
+ const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
110
+ "ADD",
111
+ "ARG",
112
+ "CMD",
113
+ "COPY",
114
+ "ENTRYPOINT",
115
+ "ENV",
116
+ "EXPOSE",
117
+ "FROM",
118
+ "HEALTHCHECK",
119
+ "LABEL",
120
+ "MAINTAINER",
121
+ "ONBUILD",
122
+ "RUN",
123
+ "SHELL",
124
+ "STOPSIGNAL",
125
+ "USER",
126
+ "VOLUME",
127
+ "WORKDIR"
128
+ ]);
129
+ const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
130
+ const HEREDOC_OPENER_RE = /<<-?\s*(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
131
+ const createParserState = () => ({
132
+ currentArgs: "",
133
+ currentInstruction: "",
134
+ heredocQueue: [],
135
+ instructions: [],
136
+ rawAccumulator: [],
137
+ startLine: 0
138
+ });
139
+ const closeInstruction = (state) => {
140
+ if (state.currentInstruction) state.instructions.push({
141
+ args: state.currentArgs,
142
+ instruction: state.currentInstruction,
143
+ line: state.startLine,
144
+ raw: state.rawAccumulator.join("\n")
145
+ });
146
+ state.currentInstruction = "";
147
+ state.currentArgs = "";
148
+ state.rawAccumulator = [];
149
+ };
150
+ const matchInstructionKeyword = (lineContent) => {
151
+ const match = lineContent.match(INSTRUCTION_LINE_RE);
152
+ const matchedWord = match?.groups?.inst.toUpperCase();
153
+ if (matchedWord && DOCKERFILE_KEYWORDS.has(matchedWord)) return {
154
+ args: match?.groups?.args ?? "",
155
+ instruction: matchedWord
156
+ };
157
+ const word = lineContent.trim().toUpperCase();
158
+ if (DOCKERFILE_KEYWORDS.has(word)) return {
159
+ args: "",
160
+ instruction: word
161
+ };
162
+ return null;
163
+ };
164
+ const findHeredocDelimiters = (lineContent) => [...lineContent.matchAll(HEREDOC_OPENER_RE)].map((m) => m.groups?.delim ?? "");
165
+ const processHeredocLine = (state, trimmed) => {
166
+ if (trimmed === state.heredocQueue[0]) state.heredocQueue.shift();
167
+ else state.currentArgs += (state.currentArgs ? " " : "") + trimmed;
168
+ if (state.heredocQueue.length === 0) closeInstruction(state);
169
+ };
170
+ const processInstructionLine = (state, trimmed, lineNum) => {
171
+ let lineContent = trimmed;
172
+ if (lineContent.startsWith("#")) return;
173
+ const hasContinuation = lineContent.endsWith("\\");
174
+ if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
175
+ if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
176
+ else {
177
+ state.startLine = lineNum;
178
+ const matched = matchInstructionKeyword(lineContent);
179
+ if (matched) {
180
+ state.currentInstruction = matched.instruction;
181
+ state.currentArgs = matched.args;
182
+ }
183
+ }
184
+ if (state.currentInstruction) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
185
+ if (state.heredocQueue.length > 0) return;
186
+ if (!hasContinuation) closeInstruction(state);
187
+ };
106
188
  const parseDockerfile = (content) => {
107
- const instructions = [];
189
+ const state = createParserState();
108
190
  const lines = content.split(/\r?\n/u);
109
- let currentInstruction = "";
110
- let currentArgs = "";
111
- let startLine = 0;
112
- let rawAccumulator = [];
113
191
  for (const [i, rawLine] of lines.entries()) {
114
192
  const trimmed = rawLine.trim();
115
193
  const lineNum = i + 1;
116
- if (!currentInstruction && (trimmed === "" || trimmed.startsWith("#"))) continue;
117
- rawAccumulator.push(rawLine);
118
- let lineContent = trimmed;
119
- if (lineContent.startsWith("#")) continue;
120
- const hasContinuation = lineContent.endsWith("\\");
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
- }
194
+ const insideHeredoc = state.heredocQueue.length > 0;
195
+ if (!state.currentInstruction && !insideHeredoc && (trimmed === "" || trimmed.startsWith("#"))) continue;
196
+ state.rawAccumulator.push(rawLine);
197
+ if (insideHeredoc) processHeredocLine(state, trimmed);
198
+ else processInstructionLine(state, trimmed, lineNum);
155
199
  }
156
- if (currentInstruction) instructions.push({
157
- args: currentArgs,
158
- instruction: currentInstruction,
159
- line: startLine,
160
- raw: rawAccumulator.join("\n")
161
- });
162
- return instructions;
200
+ closeInstruction(state);
201
+ return state.instructions;
163
202
  };
164
203
 
165
204
  //#endregion
166
205
  //#region ../core/src/errors/config-error.ts
167
- var ConfigError = class extends Data.TaggedError("ConfigError") {};
168
-
169
- //#endregion
170
- //#region ../core/src/errors/file-not-found-error.ts
171
- var FileNotFoundError = class extends Data.TaggedError("FileNotFoundError") {};
206
+ var ConfigError = class extends Error {
207
+ _tag = "ConfigError";
208
+ constructor(options) {
209
+ super(options.message);
210
+ this.name = "ConfigError";
211
+ }
212
+ };
172
213
 
173
214
  //#endregion
174
215
  //#region ../core/src/errors/parse-error.ts
175
- var ParseError = class extends Data.TaggedError("ParseError") {};
216
+ var ParseError = class extends Error {
217
+ _tag = "ParseError";
218
+ file;
219
+ constructor(options) {
220
+ super(options.message);
221
+ this.name = "ParseError";
222
+ this.file = options.file;
223
+ }
224
+ };
176
225
 
177
226
  //#endregion
178
227
  //#region ../core/src/parsers/compose-parser.ts
@@ -215,7 +264,8 @@ const preferCopyOverAdd = {
215
264
  check(instructions, file) {
216
265
  const diagnostics = [];
217
266
  for (const inst of instructions) if (inst.instruction === "ADD") {
218
- const [src] = inst.args.split(/\s+/u);
267
+ const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
268
+ if (!src) continue;
219
269
  const isRemote = src.startsWith("http://") || src.startsWith("https://");
220
270
  const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
221
271
  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 +498,54 @@ const composeRules = [
448
498
  useDependsOnCondition
449
499
  ];
450
500
 
501
+ //#endregion
502
+ //#region ../core/src/parsers/image-ref.ts
503
+ const parseImageRef = (ref) => {
504
+ if (ref.includes("${") || ref.startsWith("$")) return {
505
+ isVariable: true,
506
+ name: ref
507
+ };
508
+ let remainder = ref;
509
+ let digest;
510
+ const atIndex = remainder.indexOf("@");
511
+ if (atIndex !== -1) {
512
+ digest = remainder.slice(atIndex + 1);
513
+ remainder = remainder.slice(0, atIndex);
514
+ }
515
+ let tag;
516
+ const lastColonIndex = remainder.lastIndexOf(":");
517
+ const lastSlashIndex = remainder.lastIndexOf("/");
518
+ if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
519
+ tag = remainder.slice(lastColonIndex + 1);
520
+ remainder = remainder.slice(0, lastColonIndex);
521
+ }
522
+ let registry;
523
+ const firstSlashIndex = remainder.indexOf("/");
524
+ if (firstSlashIndex !== -1) {
525
+ const firstSegment = remainder.slice(0, firstSlashIndex);
526
+ if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
527
+ registry = firstSegment;
528
+ remainder = remainder.slice(firstSlashIndex + 1);
529
+ }
530
+ }
531
+ return {
532
+ digest,
533
+ isVariable: false,
534
+ name: remainder,
535
+ registry,
536
+ tag
537
+ };
538
+ };
539
+ const collectStageAliases = (instructions) => {
540
+ const aliases = /* @__PURE__ */ new Set();
541
+ for (const inst of instructions) {
542
+ if (inst.instruction !== "FROM") continue;
543
+ const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
544
+ if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
545
+ }
546
+ return aliases;
547
+ };
548
+
451
549
  //#endregion
452
550
  //#region ../core/src/rules/image-size.ts
453
551
  const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
@@ -462,17 +560,16 @@ const preferSlimBase = {
462
560
  category: "Image Size",
463
561
  check(instructions, file) {
464
562
  const diagnostics = [];
563
+ const stageAliases = collectStageAliases(instructions);
465
564
  for (const inst of instructions) if (inst.instruction === "FROM") {
466
565
  const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
467
566
  if (!imagePart || imagePart === "scratch") continue;
468
- const colonIndex = imagePart.indexOf(":");
469
- if (colonIndex !== -1) {
470
- const tag = imagePart.slice(colonIndex + 1).toLowerCase();
471
- const isSlim = tag.includes("alpine") || tag.includes("slim") || tag.includes("distroless");
472
- const isSha = tag.startsWith("sha256:");
473
- const isStageReference = instructions.some((other) => other.line < inst.line && other.instruction === "FROM" && other.args.toLowerCase().includes(` as ${imagePart.toLowerCase()}`));
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
- }
567
+ const ref = parseImageRef(imagePart);
568
+ if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
569
+ if (ref.digest) continue;
570
+ if (!ref.tag) continue;
571
+ const tag = ref.tag.toLowerCase();
572
+ 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
573
  }
477
574
  return diagnostics;
478
575
  },
@@ -557,10 +654,12 @@ const orderLayers = {
557
654
  const diagnostics = [];
558
655
  let copyAllLine = -1;
559
656
  for (const inst of instructions) {
657
+ if (inst.instruction === "FROM") copyAllLine = -1;
560
658
  if (inst.instruction === "COPY" || inst.instruction === "ADD") {
561
659
  const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
562
660
  if (!src) continue;
563
- if ((src === "." || src === "./" || src === "*" || src.includes("src")) && copyAllLine === -1) copyAllLine = inst.line;
661
+ const normalized = src.replace(/^\.\//u, "");
662
+ if ((normalized === "." || normalized === "" || normalized === "*" || normalized === "src" || normalized.startsWith("src/")) && copyAllLine === -1) copyAllLine = inst.line;
564
663
  }
565
664
  if (inst.instruction === "RUN" && copyAllLine !== -1) {
566
665
  const args = inst.args.toLowerCase();
@@ -600,7 +699,9 @@ const useDockerignore = {
600
699
  check(instructions, file, context) {
601
700
  if (instructions.some((inst) => {
602
701
  if (inst.instruction === "COPY" || inst.instruction === "ADD") {
603
- const [src] = inst.args.split(/\s+/u);
702
+ if (/(?:^|\s)--from=/u.test(inst.args)) return false;
703
+ const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
704
+ if (!src) return false;
604
705
  return src === "." || src === "./" || src === "*";
605
706
  }
606
707
  return false;
@@ -636,7 +737,10 @@ const noRootUser = {
636
737
  check(instructions, file) {
637
738
  let lastUser = "root";
638
739
  let lastUserLine = 1;
639
- for (const inst of instructions) if (inst.instruction === "USER") {
740
+ for (const inst of instructions) if (inst.instruction === "FROM") {
741
+ lastUser = "root";
742
+ lastUserLine = inst.line;
743
+ } else if (inst.instruction === "USER") {
640
744
  lastUser = inst.args.trim().toLowerCase();
641
745
  lastUserLine = inst.line;
642
746
  }
@@ -653,12 +757,12 @@ const noSecretsInEnv = {
653
757
  check(instructions, file) {
654
758
  const diagnostics = [];
655
759
  const secretKeywords = [
656
- /password/iu,
657
- /secret/iu,
658
- /token/iu,
659
- /api_key/iu,
660
- /private_key/iu,
661
- /auth/iu
760
+ /(?:^|[_-])password(?:[_-]|$)/iu,
761
+ /(?:^|[_-])secret(?:[_-]|$)/iu,
762
+ /(?:^|[_-])token(?:[_-]|$)/iu,
763
+ /(?:^|[_-])api_key(?:[_-]|$)/iu,
764
+ /(?:^|[_-])private_key(?:[_-]|$)/iu,
765
+ /(?:^|[_-])auth(?:[_-]|$)/iu
662
766
  ];
663
767
  for (const inst of instructions) if (inst.instruction === "ENV" || inst.instruction === "ARG") {
664
768
  const args = inst.args.trim();
@@ -693,15 +797,14 @@ const pinImageVersion = {
693
797
  category: "Security",
694
798
  check(instructions, file) {
695
799
  const diagnostics = [];
800
+ const stageAliases = collectStageAliases(instructions);
696
801
  for (const inst of instructions) if (inst.instruction === "FROM") {
697
802
  const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
698
803
  if (!imagePart || imagePart === "scratch") continue;
699
- const colonIndex = imagePart.indexOf(":");
700
- const atIndex = imagePart.indexOf("@");
701
- 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));
702
- else if (colonIndex !== -1) {
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
- }
804
+ const ref = parseImageRef(imagePart);
805
+ if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
806
+ 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));
807
+ 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
808
  }
706
809
  return diagnostics;
707
810
  },
@@ -715,7 +818,8 @@ const noAddRemote = {
715
818
  check(instructions, file) {
716
819
  const diagnostics = [];
717
820
  for (const inst of instructions) if (inst.instruction === "ADD") {
718
- const [src] = inst.args.split(/\s+/u);
821
+ const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
822
+ if (!src) continue;
719
823
  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
824
  }
721
825
  return diagnostics;
@@ -774,23 +878,65 @@ const runComposeRules = (composeContent, file, rulesConfig) => {
774
878
 
775
879
  //#endregion
776
880
  //#region ../core/src/schemas/config.ts
777
- const RuleSeveritySchema = Schema.Union([
778
- Schema.Literal("error"),
779
- Schema.Literal("warning"),
780
- Schema.Literal("info"),
781
- Schema.Literal("off")
782
- ]);
783
- const DockerDoctorConfigSchema = Schema.Struct({
784
- categories: Schema.optional(Schema.Struct({
785
- "Best Practices": Schema.optional(RuleSeveritySchema),
786
- Compose: Schema.optional(RuleSeveritySchema),
787
- "Image Size": Schema.optional(RuleSeveritySchema),
788
- Performance: Schema.optional(RuleSeveritySchema),
789
- Security: Schema.optional(RuleSeveritySchema)
790
- })),
791
- ignore: Schema.optional(Schema.Struct({ files: Schema.optional(Schema.Array(Schema.String)) })),
792
- rules: Schema.optional(Schema.Record(Schema.String, RuleSeveritySchema))
793
- });
881
+ const RULE_SEVERITIES = [
882
+ "error",
883
+ "warning",
884
+ "info",
885
+ "off"
886
+ ];
887
+ const RULE_CATEGORIES = [
888
+ "Best Practices",
889
+ "Compose",
890
+ "Image Size",
891
+ "Performance",
892
+ "Security"
893
+ ];
894
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
895
+ const isRuleSeverity = (value) => typeof value === "string" && RULE_SEVERITIES.includes(value);
896
+ const validateRules = (value) => {
897
+ if (!isPlainObject(value)) throw new Error(`Invalid config: "rules" must be an object, got ${typeof value}`);
898
+ for (const [key, severity] of Object.entries(value)) if (!isRuleSeverity(severity)) throw new Error(`Invalid severity ${JSON.stringify(severity)} for rule "${key}"`);
899
+ return value;
900
+ };
901
+ const validateCategories = (value) => {
902
+ if (!isPlainObject(value)) throw new Error(`Invalid config: "categories" must be an object, got ${typeof value}`);
903
+ const result = {};
904
+ for (const [key, severity] of Object.entries(value)) {
905
+ if (!RULE_CATEGORIES.includes(key)) continue;
906
+ if (!isRuleSeverity(severity)) throw new Error(`Invalid severity ${JSON.stringify(severity)} for category "${key}"`);
907
+ result[key] = severity;
908
+ }
909
+ return result;
910
+ };
911
+ const validateIgnore = (value) => {
912
+ if (!isPlainObject(value)) throw new Error(`Invalid config: "ignore" must be an object, got ${typeof value}`);
913
+ const result = {};
914
+ if ("files" in value && value.files !== void 0) {
915
+ 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");
916
+ result.files = value.files;
917
+ }
918
+ return result;
919
+ };
920
+ const describeInvalidTopLevel = (input) => {
921
+ if (input === null) return "null";
922
+ if (Array.isArray(input)) return "array";
923
+ return typeof input;
924
+ };
925
+ /**
926
+ * Validates and normalizes a raw config object, throwing on invalid input.
927
+ *
928
+ * Mirrors the legacy schema's behavior exactly, including silently
929
+ * dropping unknown top-level (and nested) keys rather than throwing
930
+ * or preserving them.
931
+ */
932
+ const validateConfig = (input) => {
933
+ if (!isPlainObject(input)) throw new Error(`Invalid config: expected an object, got ${describeInvalidTopLevel(input)}`);
934
+ const result = {};
935
+ if ("rules" in input && input.rules !== void 0) result.rules = validateRules(input.rules);
936
+ if ("categories" in input && input.categories !== void 0) result.categories = validateCategories(input.categories);
937
+ if ("ignore" in input && input.ignore !== void 0) result.ignore = validateIgnore(input.ignore);
938
+ return result;
939
+ };
794
940
 
795
941
  //#endregion
796
942
  //#region ../core/src/config/loader.ts
@@ -802,13 +948,16 @@ const fileExists = async (filePath) => {
802
948
  return false;
803
949
  }
804
950
  };
805
- const importConfig = async (filePath) => {
806
- if (filePath.endsWith(".json")) try {
807
- const content = await fs.readFile(filePath, "utf-8");
808
- return JSON.parse(content);
951
+ const parseConfigFile = async (filePath, format, parse) => {
952
+ try {
953
+ return parse(await fs.readFile(filePath, "utf-8"));
809
954
  } catch (error) {
810
- throw new ConfigError({ message: `Failed to parse config JSON: ${error instanceof Error ? error.message : String(error)}` });
955
+ throw new ConfigError({ message: `Failed to parse config ${format}: ${error instanceof Error ? error.message : String(error)}` });
811
956
  }
957
+ };
958
+ const importConfig = async (filePath) => {
959
+ if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
960
+ if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", parse);
812
961
  try {
813
962
  const configModule = await import(filePath);
814
963
  return configModule.default || configModule;
@@ -828,7 +977,9 @@ const loadConfig = async (rootDir, customPath) => {
828
977
  "docker-doctor.config.js",
829
978
  "docker-doctor.config.mjs",
830
979
  "docker-doctor.config.cjs",
831
- "docker-doctor.config.json"
980
+ "docker-doctor.config.json",
981
+ "docker-doctor.config.yaml",
982
+ "docker-doctor.config.yml"
832
983
  ]) {
833
984
  const fullPath = path.join(rootDir, cand);
834
985
  if (await fileExists(fullPath)) {
@@ -847,7 +998,7 @@ const loadConfig = async (rootDir, customPath) => {
847
998
  }
848
999
  if (!configObject) return {};
849
1000
  try {
850
- return Schema.decodeSync(DockerDoctorConfigSchema)(configObject);
1001
+ return validateConfig(configObject);
851
1002
  } catch (error) {
852
1003
  throw new ConfigError({ message: `Invalid configuration format: ${error instanceof Error ? error.message : String(error)}` });
853
1004
  }
@@ -855,24 +1006,48 @@ const loadConfig = async (rootDir, customPath) => {
855
1006
 
856
1007
  //#endregion
857
1008
  //#region ../core/src/scoring.ts
1009
+ const SCORE_BUCKETS = [
1010
+ {
1011
+ emoji: "🏆",
1012
+ label: "Excellent",
1013
+ min: 90
1014
+ },
1015
+ {
1016
+ emoji: "✅",
1017
+ label: "Good",
1018
+ min: 75
1019
+ },
1020
+ {
1021
+ emoji: "⚠️",
1022
+ label: "Needs Work",
1023
+ min: 50
1024
+ },
1025
+ {
1026
+ emoji: "🚨",
1027
+ label: "Critical",
1028
+ min: 0
1029
+ }
1030
+ ];
1031
+ const getScoreBucket = (score) => {
1032
+ for (const bucket of SCORE_BUCKETS) if (score >= bucket.min) return bucket;
1033
+ return SCORE_BUCKETS.at(-1);
1034
+ };
858
1035
  const calculateScore = (diagnostics) => {
859
1036
  let penalty = 0;
860
1037
  for (const diag of diagnostics) if (diag.severity === "error") penalty += 10;
861
1038
  else if (diag.severity === "warning") penalty += 4;
862
1039
  else if (diag.severity === "info") penalty += 1;
863
- const score = Math.max(0, 100 - penalty);
864
- let label = "Critical 🚨";
865
- if (score >= 90) label = "Excellent 🏆";
866
- else if (score >= 75) label = "Good ✅";
867
- else if (score >= 50) label = "Needs Work ⚠️";
1040
+ const score = Math.round(100 * Math.exp(-penalty / 70));
1041
+ const bucket = getScoreBucket(score);
868
1042
  return {
869
- label,
1043
+ label: `${bucket.label} ${bucket.emoji}`,
870
1044
  score
871
1045
  };
872
1046
  };
873
1047
 
874
1048
  //#endregion
875
1049
  //#region ../core/src/report.ts
1050
+ const REPORT_SCHEMA_VERSION = 2;
876
1051
  const toJsonReport = (diagnostics, score, label, project) => ({
877
1052
  diagnostics: diagnostics.map((d) => ({
878
1053
  column: d.column,
@@ -885,10 +1060,11 @@ const toJsonReport = (diagnostics, score, label, project) => ({
885
1060
  })),
886
1061
  label,
887
1062
  project,
1063
+ schemaVersion: 2,
888
1064
  score,
889
1065
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
890
1066
  });
891
1067
 
892
1068
  //#endregion
893
1069
  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-DvNFg6Nz.mjs.map
1070
+ //# sourceMappingURL=src-BOzcDwm-.mjs.map