@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.
@@ -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.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 instructions = [];
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
- 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
- }
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
- if (currentInstruction) instructions.push({
157
- args: currentArgs,
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 Data.TaggedError("ConfigError") {};
168
-
169
- //#endregion
170
- //#region ../core/src/errors/file-not-found-error.ts
171
- var FileNotFoundError = class extends Data.TaggedError("FileNotFoundError") {};
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 Data.TaggedError("ParseError") {};
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 [src] = inst.args.split(/\s+/u);
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 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
- }
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
- if ((src === "." || src === "./" || src === "*" || src.includes("src")) && copyAllLine === -1) copyAllLine = inst.line;
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
- const [src] = inst.args.split(/\s+/u);
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 === "USER") {
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 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
- }
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 [src] = inst.args.split(/\s+/u);
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 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
- });
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 Schema.decodeSync(DockerDoctorConfigSchema)(configObject);
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.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 ⚠️";
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-Bt3AB-Xl.mjs.map
1060
+ //# sourceMappingURL=src-EqtziccA.mjs.map