@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.
@@ -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.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 instructions = [];
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
- 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
- }
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
- if (currentInstruction) instructions.push({
188
- args: currentArgs,
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 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") {};
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 effect_Data.TaggedError("ParseError") {};
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 [src] = inst.args.split(/\s+/u);
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 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
- }
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
- if ((src === "." || src === "./" || src === "*" || src.includes("src")) && copyAllLine === -1) copyAllLine = inst.line;
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
- const [src] = inst.args.split(/\s+/u);
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 === "USER") {
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 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
- }
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 [src] = inst.args.split(/\s+/u);
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 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
- });
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 effect_Schema.decodeSync(DockerDoctorConfigSchema)(configObject);
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.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 ⚠️";
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-BUYNqQd8.cjs.map
1160
+ //# sourceMappingURL=src-TEhFWhpk.cjs.map