@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.
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
3
- const require_src = require('./src-D4rVJg36.cjs');
3
+ const require_src = require('./src-BpAv-H0Z.cjs');
4
4
 
5
5
  //#region ../core/src/config/define-config.ts
6
6
  const defineConfig = (config) => config;
package/dist/index.d.cts CHANGED
@@ -48,6 +48,7 @@ interface DockerDoctorConfig {
48
48
  //#region ../core/src/project-info/discover.d.ts
49
49
  export declare const discoverProject: (rootDir: string, options?: {
50
50
  ignoreFiles?: readonly string[];
51
+ onSkippedDirectory?: (relativeDir: string) => void;
51
52
  }) => Promise<ProjectInfo>;
52
53
  //#endregion
53
54
  //#region ../core/src/parsers/dockerfile-parser.d.ts
@@ -79,6 +80,10 @@ export declare const calculateScore: (diagnostics: Diagnostic[]) => {
79
80
  };
80
81
  //#endregion
81
82
  //#region ../core/src/report.d.ts
83
+ interface ReportFailure {
84
+ file: string;
85
+ message: string;
86
+ }
82
87
  interface JsonReport {
83
88
  diagnostics: {
84
89
  category: RuleCategory;
@@ -90,13 +95,14 @@ interface JsonReport {
90
95
  rule: string;
91
96
  severity: "error" | "warning" | "info";
92
97
  }[];
98
+ failures: ReportFailure[];
93
99
  label: string;
94
100
  project: ProjectInfo;
95
101
  schemaVersion: number;
96
102
  score: number;
97
103
  timestamp: string;
98
104
  }
99
- export declare const toJsonReport: (diagnostics: Diagnostic[], score: number, label: string, project: ProjectInfo) => JsonReport;
105
+ export declare const toJsonReport: (diagnostics: Diagnostic[], score: number, label: string, project: ProjectInfo, failures?: ReportFailure[]) => JsonReport;
100
106
  //#endregion
101
107
  //#region src/index.d.ts
102
108
  export declare const version: string;
package/dist/index.d.mts CHANGED
@@ -48,6 +48,7 @@ interface DockerDoctorConfig {
48
48
  //#region ../core/src/project-info/discover.d.ts
49
49
  export declare const discoverProject: (rootDir: string, options?: {
50
50
  ignoreFiles?: readonly string[];
51
+ onSkippedDirectory?: (relativeDir: string) => void;
51
52
  }) => Promise<ProjectInfo>;
52
53
  //#endregion
53
54
  //#region ../core/src/parsers/dockerfile-parser.d.ts
@@ -79,6 +80,10 @@ export declare const calculateScore: (diagnostics: Diagnostic[]) => {
79
80
  };
80
81
  //#endregion
81
82
  //#region ../core/src/report.d.ts
83
+ interface ReportFailure {
84
+ file: string;
85
+ message: string;
86
+ }
82
87
  interface JsonReport {
83
88
  diagnostics: {
84
89
  category: RuleCategory;
@@ -90,13 +95,14 @@ interface JsonReport {
90
95
  rule: string;
91
96
  severity: "error" | "warning" | "info";
92
97
  }[];
98
+ failures: ReportFailure[];
93
99
  label: string;
94
100
  project: ProjectInfo;
95
101
  schemaVersion: number;
96
102
  score: number;
97
103
  timestamp: string;
98
104
  }
99
- export declare const toJsonReport: (diagnostics: Diagnostic[], score: number, label: string, project: ProjectInfo) => JsonReport;
105
+ export declare const toJsonReport: (diagnostics: Diagnostic[], score: number, label: string, project: ProjectInfo, failures?: ReportFailure[]) => JsonReport;
100
106
  //#endregion
101
107
  //#region src/index.d.ts
102
108
  export declare const version: string;
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as runDockerfileRules, d as discoverProject, f as version$1, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-CklEIXTX.mjs";
2
+ import { a as runDockerfileRules, d as discoverProject, f as version$1, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-BLeXr_hR.mjs";
3
3
 
4
4
  //#region ../core/src/config/define-config.ts
5
5
  const defineConfig = (config) => config;
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { LineCounter, isAlias, isMap, isScalar, isSeq, parse, parseDocument } from "yaml";
5
5
 
6
6
  //#region package.json
7
- var version = "0.5.3";
7
+ var version = "0.6.0";
8
8
 
9
9
  //#endregion
10
10
  //#region ../core/src/project-info/ignore.ts
@@ -60,20 +60,66 @@ const createIgnoreMatcher = (patterns) => {
60
60
 
61
61
  //#endregion
62
62
  //#region ../core/src/project-info/discover.ts
63
- const walk = async (dir, fileList = []) => {
64
- const files = await fs.readdir(dir, { withFileTypes: true });
63
+ const PRUNED_DIRECTORIES = [
64
+ ".git",
65
+ ".hg",
66
+ ".svn",
67
+ "node_modules",
68
+ "bower_components",
69
+ ".pnpm-store",
70
+ ".yarn",
71
+ ".venv",
72
+ "venv",
73
+ "__pycache__",
74
+ ".tox",
75
+ ".mypy_cache",
76
+ ".pytest_cache",
77
+ ".ruff_cache",
78
+ ".gradle",
79
+ ".turbo",
80
+ ".next",
81
+ ".nuxt",
82
+ ".svelte-kit",
83
+ ".parcel-cache",
84
+ ".cache",
85
+ "coverage",
86
+ "dist"
87
+ ];
88
+ const PRUNED = new Set(PRUNED_DIRECTORIES);
89
+ const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
90
+ const SUBTREE_SUFFIX = "/**";
91
+ const createPrunedDirectoryMatcher = (patterns = []) => createIgnoreMatcher(patterns.filter((pattern) => pattern.endsWith(SUBTREE_SUFFIX)).map((pattern) => pattern.slice(0, -3)));
92
+ const isPermissionError = (error) => error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
93
+ const walk = async (dir, options, fileList = []) => {
94
+ let files;
95
+ try {
96
+ files = await fs.readdir(dir, { withFileTypes: true });
97
+ } catch (error) {
98
+ if (dir !== options.rootDir && isPermissionError(error)) {
99
+ options.onSkippedDirectory?.(toPosixRelative(options.rootDir, dir));
100
+ return fileList;
101
+ }
102
+ throw error;
103
+ }
65
104
  await Promise.all(files.map(async (file) => {
66
105
  const filePath = path.join(dir, file.name);
106
+ if (file.isSymbolicLink()) {
107
+ fileList.push(filePath);
108
+ return;
109
+ }
67
110
  if (file.isDirectory()) {
68
- if (file.name === "node_modules" || file.name === ".git" || file.name === ".next" || file.name === "dist" || file.name === ".turbo") return;
69
- await walk(filePath, fileList);
111
+ if (PRUNED.has(file.name) || options.isPrunedByConfig(toPosixRelative(options.rootDir, filePath))) return;
112
+ await walk(filePath, options, fileList);
70
113
  } else fileList.push(filePath);
71
114
  }));
72
115
  return fileList;
73
116
  };
74
- const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
75
117
  const discoverProject = async (rootDir, options) => {
76
- const allFiles = await walk(rootDir);
118
+ const allFiles = await walk(rootDir, {
119
+ isPrunedByConfig: createPrunedDirectoryMatcher(options?.ignoreFiles),
120
+ onSkippedDirectory: options?.onSkippedDirectory,
121
+ rootDir
122
+ });
77
123
  const dockerfiles = [];
78
124
  const composeFiles = [];
79
125
  const dockerignores = [];
@@ -258,7 +304,7 @@ const parseDockerfile = (content, file = "Dockerfile") => {
258
304
  const lineNum = i + 1;
259
305
  const insideHeredoc = state.heredocQueue.length > 0;
260
306
  if (!insideHeredoc && trimmed.startsWith("#")) continue;
261
- if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
307
+ if (!insideHeredoc && trimmed === "") continue;
262
308
  state.rawAccumulator.push(rawLine);
263
309
  if (insideHeredoc) processHeredocLine(state, trimmed);
264
310
  else processInstructionLine(state, trimmed, lineNum);
@@ -429,6 +475,13 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
429
475
  severity
430
476
  });
431
477
 
478
+ //#endregion
479
+ //#region ../core/src/rules/package-managers.ts
480
+ const APT_GET_INSTALL_RE = /\bapt-get(?:\s+-\S+)*\s+install\b/u;
481
+ const APT_GET_UPDATE_RE = /\bapt-get(?:\s+-\S+)*\s+update\b/u;
482
+ const hasAptGetInstall = (text) => APT_GET_INSTALL_RE.test(text);
483
+ const hasAptGetUpdate = (text) => APT_GET_UPDATE_RE.test(text);
484
+
432
485
  //#endregion
433
486
  //#region ../core/src/rules/best-practices.ts
434
487
  const requireHealthcheck = {
@@ -444,6 +497,16 @@ const requireHealthcheck = {
444
497
  key: "docker-doctor/require-healthcheck",
445
498
  message: "Add a HEALTHCHECK instruction"
446
499
  };
500
+ const AUTO_EXTRACT_SUFFIXES = [
501
+ ".tar",
502
+ ".tar.gz",
503
+ ".tgz",
504
+ ".tar.bz2",
505
+ ".tbz2",
506
+ ".tbz",
507
+ ".tar.xz",
508
+ ".txz"
509
+ ];
447
510
  const preferCopyOverAdd = {
448
511
  category: "Best Practices",
449
512
  check(instructions, file) {
@@ -452,7 +515,7 @@ const preferCopyOverAdd = {
452
515
  const src = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
453
516
  if (!src) continue;
454
517
  const isRemote = src.startsWith("http://") || src.startsWith("https://");
455
- const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
518
+ const isArchive = AUTO_EXTRACT_SUFFIXES.some((suffix) => src.toLowerCase().endsWith(suffix));
456
519
  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));
457
520
  }
458
521
  return diagnostics;
@@ -490,8 +553,8 @@ const combineAptUpdateInstall = {
490
553
  check(instructions, file) {
491
554
  const diagnostics = [];
492
555
  for (const inst of instructions) if (inst.instruction === "RUN") {
493
- const hasUpdate = inst.args.includes("apt-get update");
494
- const hasInstall = inst.args.includes("apt-get install");
556
+ const hasUpdate = hasAptGetUpdate(inst.args);
557
+ const hasInstall = hasAptGetInstall(inst.args);
495
558
  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));
496
559
  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));
497
560
  }
@@ -571,18 +634,32 @@ const avoidRunCd = {
571
634
  key: "docker-doctor/avoid-run-cd",
572
635
  message: "Avoid changing directories with cd in RUN"
573
636
  };
637
+ const PACKAGE_LIST_START_RE = /\b(?:apt-get(?:\s+-\S+)*\s+install|apk\s+add|yum\s+install|dnf\s+install)\b/u;
638
+ const SHELL_SEPARATOR_RE$1 = /[;|]|&&/u;
639
+ const WHITESPACE_RE$1 = /\s+/u;
640
+ const collectPackageList = (raw) => {
641
+ const start = PACKAGE_LIST_START_RE.exec(raw);
642
+ if (!start) return [];
643
+ const packages = [];
644
+ const tokens = raw.slice(start.index + start[0].length).split(WHITESPACE_RE$1);
645
+ for (const token of tokens) {
646
+ if (SHELL_SEPARATOR_RE$1.test(token)) break;
647
+ if (token !== "" && token !== "\\" && !token.startsWith("-")) packages.push(token);
648
+ }
649
+ return packages;
650
+ };
574
651
  const sortMultilineArgs = {
575
652
  category: "Best Practices",
576
653
  check(instructions, file) {
577
654
  const diagnostics = [];
578
655
  for (const inst of instructions) if (inst.instruction === "RUN") {
579
656
  const { raw } = inst;
580
- const isPackageInstall = raw.includes("apt-get install") || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
657
+ const isPackageInstall = hasAptGetInstall(raw) || raw.includes("apk add") || raw.includes("yum install") || raw.includes("dnf install");
581
658
  const hasContinuation = raw.includes("\\\n") || raw.includes("\\\r\n");
582
659
  if (isPackageInstall && hasContinuation) {
583
- 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);
660
+ const packages = collectPackageList(raw);
584
661
  if (packages.length > 1) {
585
- const sorted = packages.toSorted((a, b) => a.localeCompare(b));
662
+ const sorted = packages.toSorted();
586
663
  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));
587
664
  }
588
665
  }
@@ -844,7 +921,10 @@ const composeModelRules = [undefinedModelReference, pinModelVersion];
844
921
  const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
845
922
  const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
846
923
  const HOME_INTERPOLATION = /\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/gu;
847
- const resolveInterpolationDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
924
+ const PWD_INTERPOLATION = /\$\{PWD\}|\$PWD(?![A-Za-z0-9_])/gu;
925
+ const LEADING_INTERPOLATION_RE = /^\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)/u;
926
+ const applyFoldsAndDefaults = (value) => value.replace(HOME_INTERPOLATION, "~").replace(PWD_INTERPOLATION, ".").replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback);
927
+ const resolveInterpolationDefaults = (value) => applyFoldsAndDefaults(value).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
848
928
  const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
849
929
  const splitShortSyntax = (spec) => {
850
930
  const parts = [];
@@ -895,12 +975,16 @@ const volumeMount = (volume) => {
895
975
  * The host path a bind mount reads from, with interpolation defaults
896
976
  * applied and Windows separators folded to `/`. Undefined when the entry is
897
977
  * not a bind mount (named volume, tmpfs, anonymous volume) or when the
898
- * source is an interpolation with no default, which names no host path.
978
+ * source starts with an interpolation that has no default. A source such as
979
+ * `${DATA_DIR}/pg` names no known host location, so no rule can say whether
980
+ * it is inside the project.
899
981
  */
900
982
  const bindMountSource = (mount) => {
901
983
  if (mount.type !== void 0 && mount.type !== "bind") return;
902
984
  if (!mount.source) return;
903
- const resolved = resolveInterpolationDefaults(mount.source).replaceAll("\\", "/");
985
+ const withDefaults = applyFoldsAndDefaults(mount.source);
986
+ if (LEADING_INTERPOLATION_RE.test(withDefaults)) return;
987
+ const resolved = withDefaults.replace(INTERPOLATION_WITHOUT_DEFAULT, "").replaceAll("\\", "/");
904
988
  if (resolved === "" || !PATH_SHAPED.test(resolved)) return;
905
989
  return resolved;
906
990
  };
@@ -908,15 +992,16 @@ const isDockerSocketPath = (hostPath) => hostPath.endsWith("/docker.sock") || ho
908
992
  const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
909
993
  /**
910
994
  * Whether the entry bind-mounts the Docker socket, under any of its host
911
- * spellings. A bare `${VAR}` source names no host path, but a socket target
912
- * still tells the story.
995
+ * spellings. A source that starts with `${VAR}` names no known host path,
996
+ * but a `/docker.sock` suffix or a socket target still identifies it.
913
997
  */
914
998
  const mountsDockerSocket = (mount) => {
915
999
  const source = bindMountSource(mount);
916
1000
  if (source !== void 0) return isDockerSocketPath(source);
917
1001
  if (mount.type !== void 0 && mount.type !== "bind") return false;
918
1002
  if (!mount.source) return false;
919
- return resolveInterpolationDefaults(mount.source) === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
1003
+ const remainder = resolveInterpolationDefaults(mount.source);
1004
+ return isDockerSocketPath(remainder) || remainder === "" && resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
920
1005
  };
921
1006
  const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/?$/u;
922
1007
  const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
@@ -966,7 +1051,11 @@ const SECRET_KEY_PATTERNS = [
966
1051
  ];
967
1052
  const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
968
1053
  const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
969
- const isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
1054
+ const SURROUNDING_QUOTES_RE = /^(?<quote>["'])(?<inner>.*)\k<quote>$/su;
1055
+ const isLiteralSecretValue = (value) => {
1056
+ const unquoted = value.match(SURROUNDING_QUOTES_RE)?.groups?.inner ?? value;
1057
+ return unquoted.length > 0 && !unquoted.startsWith("$") && !CREDENTIAL_FREE_URL.test(unquoted);
1058
+ };
970
1059
 
971
1060
  //#endregion
972
1061
  //#region ../core/src/rules/compose-security.ts
@@ -1073,6 +1162,10 @@ const preferReadOnlyBindMount = {
1073
1162
  key: "docker-doctor/prefer-read-only-bind-mount",
1074
1163
  message: "Bind mounts outside the project should be read-only"
1075
1164
  };
1165
+ const literalValue = (value) => {
1166
+ if (typeof value === "string") return value;
1167
+ if (typeof value === "number") return String(value);
1168
+ };
1076
1169
  const noPlaintextSecrets = {
1077
1170
  category: "Compose",
1078
1171
  check(composeContent, file, context) {
@@ -1088,15 +1181,16 @@ const noPlaintextSecrets = {
1088
1181
  if (eqIndex <= 0) continue;
1089
1182
  const key = entry.slice(0, eqIndex);
1090
1183
  const value = entry.slice(eqIndex + 1);
1091
- if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
1184
+ if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
1092
1185
  "services",
1093
1186
  name,
1094
1187
  "environment",
1095
1188
  index
1096
1189
  ]));
1097
1190
  }
1098
- else if (environment && typeof environment === "object") {
1099
- for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
1191
+ else if (environment && typeof environment === "object") for (const [key, value] of Object.entries(environment)) {
1192
+ const literal = literalValue(value);
1193
+ if (literal !== void 0 && isSecretKey(key) && isLiteralSecretValue(literal)) flag(name, key, context?.locate?.([
1100
1194
  "services",
1101
1195
  name,
1102
1196
  "environment",
@@ -1156,7 +1250,7 @@ const cleanPackageCache = {
1156
1250
  const diagnostics = [];
1157
1251
  for (const inst of instructions) if (inst.instruction === "RUN") {
1158
1252
  const { args } = inst;
1159
- 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));
1253
+ 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));
1160
1254
  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));
1161
1255
  }
1162
1256
  return diagnostics;
@@ -1166,7 +1260,40 @@ const cleanPackageCache = {
1166
1260
  key: "docker-doctor/clean-package-cache",
1167
1261
  message: "Clean up package manager cache in the same RUN layer"
1168
1262
  };
1169
- 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");
1263
+ const SHELL_SEPARATOR_RE = /\s*(?:&&|\|\||;|\|)\s*/u;
1264
+ const WHITESPACE_RE = /\s+/u;
1265
+ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u;
1266
+ const NODE_PACKAGE_MANAGERS = /* @__PURE__ */ new Set([
1267
+ "npm",
1268
+ "yarn",
1269
+ "pnpm",
1270
+ "bun"
1271
+ ]);
1272
+ const INSTALL_VERBS = /* @__PURE__ */ new Set([
1273
+ "install",
1274
+ "ci",
1275
+ "i"
1276
+ ]);
1277
+ const NON_DEV_INSTALL_FLAGS = /* @__PURE__ */ new Set([
1278
+ "-g",
1279
+ "--global",
1280
+ "--production",
1281
+ "--omit=dev",
1282
+ "--only=prod",
1283
+ "--only=production",
1284
+ "--prod",
1285
+ "-P",
1286
+ "-p"
1287
+ ]);
1288
+ const segmentInstallsDevDependencies = (segment) => {
1289
+ const tokens = segment.split(WHITESPACE_RE).filter(Boolean);
1290
+ const commandStart = tokens.findIndex((token) => !ENV_ASSIGNMENT_RE.test(token));
1291
+ if (commandStart === -1) return false;
1292
+ const [manager, verb, ...options] = tokens.slice(commandStart);
1293
+ if (!(NODE_PACKAGE_MANAGERS.has(manager) && INSTALL_VERBS.has(verb))) return false;
1294
+ return !tokens.slice(0, commandStart).includes("NODE_ENV=production") && options.every((option) => option.startsWith("-") && !NON_DEV_INSTALL_FLAGS.has(option));
1295
+ };
1296
+ const installsDevDependencies = (args) => !args.includes("prune") && maskQuotedText(args).split(SHELL_SEPARATOR_RE).some(segmentInstallsDevDependencies);
1170
1297
  const avoidDevDependencies = {
1171
1298
  category: "Image Size",
1172
1299
  check(instructions, file) {
@@ -1212,11 +1339,12 @@ const imageSizeRules = [
1212
1339
 
1213
1340
  //#endregion
1214
1341
  //#region ../core/src/rules/performance.ts
1342
+ const BUILD_TOOL_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*(?:make|cmake)(?=\s|$)/u;
1215
1343
  const useMultiStage = {
1216
1344
  category: "Performance",
1217
1345
  check(instructions, file) {
1218
1346
  if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
1219
- 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)];
1347
+ 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)];
1220
1348
  }
1221
1349
  return [];
1222
1350
  },
@@ -1275,7 +1403,8 @@ const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
1275
1403
  const lastSlash = dockerfilePath.lastIndexOf("/");
1276
1404
  const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
1277
1405
  const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
1278
- return projectFiles.some((f) => f === adjacent || f === ".dockerignore");
1406
+ const perDockerfile = `${dockerfilePath}.dockerignore`;
1407
+ return projectFiles.some((f) => f === adjacent || f === perDockerfile || f === ".dockerignore");
1279
1408
  };
1280
1409
  const useDockerignore = {
1281
1410
  category: "Performance",
@@ -1687,13 +1816,13 @@ const calculateScore = (diagnostics) => {
1687
1816
 
1688
1817
  //#endregion
1689
1818
  //#region ../core/src/report.ts
1690
- const REPORT_SCHEMA_VERSION = 3;
1819
+ const REPORT_SCHEMA_VERSION = 4;
1691
1820
  const resolveCategory = (diagnostic) => {
1692
1821
  const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
1693
1822
  if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
1694
1823
  return category;
1695
1824
  };
1696
- const toJsonReport = (diagnostics, score, label, project) => ({
1825
+ const toJsonReport = (diagnostics, score, label, project, failures = []) => ({
1697
1826
  diagnostics: diagnostics.map((d) => ({
1698
1827
  category: resolveCategory(d),
1699
1828
  column: d.column,
@@ -1704,13 +1833,17 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1704
1833
  rule: d.rule,
1705
1834
  severity: d.severity
1706
1835
  })),
1836
+ failures: failures.map(({ file, message }) => ({
1837
+ file,
1838
+ message
1839
+ })),
1707
1840
  label,
1708
1841
  project,
1709
- schemaVersion: 3,
1842
+ schemaVersion: 4,
1710
1843
  score,
1711
1844
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1712
1845
  });
1713
1846
 
1714
1847
  //#endregion
1715
1848
  export { runDockerfileRules as a, createComposeLocator as c, discoverProject as d, version as f, runComposeRules as i, parseCompose as l, calculateScore as n, allRules as o, loadConfig as r, findRule as s, toJsonReport as t, parseDockerfile as u };
1716
- //# sourceMappingURL=src-CklEIXTX.mjs.map
1849
+ //# sourceMappingURL=src-BLeXr_hR.mjs.map