@docker-doctor/cli 0.5.3 → 0.6.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.
@@ -33,7 +33,7 @@ node_path = __toESM(node_path, 1);
33
33
  let yaml = require("yaml");
34
34
 
35
35
  //#region package.json
36
- var version = "0.5.3";
36
+ var version = "0.6.0";
37
37
 
38
38
  //#endregion
39
39
  //#region ../core/src/project-info/ignore.ts
@@ -89,20 +89,66 @@ const createIgnoreMatcher = (patterns) => {
89
89
 
90
90
  //#endregion
91
91
  //#region ../core/src/project-info/discover.ts
92
- const walk = async (dir, fileList = []) => {
93
- const files = await node_fs_promises.default.readdir(dir, { withFileTypes: true });
92
+ const PRUNED_DIRECTORIES = [
93
+ ".git",
94
+ ".hg",
95
+ ".svn",
96
+ "node_modules",
97
+ "bower_components",
98
+ ".pnpm-store",
99
+ ".yarn",
100
+ ".venv",
101
+ "venv",
102
+ "__pycache__",
103
+ ".tox",
104
+ ".mypy_cache",
105
+ ".pytest_cache",
106
+ ".ruff_cache",
107
+ ".gradle",
108
+ ".turbo",
109
+ ".next",
110
+ ".nuxt",
111
+ ".svelte-kit",
112
+ ".parcel-cache",
113
+ ".cache",
114
+ "coverage",
115
+ "dist"
116
+ ];
117
+ const PRUNED = new Set(PRUNED_DIRECTORIES);
118
+ const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
119
+ const SUBTREE_SUFFIX = "/**";
120
+ const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
121
+ const isPermissionError = (error) => error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
122
+ const walk = async (dir, options, fileList = []) => {
123
+ let files;
124
+ try {
125
+ files = await node_fs_promises.default.readdir(dir, { withFileTypes: true });
126
+ } catch (error) {
127
+ if (dir !== options.rootDir && isPermissionError(error)) {
128
+ options.onSkippedDirectory?.(toPosixRelative(options.rootDir, dir));
129
+ return fileList;
130
+ }
131
+ throw error;
132
+ }
94
133
  await Promise.all(files.map(async (file) => {
95
134
  const filePath = node_path.default.join(dir, file.name);
135
+ if (file.isSymbolicLink()) {
136
+ fileList.push(filePath);
137
+ return;
138
+ }
96
139
  if (file.isDirectory()) {
97
- if (file.name === "node_modules" || file.name === ".git" || file.name === ".next" || file.name === "dist" || file.name === ".turbo") return;
98
- await walk(filePath, fileList);
140
+ if (PRUNED.has(file.name) || options.isPrunedByConfig(toPosixRelative(options.rootDir, filePath))) return;
141
+ await walk(filePath, options, fileList);
99
142
  } else fileList.push(filePath);
100
143
  }));
101
144
  return fileList;
102
145
  };
103
- const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
104
146
  const discoverProject = async (rootDir, options) => {
105
- const allFiles = await walk(rootDir);
147
+ const allFiles = await walk(rootDir, {
148
+ isPrunedByConfig: createPrunedDirectoryMatcher(options?.ignoreFiles),
149
+ onSkippedDirectory: options?.onSkippedDirectory,
150
+ rootDir
151
+ });
106
152
  const dockerfiles = [];
107
153
  const composeFiles = [];
108
154
  const dockerignores = [];
@@ -287,7 +333,7 @@ const parseDockerfile = (content, file = "Dockerfile") => {
287
333
  const lineNum = i + 1;
288
334
  const insideHeredoc = state.heredocQueue.length > 0;
289
335
  if (!insideHeredoc && trimmed.startsWith("#")) continue;
290
- if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
336
+ if (!insideHeredoc && trimmed === "") continue;
291
337
  state.rawAccumulator.push(rawLine);
292
338
  if (insideHeredoc) processHeredocLine(state, trimmed);
293
339
  else processInstructionLine(state, trimmed, lineNum);
@@ -458,6 +504,13 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
458
504
  severity
459
505
  });
460
506
 
507
+ //#endregion
508
+ //#region ../core/src/rules/package-managers.ts
509
+ const APT_GET_INSTALL_RE = /\bapt-get(?:\s+-\S+)*\s+install\b/u;
510
+ const APT_GET_UPDATE_RE = /\bapt-get(?:\s+-\S+)*\s+update\b/u;
511
+ const hasAptGetInstall = (text) => APT_GET_INSTALL_RE.test(text);
512
+ const hasAptGetUpdate = (text) => APT_GET_UPDATE_RE.test(text);
513
+
461
514
  //#endregion
462
515
  //#region ../core/src/rules/best-practices.ts
463
516
  const requireHealthcheck = {
@@ -473,6 +526,16 @@ const requireHealthcheck = {
473
526
  key: "docker-doctor/require-healthcheck",
474
527
  message: "Add a HEALTHCHECK instruction"
475
528
  };
529
+ const AUTO_EXTRACT_SUFFIXES = [
530
+ ".tar",
531
+ ".tar.gz",
532
+ ".tgz",
533
+ ".tar.bz2",
534
+ ".tbz2",
535
+ ".tbz",
536
+ ".tar.xz",
537
+ ".txz"
538
+ ];
476
539
  const preferCopyOverAdd = {
477
540
  category: "Best Practices",
478
541
  check(instructions, file) {
@@ -481,7 +544,7 @@ const preferCopyOverAdd = {
481
544
  const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
482
545
  if (!src) continue;
483
546
  const isRemote = src.startsWith("http://") || src.startsWith("https://");
484
- const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
547
+ const isArchive = AUTO_EXTRACT_SUFFIXES.some((suffix) => src.toLowerCase().endsWith(suffix));
485
548
  if (!isRemote && !isArchive) diagnostics.push(createDiagnostic(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));
486
549
  }
487
550
  return diagnostics;
@@ -519,8 +582,8 @@ const combineAptUpdateInstall = {
519
582
  check(instructions, file) {
520
583
  const diagnostics = [];
521
584
  for (const inst of instructions) if (inst.instruction === "RUN") {
522
- const hasUpdate = inst.args.includes("apt-get update");
523
- const hasInstall = inst.args.includes("apt-get install");
585
+ const hasUpdate = hasAptGetUpdate(inst.args);
586
+ const hasInstall = hasAptGetInstall(inst.args);
524
587
  if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN apt-get update used without apt-get install in the same instruction. This can cause caching issues and build failures.", this.help, inst.line));
525
588
  else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN apt-get install used without apt-get update in the same instruction. Always combine them to ensure up-to-date package installation.", this.help, inst.line));
526
589
  }
@@ -600,18 +663,32 @@ const avoidRunCd = {
600
663
  key: "docker-doctor/avoid-run-cd",
601
664
  message: "Avoid changing directories with cd in RUN"
602
665
  };
666
+ const PACKAGE_LIST_START_RE = /\b(?:apt-get(?:\s+-\S+)*\s+install|apk\s+add|yum\s+install|dnf\s+install)\b/u;
667
+ const SHELL_SEPARATOR_RE$1 = /[;|]|&&/u;
668
+ const WHITESPACE_RE$1 = /\s+/u;
669
+ const collectPackageList = (raw) => {
670
+ const start = PACKAGE_LIST_START_RE.exec(raw);
671
+ if (!start) return [];
672
+ const packages = [];
673
+ const tokens = raw.slice(start.index + start[0].length).split(WHITESPACE_RE$1);
674
+ for (const token of tokens) {
675
+ if (SHELL_SEPARATOR_RE$1.test(token)) break;
676
+ if (token !== "" && token !== "\\" && !token.startsWith("-")) packages.push(token);
677
+ }
678
+ return packages;
679
+ };
603
680
  const sortMultilineArgs = {
604
681
  category: "Best Practices",
605
682
  check(instructions, file) {
606
683
  const diagnostics = [];
607
684
  for (const inst of instructions) if (inst.instruction === "RUN") {
608
685
  const { raw } = inst;
609
- const isPackageInstall = raw.includes("apt-get install") || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
686
+ const isPackageInstall = hasAptGetInstall(raw) || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
610
687
  const hasContinuation = raw.includes("\\\n") || raw.includes("\\\r\n");
611
688
  if (isPackageInstall && hasContinuation) {
612
- const packages = raw.split(/\r?\n/u).slice(1).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("&&") && !line.startsWith("-") && !line.includes("rm -rf")).map((line) => line.endsWith("\\") ? line.slice(0, -1).trim() : line).filter(Boolean);
689
+ const packages = collectPackageList(raw);
613
690
  if (packages.length > 1) {
614
- const sorted = packages.toSorted((a, b) => a.localeCompare(b));
691
+ const sorted = packages.toSorted();
615
692
  if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "Multi-line package arguments are not sorted alphanumerically. Keeping them sorted makes maintenance easier and prevents duplicates.", this.help, inst.line));
616
693
  }
617
694
  }
@@ -873,7 +950,10 @@ const composeModelRules = [undefinedModelReference, pinModelVersion];
873
950
  const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
874
951
  const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
875
952
  const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
876
- const resolveInterpolationDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
953
+ const PWD_INTERPOLATION = /\$\{PWD\}|\$PWD(?![A-Za-z0-9_])/gu;
954
+ const LEADING_INTERPOLATION_RE = /^\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)/u;
955
+ const applyFoldsAndDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(PWD_INTERPOLATION, ".").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback);
956
+ const resolveInterpolationDefaults = (value) => applyFoldsAndDefaults(value).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
877
957
  const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
878
958
  const splitShortSyntax = (spec) => {
879
959
  const parts = [];
@@ -924,12 +1004,16 @@ const volumeMount = (volume) => {
924
1004
  * The host path a bind mount reads from, with interpolation defaults
925
1005
  * applied and Windows separators folded to `/`. Undefined when the entry is
926
1006
  * not a bind mount (named volume, tmpfs, anonymous volume) or when the
927
- * source is an interpolation with no default, which names no host path.
1007
+ * source starts with an interpolation that has no default. A source such as
1008
+ * `${DATA_DIR}/pg` names no known host location, so no rule can say whether
1009
+ * it is inside the project.
928
1010
  */
929
1011
  const bindMountSource = (mount) => {
930
1012
  if (mount.type !== void 0 && mount.type !== "bind") return;
931
1013
  if (!mount.source) return;
932
- const resolved = resolveInterpolationDefaults(mount.source).replaceAll("\\", "/");
1014
+ const withDefaults = applyFoldsAndDefaults(mount.source);
1015
+ if (LEADING_INTERPOLATION_RE.test(withDefaults)) return;
1016
+ const resolved = withDefaults.replace(INTERPOLATION_WITHOUT_DEFAULT, "").replaceAll("\\", "/");
933
1017
  if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
934
1018
  return resolved;
935
1019
  };
@@ -937,15 +1021,16 @@ const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || ho
937
1021
  const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
938
1022
  /**
939
1023
  * Whether the entry bind-mounts the Docker socket, under any of its host
940
- * spellings. A bare `${VAR}` source names no host path, but a socket target
941
- * still tells the story.
1024
+ * spellings. A source that starts with `${VAR}` names no known host path,
1025
+ * but a `/docker.sock` suffix or a socket target still identifies it.
942
1026
  */
943
1027
  const mountsDockerSocket = (mount) => {
944
1028
  const source = bindMountSource(mount);
945
1029
  if (source !== void 0) return isDockerSocketPath(source);
946
1030
  if (mount.type !== void 0 && mount.type !== "bind") return false;
947
1031
  if (!mount.source) return false;
948
- return resolveInterpolationDefaults(mount.source) === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
1032
+ const remainder = resolveInterpolationDefaults(mount.source);
1033
+ return isDockerSocketPath(remainder) || remainder === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
949
1034
  };
950
1035
  const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
951
1036
  const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
@@ -995,7 +1080,11 @@ const SECRET_KEY_PATTERNS = [
995
1080
  ];
996
1081
  const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
997
1082
  const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
998
- const isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
1083
+ const SURROUNDING_QUOTES_RE = /^(?<quote>["'])(?<inner>.*)\k<quote>$/su;
1084
+ const isLiteralSecretValue = (value) => {
1085
+ const unquoted = value.match(SURROUNDING_QUOTES_RE)?.groups?.inner ?? value;
1086
+ return unquoted.length > 0 && !unquoted.startsWith("$") && !CREDENTIAL_FREE_URL.test(unquoted);
1087
+ };
999
1088
 
1000
1089
  //#endregion
1001
1090
  //#region ../core/src/rules/compose-security.ts
@@ -1102,6 +1191,10 @@ const preferReadOnlyBindMount = {
1102
1191
  key: "docker-doctor/prefer-read-only-bind-mount",
1103
1192
  message: "Bind mounts outside the project should be read-only"
1104
1193
  };
1194
+ const literalValue = (value) => {
1195
+ if (typeof value === "string") return value;
1196
+ if (typeof value === "number") return String(value);
1197
+ };
1105
1198
  const noPlaintextSecrets = {
1106
1199
  category: "Compose",
1107
1200
  check(composeContent, file, context) {
@@ -1117,15 +1210,16 @@ const noPlaintextSecrets = {
1117
1210
  if (eqIndex <= 0) continue;
1118
1211
  const key = entry.slice(0, eqIndex);
1119
1212
  const value = entry.slice(eqIndex + 1);
1120
- if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
1213
+ if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
1121
1214
  "services",
1122
1215
  name,
1123
1216
  "environment",
1124
1217
  index
1125
1218
  ]));
1126
1219
  }
1127
- else if (environment && typeof environment === "object") {
1128
- for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
1220
+ else if (environment && typeof environment === "object") for (const [key, value] of Object.entries(environment)) {
1221
+ const literal = literalValue(value);
1222
+ if (literal !== void 0 && isSecretKey(key) && isLiteralSecretValue(literal)) flag(name, key, context?.locate?.([
1129
1223
  "services",
1130
1224
  name,
1131
1225
  "environment",
@@ -1185,7 +1279,7 @@ const cleanPackageCache = {
1185
1279
  const diagnostics = [];
1186
1280
  for (const inst of instructions) if (inst.instruction === "RUN") {
1187
1281
  const { args } = inst;
1188
- if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists") && !hasCacheMountFor(args, APT_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apt-get install' without removing package lists afterwards. This keeps metadata caches inside the image layer.`, this.help, inst.line));
1282
+ if (hasAptGetInstall(args) && !args.includes("rm -rf /var/lib/apt/lists") && !hasCacheMountFor(args, APT_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apt-get install' without removing package lists afterwards. This keeps metadata caches inside the image layer.`, this.help, inst.line));
1189
1283
  if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk") && !hasCacheMountFor(args, APK_CACHE_DIRS)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running 'apk add' without '--no-cache' or cleaning the apk cache. This increases layer size.`, this.help, inst.line));
1190
1284
  }
1191
1285
  return diagnostics;
@@ -1195,7 +1289,40 @@ const cleanPackageCache = {
1195
1289
  key: "docker-doctor/clean-package-cache",
1196
1290
  message: "Clean up package manager cache in the same RUN layer"
1197
1291
  };
1198
- const installsDevDependencies = (args) => (args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install")) && !args.includes("--production") && !args.includes("--omit=dev") && !args.includes("prune");
1292
+ const SHELL_SEPARATOR_RE = /\s*(?:&&|\|\||;|\|)\s*/u;
1293
+ const WHITESPACE_RE = /\s+/u;
1294
+ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u;
1295
+ const NODE_PACKAGE_MANAGERS = /* @__PURE__ */ new Set([
1296
+ "npm",
1297
+ "yarn",
1298
+ "pnpm",
1299
+ "bun"
1300
+ ]);
1301
+ const INSTALL_VERBS = /* @__PURE__ */ new Set([
1302
+ "install",
1303
+ "ci",
1304
+ "i"
1305
+ ]);
1306
+ const NON_DEV_INSTALL_FLAGS = /* @__PURE__ */ new Set([
1307
+ "-g",
1308
+ "--global",
1309
+ "--production",
1310
+ "--omit=dev",
1311
+ "--only=prod",
1312
+ "--only=production",
1313
+ "--prod",
1314
+ "-P",
1315
+ "-p"
1316
+ ]);
1317
+ const segmentInstallsDevDependencies = (segment) => {
1318
+ const tokens = segment.split(WHITESPACE_RE).filter(Boolean);
1319
+ const commandStart = tokens.findIndex((token) => !ENV_ASSIGNMENT_RE.test(token));
1320
+ if (commandStart === -1) return false;
1321
+ const [manager, verb, ...options] = tokens.slice(commandStart);
1322
+ if (!(NODE_PACKAGE_MANAGERS.has(manager) && INSTALL_VERBS.has(verb))) return false;
1323
+ return !tokens.slice(0, commandStart).includes("NODE_ENV=production") && options.every((option) => option.startsWith("-") && !NON_DEV_INSTALL_FLAGS.has(option));
1324
+ };
1325
+ const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(args).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
1199
1326
  const avoidDevDependencies = {
1200
1327
  category: "Image Size",
1201
1328
  check(instructions, file) {
@@ -1241,11 +1368,12 @@ const imageSizeRules = [
1241
1368
 
1242
1369
  //#endregion
1243
1370
  //#region ../core/src/rules/performance.ts
1371
+ const BUILD_TOOL_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*(?:make|cmake)(?=\s|$)/u;
1244
1372
  const useMultiStage = {
1245
1373
  category: "Performance",
1246
1374
  check(instructions, file) {
1247
1375
  if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
1248
- if (instructions.some((inst) => inst.instruction === "RUN" && (inst.args.includes("npm run build") || inst.args.includes("yarn build") || inst.args.includes("bun run build") || inst.args.includes("cargo build") || inst.args.includes("make")))) return [createDiagnostic(file, this.key, this.defaultSeverity, "Only one build stage (FROM) was detected, but build instructions were found. Multi-stage builds can significantly reduce final image size.", this.help, instructions.find((inst) => inst.instruction === "FROM")?.line || 1)];
1376
+ if (instructions.some((inst) => inst.instruction === "RUN" && (inst.args.includes("npm run build") || inst.args.includes("yarn build") || inst.args.includes("bun run build") || inst.args.includes("cargo build") || BUILD_TOOL_COMMAND_RE.test(maskQuotedText(inst.args))))) return [createDiagnostic(file, this.key, this.defaultSeverity, "Only one build stage (FROM) was detected, but build instructions were found. Multi-stage builds can significantly reduce final image size.", this.help, instructions.find((inst) => inst.instruction === "FROM")?.line || 1)];
1249
1377
  }
1250
1378
  return [];
1251
1379
  },
@@ -1304,7 +1432,8 @@ const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
1304
1432
  const lastSlash = dockerfilePath.lastIndexOf("/");
1305
1433
  const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
1306
1434
  const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
1307
- return projectFiles.some((f) => f === adjacent || f === ".dockerignore");
1435
+ const perDockerfile = `${dockerfilePath}.dockerignore`;
1436
+ return projectFiles.some((f) => f === adjacent || f === perDockerfile || f === ".dockerignore");
1308
1437
  };
1309
1438
  const useDockerignore = {
1310
1439
  category: "Performance",
@@ -1716,13 +1845,13 @@ const calculateScore = (diagnostics) => {
1716
1845
 
1717
1846
  //#endregion
1718
1847
  //#region ../core/src/report.ts
1719
- const REPORT_SCHEMA_VERSION = 3;
1848
+ const REPORT_SCHEMA_VERSION = 4;
1720
1849
  const resolveCategory = (diagnostic) => {
1721
1850
  const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
1722
1851
  if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
1723
1852
  return category;
1724
1853
  };
1725
- const toJsonReport = (diagnostics, score, label, project) => ({
1854
+ const toJsonReport = (diagnostics, score, label, project, failures = []) => ({
1726
1855
  diagnostics: diagnostics.map((d) => ({
1727
1856
  category: resolveCategory(d),
1728
1857
  column: d.column,
@@ -1733,9 +1862,13 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1733
1862
  rule: d.rule,
1734
1863
  severity: d.severity
1735
1864
  })),
1865
+ failures: failures.map(({ file, message }) => ({
1866
+ file,
1867
+ message
1868
+ })),
1736
1869
  label,
1737
1870
  project,
1738
- schemaVersion: 3,
1871
+ schemaVersion: 4,
1739
1872
  score,
1740
1873
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1741
1874
  });
@@ -1819,4 +1952,4 @@ Object.defineProperty(exports, 'version', {
1819
1952
  return version;
1820
1953
  }
1821
1954
  });
1822
- //# sourceMappingURL=src-D4rVJg36.cjs.map
1955
+ //# sourceMappingURL=src-BpAv-H0Z.cjs.map