@docker-doctor/cli 0.4.1 → 0.4.3

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.4.1";
36
+ var version = "0.4.3";
37
37
 
38
38
  //#endregion
39
39
  //#region ../core/src/project-info/discover.ts
@@ -60,9 +60,9 @@ const discoverProject = async (rootDir) => {
60
60
  if (base === "docker-compose.yml" || base === "docker-compose.yaml" || base === "compose.yml" || base === "compose.yaml" || (base.startsWith("docker-compose.") || base.startsWith("compose.")) && (base.endsWith(".yml") || base.endsWith(".yaml"))) composeFiles.push(node_path.default.relative(rootDir, file));
61
61
  }
62
62
  return {
63
- composeFiles,
64
- dockerfiles,
65
- dockerignores
63
+ composeFiles: composeFiles.toSorted(),
64
+ dockerfiles: dockerfiles.toSorted(),
65
+ dockerignores: dockerignores.toSorted()
66
66
  };
67
67
  };
68
68
 
@@ -88,8 +88,13 @@ const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
88
88
  "VOLUME",
89
89
  "WORKDIR"
90
90
  ]);
91
+ const HEREDOC_INSTRUCTIONS = /* @__PURE__ */ new Set([
92
+ "ADD",
93
+ "COPY",
94
+ "RUN"
95
+ ]);
91
96
  const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;
92
- const HEREDOC_OPENER_RE = /<<-?\s*(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
97
+ const HEREDOC_OPENER_RE = /(?<=^|\s)<<-?(?<quote>['"]?)(?<delim>\w+)\k<quote>/gu;
93
98
  const createParserState = () => ({
94
99
  currentArgs: "",
95
100
  currentInstruction: "",
@@ -143,7 +148,7 @@ const processInstructionLine = (state, trimmed, lineNum) => {
143
148
  state.currentArgs = matched.args;
144
149
  }
145
150
  }
146
- if (state.currentInstruction) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
151
+ if (HEREDOC_INSTRUCTIONS.has(state.currentInstruction)) state.heredocQueue.push(...findHeredocDelimiters(lineContent));
147
152
  if (state.heredocQueue.length > 0) return;
148
153
  if (!hasContinuation) closeInstruction(state);
149
154
  };
@@ -189,7 +194,7 @@ var ParseError = class extends Error {
189
194
  //#region ../core/src/parsers/compose-parser.ts
190
195
  const parseCompose = (content, filepath) => {
191
196
  try {
192
- return (0, yaml.parse)(content);
197
+ return (0, yaml.parse)(content, { merge: true });
193
198
  } catch (error) {
194
199
  throw new ParseError({
195
200
  file: filepath,
@@ -198,6 +203,79 @@ const parseCompose = (content, filepath) => {
198
203
  }
199
204
  };
200
205
 
206
+ //#endregion
207
+ //#region ../core/src/parsers/exec-form.ts
208
+ const parseExecForm = (args) => {
209
+ const trimmed = args.trim();
210
+ if (!trimmed.startsWith("[")) return null;
211
+ let parsed;
212
+ try {
213
+ parsed = JSON.parse(trimmed);
214
+ } catch {
215
+ return null;
216
+ }
217
+ if (!Array.isArray(parsed)) return null;
218
+ if (!parsed.every((el) => typeof el === "string")) return null;
219
+ return parsed;
220
+ };
221
+
222
+ //#endregion
223
+ //#region ../core/src/parsers/image-ref.ts
224
+ const parseImageRef = (ref) => {
225
+ if (ref.includes("${") || ref.startsWith("$")) return {
226
+ isVariable: true,
227
+ name: ref
228
+ };
229
+ let remainder = ref;
230
+ let digest;
231
+ const atIndex = remainder.indexOf("@");
232
+ if (atIndex !== -1) {
233
+ digest = remainder.slice(atIndex + 1);
234
+ remainder = remainder.slice(0, atIndex);
235
+ }
236
+ let tag;
237
+ const lastColonIndex = remainder.lastIndexOf(":");
238
+ const lastSlashIndex = remainder.lastIndexOf("/");
239
+ if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
240
+ tag = remainder.slice(lastColonIndex + 1);
241
+ remainder = remainder.slice(0, lastColonIndex);
242
+ }
243
+ let registry;
244
+ const firstSlashIndex = remainder.indexOf("/");
245
+ if (firstSlashIndex !== -1) {
246
+ const firstSegment = remainder.slice(0, firstSlashIndex);
247
+ if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
248
+ registry = firstSegment;
249
+ remainder = remainder.slice(firstSlashIndex + 1);
250
+ }
251
+ }
252
+ return {
253
+ digest,
254
+ isVariable: false,
255
+ name: remainder,
256
+ registry,
257
+ tag
258
+ };
259
+ };
260
+ const parseFromArgs = (args) => {
261
+ const parts = args.split(/\s+/u).filter(Boolean);
262
+ const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
263
+ return {
264
+ base: (asIndex === -1 ? parts : parts.slice(0, asIndex)).find((p) => !p.startsWith("--")) ?? null,
265
+ stage: asIndex === -1 ? null : parts[asIndex + 1] ?? null
266
+ };
267
+ };
268
+ const isScratch = (base) => base?.toLowerCase() === "scratch";
269
+ const collectStageAliases = (instructions) => {
270
+ const aliases = /* @__PURE__ */ new Set();
271
+ for (const inst of instructions) {
272
+ if (inst.instruction !== "FROM") continue;
273
+ const { stage } = parseFromArgs(inst.args);
274
+ if (stage) aliases.add(stage.toLowerCase());
275
+ }
276
+ return aliases;
277
+ };
278
+
201
279
  //#endregion
202
280
  //#region ../core/src/rules/best-practices.ts
203
281
  const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
@@ -217,7 +295,7 @@ const requireHealthcheck = {
217
295
  return [];
218
296
  },
219
297
  defaultSeverity: "info",
220
- help: "Use HEALTHCHECK (e.g., 'HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost/ || exit 1') so Docker can monitor the container's live status.",
298
+ help: "Use HEALTHCHECK (e.g., `HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost/ || exit 1`) so Docker can monitor the container's live status.",
221
299
  key: "docker-doctor/require-healthcheck",
222
300
  message: "Add a HEALTHCHECK instruction"
223
301
  };
@@ -243,14 +321,11 @@ const useExecForm = {
243
321
  category: "Best Practices",
244
322
  check(instructions, file) {
245
323
  const diagnostics = [];
246
- for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
247
- const args = inst.args.trim();
248
- if (!args.startsWith("[") || !args.endsWith("]")) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `${inst.instruction} instruction uses shell form instead of exec form. In shell form, the command runs under '/bin/sh -c', which does not pass signals to child processes.`, this.help, inst.line));
249
- }
324
+ for (const inst of instructions) if ((inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") && parseExecForm(inst.args) === null) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `${inst.instruction} instruction uses shell form instead of exec form. In shell form, the command runs under '/bin/sh -c', which does not pass signals to child processes.`, this.help, inst.line));
250
325
  return diagnostics;
251
326
  },
252
327
  defaultSeverity: "warning",
253
- help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. ENTRYPOINT [\"node\", \"index.js\"]) so OS signals (like SIGTERM) are forwarded correctly.",
328
+ help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. `ENTRYPOINT [\"node\", \"index.js\"]`) so OS signals (like SIGTERM) are forwarded correctly.",
254
329
  key: "docker-doctor/use-exec-form",
255
330
  message: "Use exec form for CMD and ENTRYPOINT"
256
331
  };
@@ -261,7 +336,7 @@ const requireLabels = {
261
336
  return [];
262
337
  },
263
338
  defaultSeverity: "info",
264
- help: "Use LABEL instructions (e.g. LABEL org.opencontainers.image.authors=\"...\") to document ownership, license, version, and build info.",
339
+ help: "Use LABEL instructions (e.g. `LABEL org.opencontainers.image.authors=\"...\"`) to document ownership, license, version, and build info.",
265
340
  key: "docker-doctor/require-labels",
266
341
  message: "Add LABEL metadata to images"
267
342
  };
@@ -278,22 +353,47 @@ const combineAptUpdateInstall = {
278
353
  return diagnostics;
279
354
  },
280
355
  defaultSeverity: "warning",
281
- help: "Combine 'apt-get update' and 'apt-get install' in the same RUN instruction (e.g. 'RUN apt-get update && apt-get install -y --no-install-recommends <package> && rm -rf /var/lib/apt/lists/*').",
356
+ help: "Combine `apt-get update` and `apt-get install` in the same RUN instruction (e.g. `RUN apt-get update && apt-get install -y --no-install-recommends <package> && rm -rf /var/lib/apt/lists/*`).",
282
357
  key: "docker-doctor/combine-apt-update-install",
283
358
  message: "Combine apt-get update and apt-get install"
284
359
  };
360
+ const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
361
+ const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
362
+ const shellDirectiveEnablesPipefail = (args) => {
363
+ const argv = parseExecForm(args);
364
+ return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
365
+ };
285
366
  const usePipefail = {
286
367
  category: "Best Practices",
287
368
  check(instructions, file) {
288
369
  const diagnostics = [];
289
- for (const inst of instructions) if (inst.instruction === "RUN") {
290
- const { raw } = inst;
291
- if (/(?<!\|)\|(?!\|)/u.test(raw) && !raw.includes("pipefail")) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN instruction uses a pipe (|) but does not configure 'pipefail'. If a command in the pipe fails, the step may still succeed silently.", this.help, inst.line));
370
+ const stagePipefail = /* @__PURE__ */ new Map();
371
+ let shellHasPipefail = false;
372
+ let currentStage = null;
373
+ for (const inst of instructions) {
374
+ if (inst.instruction === "FROM") {
375
+ const { base, stage } = parseFromArgs(inst.args);
376
+ const parentStage = isScratch(base) ? null : base?.toLowerCase() ?? null;
377
+ shellHasPipefail = parentStage !== null && stagePipefail.get(parentStage) === true;
378
+ currentStage = stage?.toLowerCase() ?? null;
379
+ if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
380
+ continue;
381
+ }
382
+ if (inst.instruction === "SHELL") {
383
+ shellHasPipefail = shellDirectiveEnablesPipefail(inst.args);
384
+ if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
385
+ continue;
386
+ }
387
+ if (inst.instruction !== "RUN") continue;
388
+ const { args } = inst;
389
+ if (!HAS_PIPE_RE.test(args)) continue;
390
+ const execArgv = parseExecForm(args);
391
+ if (!(execArgv === null ? shellHasPipefail || PIPEFAIL_SETTING_RE.test(args) : PIPEFAIL_SETTING_RE.test(execArgv.join(" ")))) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN instruction uses a pipe (|) but does not configure 'pipefail'. If a command in the pipe fails, the step may still succeed silently.", this.help, inst.line));
292
392
  }
293
393
  return diagnostics;
294
394
  },
295
395
  defaultSeverity: "warning",
296
- help: "Prepend 'set -o pipefail &&' to pipe commands, or use exec form with a shell that supports it (e.g., RUN ['/bin/bash', '-c', 'set -o pipefail && ...']).",
396
+ help: "Prepend `set -o pipefail &&` to pipe commands, use exec form with a shell that supports it (e.g., `RUN [\"/bin/bash\", \"-c\", \"set -o pipefail && ...\"]`), or set `SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]` at the top of the stage.",
297
397
  key: "docker-doctor/use-pipefail",
298
398
  message: "Use pipefail to catch pipeline command failures"
299
399
  };
@@ -308,7 +408,7 @@ const absoluteWorkdir = {
308
408
  return diagnostics;
309
409
  },
310
410
  defaultSeverity: "warning",
311
- help: "Always specify absolute paths for WORKDIR instructions (e.g. WORKDIR /app).",
411
+ help: "Always specify absolute paths for WORKDIR instructions (e.g. `WORKDIR /app`).",
312
412
  key: "docker-doctor/absolute-workdir",
313
413
  message: "Use absolute paths for WORKDIR"
314
414
  };
@@ -320,7 +420,7 @@ const avoidRunCd = {
320
420
  return diagnostics;
321
421
  },
322
422
  defaultSeverity: "info",
323
- help: "Use the WORKDIR instruction instead of 'cd' inside RUN to establish directory context.",
423
+ help: "Use the WORKDIR instruction instead of `cd` inside RUN to establish directory context.",
324
424
  key: "docker-doctor/avoid-run-cd",
325
425
  message: "Avoid changing directories with cd in RUN"
326
426
  };
@@ -355,7 +455,7 @@ const useraddNoLogInit = {
355
455
  return diagnostics;
356
456
  },
357
457
  defaultSeverity: "warning",
358
- help: "Pass '--no-log-init' flag to useradd (e.g., 'RUN useradd --no-log-init -r -g mygroup myuser').",
458
+ help: "Pass `--no-log-init` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
359
459
  key: "docker-doctor/useradd-no-log-init",
360
460
  message: "Use --no-log-init with useradd"
361
461
  };
@@ -388,9 +488,9 @@ const noVersionKey = {
388
488
  return [];
389
489
  },
390
490
  defaultSeverity: "warning",
391
- help: "The 'version' key is deprecated by the Compose specification. Omitting it defaults to the latest specification.",
491
+ help: "The `version` key is obsolete in the Compose specification. Omitting it defaults to the latest specification.",
392
492
  key: "docker-doctor/no-version-key",
393
- message: "Remove the 'version' key from Compose file"
493
+ message: "Remove the `version` key from the Compose file"
394
494
  };
395
495
  const requireResourceLimits = {
396
496
  category: "Compose",
@@ -408,7 +508,7 @@ const requireResourceLimits = {
408
508
  return diagnostics;
409
509
  },
410
510
  defaultSeverity: "warning",
411
- help: "Add resource limits (e.g. deploy.resources.limits) to prevent a single service from starving host resources in production.",
511
+ help: "Add resource limits (e.g. `deploy.resources.limits`) to prevent a single service from starving host resources in production.",
412
512
  key: "docker-doctor/require-resource-limits",
413
513
  message: "Define resource limits for services"
414
514
  };
@@ -429,7 +529,7 @@ const requireRestartPolicy = {
429
529
  return diagnostics;
430
530
  },
431
531
  defaultSeverity: "warning",
432
- help: "Define 'restart: always' or 'restart: unless-stopped' (or deploy.restart_policy) so services restart on crashes or host reboot.",
532
+ help: "Define `restart: always` or `restart: unless-stopped` (or `deploy.restart_policy`) so services restart on crashes or host reboot.",
433
533
  key: "docker-doctor/require-restart-policy",
434
534
  message: "Set restart policy for services"
435
535
  };
@@ -449,7 +549,7 @@ const useDependsOnCondition = {
449
549
  return diagnostics;
450
550
  },
451
551
  defaultSeverity: "info",
452
- help: "Instead of a simple service list, use 'depends_on: { dependency: { condition: service_healthy } }' to ensure dependencies are fully ready before starting.",
552
+ help: "Instead of a simple service list, use `depends_on: { dependency: { condition: service_healthy } }` to ensure dependencies are fully ready before starting.",
453
553
  key: "docker-doctor/use-depends-on-condition",
454
554
  message: "Use long-form depends_on with healthcheck conditions"
455
555
  };
@@ -460,54 +560,6 @@ const composeRules = [
460
560
  useDependsOnCondition
461
561
  ];
462
562
 
463
- //#endregion
464
- //#region ../core/src/parsers/image-ref.ts
465
- const parseImageRef = (ref) => {
466
- if (ref.includes("${") || ref.startsWith("$")) return {
467
- isVariable: true,
468
- name: ref
469
- };
470
- let remainder = ref;
471
- let digest;
472
- const atIndex = remainder.indexOf("@");
473
- if (atIndex !== -1) {
474
- digest = remainder.slice(atIndex + 1);
475
- remainder = remainder.slice(0, atIndex);
476
- }
477
- let tag;
478
- const lastColonIndex = remainder.lastIndexOf(":");
479
- const lastSlashIndex = remainder.lastIndexOf("/");
480
- if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
481
- tag = remainder.slice(lastColonIndex + 1);
482
- remainder = remainder.slice(0, lastColonIndex);
483
- }
484
- let registry;
485
- const firstSlashIndex = remainder.indexOf("/");
486
- if (firstSlashIndex !== -1) {
487
- const firstSegment = remainder.slice(0, firstSlashIndex);
488
- if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
489
- registry = firstSegment;
490
- remainder = remainder.slice(firstSlashIndex + 1);
491
- }
492
- }
493
- return {
494
- digest,
495
- isVariable: false,
496
- name: remainder,
497
- registry,
498
- tag
499
- };
500
- };
501
- const collectStageAliases = (instructions) => {
502
- const aliases = /* @__PURE__ */ new Set();
503
- for (const inst of instructions) {
504
- if (inst.instruction !== "FROM") continue;
505
- const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
506
- if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
507
- }
508
- return aliases;
509
- };
510
-
511
563
  //#endregion
512
564
  //#region ../core/src/rules/image-size.ts
513
565
  const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
@@ -524,21 +576,21 @@ const preferSlimBase = {
524
576
  const diagnostics = [];
525
577
  const stageAliases = collectStageAliases(instructions);
526
578
  for (const inst of instructions) if (inst.instruction === "FROM") {
527
- const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
528
- if (!imagePart || imagePart === "scratch") continue;
579
+ const imagePart = parseFromArgs(inst.args).base;
580
+ if (!imagePart || isScratch(imagePart)) continue;
529
581
  const ref = parseImageRef(imagePart);
530
582
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
531
583
  if (ref.digest) continue;
532
584
  if (!ref.tag) continue;
533
- const tag = ref.tag.toLowerCase();
534
- 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));
585
+ const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
586
+ if (!(haystack.includes("alpine") || haystack.includes("slim") || haystack.includes("distroless") || haystack.includes("busybox"))) 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));
535
587
  }
536
588
  return diagnostics;
537
589
  },
538
590
  defaultSeverity: "info",
539
- help: "Prefer tags with '-slim', '-alpine', or use distroless base images to minimize the default operating system footprint.",
591
+ help: "Prefer tags with `-slim`, `-alpine`, or use distroless base images to minimize the default operating system footprint.",
540
592
  key: "docker-doctor/prefer-slim-base",
541
- message: "Use slim, alpine, or distroless base images"
593
+ message: "Prefer slim, alpine, or distroless base images"
542
594
  };
543
595
  const cleanPackageCache = {
544
596
  category: "Image Size",
@@ -552,34 +604,47 @@ const cleanPackageCache = {
552
604
  return diagnostics;
553
605
  },
554
606
  defaultSeverity: "warning",
555
- help: "For apt-get, append '&& rm -rf /var/lib/apt/lists/*'. For apk, use 'apk add --no-cache'. For dnf/yum, run 'yum clean all'.",
607
+ help: "For apt-get, append `&& rm -rf /var/lib/apt/lists/*`. For apk, use `apk add --no-cache`. For dnf/yum, run `yum clean all`.",
556
608
  key: "docker-doctor/clean-package-cache",
557
609
  message: "Clean up package manager cache in the same RUN layer"
558
610
  };
611
+ 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");
559
612
  const avoidDevDependencies = {
560
613
  category: "Image Size",
561
614
  check(instructions, file) {
562
615
  const diagnostics = [];
563
- let isLastStage = false;
564
- let fromCount = 0;
565
- for (const inst of instructions) if (inst.instruction === "FROM") fromCount += 1;
566
- let currentStage = 0;
567
- for (const inst of instructions) {
568
- if (inst.instruction === "FROM") {
569
- currentStage += 1;
570
- isLastStage = currentStage === fromCount;
571
- }
572
- if (isLastStage && inst.instruction === "RUN") {
573
- const { args } = inst;
574
- if ((args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install")) && !args.includes("--production") && !args.includes("--omit=dev") && !args.includes("prune")) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' in the final stage without omitting devDependencies.`, this.help, inst.line));
616
+ const stages = [];
617
+ for (const inst of instructions) if (inst.instruction === "FROM") {
618
+ const { base, stage } = parseFromArgs(inst.args);
619
+ stages.push({
620
+ base: base?.toLowerCase() ?? "",
621
+ name: stage?.toLowerCase() ?? null,
622
+ runs: []
623
+ });
624
+ } else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
625
+ if (stages.length === 0) return diagnostics;
626
+ const auditedIndices = [];
627
+ let index = stages.length - 1;
628
+ while (index >= 0) {
629
+ auditedIndices.push(index);
630
+ const { base } = stages[index];
631
+ index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
632
+ }
633
+ const finalIndex = stages.length - 1;
634
+ for (const stageIndex of auditedIndices.toReversed()) {
635
+ const stage = stages[stageIndex];
636
+ for (const inst of stage.runs) {
637
+ if (!installsDevDependencies(inst.args)) continue;
638
+ const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
639
+ diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
575
640
  }
576
641
  }
577
642
  return diagnostics;
578
643
  },
579
644
  defaultSeverity: "warning",
580
- help: "For Node.js, run 'npm prune --production' or install only production dependencies ('npm ci --omit=dev') in the runtime stage.",
645
+ help: "For Node.js, run `npm prune --production` or install only production dependencies (`npm ci --omit=dev`) in the runtime stage.",
581
646
  key: "docker-doctor/avoid-dev-dependencies",
582
- message: "Avoid installing development dependencies in final production stage"
647
+ message: "Avoid installing dev dependencies in the final stage"
583
648
  };
584
649
  const imageSizeRules = [
585
650
  preferSlimBase,
@@ -608,7 +673,7 @@ const useMultiStage = {
608
673
  defaultSeverity: "info",
609
674
  help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
610
675
  key: "docker-doctor/use-multi-stage",
611
- message: "Consider using multi-stage builds"
676
+ message: "Use multi-stage builds"
612
677
  };
613
678
  const orderLayers = {
614
679
  category: "Performance",
@@ -652,10 +717,16 @@ const minimizeLayers = {
652
717
  return diagnostics;
653
718
  },
654
719
  defaultSeverity: "info",
655
- help: "Combine consecutive RUN instructions using '&&' and '\\' to reduce the total layer count and image size.",
720
+ help: "Combine consecutive RUN instructions using `&&` and `\\` to reduce the total layer count and image size.",
656
721
  key: "docker-doctor/minimize-layers",
657
722
  message: "Minimize the number of image layers"
658
723
  };
724
+ const hasDockerignoreFor = (dockerfilePath, projectFiles) => {
725
+ const lastSlash = dockerfilePath.lastIndexOf("/");
726
+ const dir = lastSlash === -1 ? "" : dockerfilePath.slice(0, lastSlash);
727
+ const adjacent = dir === "" ? ".dockerignore" : `${dir}/.dockerignore`;
728
+ return projectFiles.some((f) => f === adjacent || f === ".dockerignore");
729
+ };
659
730
  const useDockerignore = {
660
731
  category: "Performance",
661
732
  check(instructions, file, context) {
@@ -667,15 +738,13 @@ const useDockerignore = {
667
738
  return src === "." || src === "./" || src === "*";
668
739
  }
669
740
  return false;
670
- }) && context?.projectFiles) {
671
- if (!context.projectFiles.some((f) => f.endsWith(".dockerignore"))) return [createDiagnostic$1(file, this.key, this.defaultSeverity, "Using COPY/ADD with wildcard/directory, but no .dockerignore file was found in the workspace. This can copy local build folders and secrets.", this.help, 1)];
672
- }
741
+ }) && context?.projectFiles && !hasDockerignoreFor(file, context.projectFiles)) return [createDiagnostic$1(file, this.key, this.defaultSeverity, "Using COPY/ADD with a wildcard or directory, but no .dockerignore file was found next to the Dockerfile or at the project root. This can copy local build folders and secrets.", this.help, 1)];
673
742
  return [];
674
743
  },
675
744
  defaultSeverity: "warning",
676
745
  help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
677
746
  key: "docker-doctor/use-dockerignore",
678
- message: "Ensure .dockerignore is used"
747
+ message: "Add a .dockerignore file"
679
748
  };
680
749
  const performanceRules = [
681
750
  useMultiStage,
@@ -694,25 +763,35 @@ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
694
763
  rule: ruleKey,
695
764
  severity
696
765
  });
766
+ const isRootUser = (value) => {
767
+ const [user] = value.split(":");
768
+ return user === "root" || user === "0";
769
+ };
697
770
  const noRootUser = {
698
771
  category: "Security",
699
772
  check(instructions, file) {
773
+ const stageUser = /* @__PURE__ */ new Map();
774
+ let currentStage = null;
700
775
  let lastUser = "root";
701
776
  let lastUserLine = 1;
702
777
  for (const inst of instructions) if (inst.instruction === "FROM") {
703
- lastUser = "root";
778
+ const { base, stage } = parseFromArgs(inst.args);
779
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
704
780
  lastUserLine = inst.line;
781
+ currentStage = stage?.toLowerCase() ?? null;
782
+ if (currentStage) stageUser.set(currentStage, lastUser);
705
783
  } else if (inst.instruction === "USER") {
706
784
  lastUser = inst.args.trim().toLowerCase();
707
785
  lastUserLine = inst.line;
786
+ if (currentStage) stageUser.set(currentStage, lastUser);
708
787
  }
709
- if (lastUser === "root" || lastUser === "0" || lastUser === "0:0") return [createDiagnostic(file, this.key, this.defaultSeverity, "The container runs as root. Running as root allows potential container breakout vulnerabilities.", this.help, lastUserLine)];
788
+ if (isRootUser(lastUser)) return [createDiagnostic(file, this.key, this.defaultSeverity, "The container runs as root. Running as root allows potential container breakout vulnerabilities.", this.help, lastUserLine)];
710
789
  return [];
711
790
  },
712
791
  defaultSeverity: "warning",
713
- help: "Add a non-root user (e.g., 'USER node' or 'USER 1000') to improve security.",
792
+ help: "Add a non-root user (e.g., `USER node` or `USER 1000`) to improve security.",
714
793
  key: "docker-doctor/no-root-user",
715
- message: "Container should not run as root user"
794
+ message: "Run the container as a non-root user"
716
795
  };
717
796
  const noSecretsInEnv = {
718
797
  category: "Security",
@@ -753,7 +832,7 @@ const noSecretsInEnv = {
753
832
  defaultSeverity: "error",
754
833
  help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
755
834
  key: "docker-doctor/no-secrets-in-env",
756
- message: "Do not store secrets in ENV or ARG instructions"
835
+ message: "Avoid storing secrets in ENV or ARG instructions"
757
836
  };
758
837
  const pinImageVersion = {
759
838
  category: "Security",
@@ -761,8 +840,8 @@ const pinImageVersion = {
761
840
  const diagnostics = [];
762
841
  const stageAliases = collectStageAliases(instructions);
763
842
  for (const inst of instructions) if (inst.instruction === "FROM") {
764
- const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
765
- if (!imagePart || imagePart === "scratch") continue;
843
+ const imagePart = parseFromArgs(inst.args).base;
844
+ if (!imagePart || isScratch(imagePart)) continue;
766
845
  const ref = parseImageRef(imagePart);
767
846
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
768
847
  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));
@@ -771,9 +850,9 @@ const pinImageVersion = {
771
850
  return diagnostics;
772
851
  },
773
852
  defaultSeverity: "warning",
774
- help: "Specify a concrete tag instead of 'latest' or no tag (e.g., 'node:22.2.0-alpine' instead of 'node').",
853
+ help: "Specify a concrete tag instead of `latest` or no tag (e.g., `node:22.2.0-alpine` instead of `node`).",
775
854
  key: "docker-doctor/pin-image-version",
776
- message: "Always pin base image versions to specific tags"
855
+ message: "Pin base images to a specific tag or digest"
777
856
  };
778
857
  const noAddRemote = {
779
858
  category: "Security",
@@ -787,7 +866,7 @@ const noAddRemote = {
787
866
  return diagnostics;
788
867
  },
789
868
  defaultSeverity: "warning",
790
- help: "Use 'RUN curl' or 'RUN wget' instead of ADD for remote URLs, and delete the downloaded archive in the same layer to minimize size.",
869
+ help: "Use `RUN curl` or `RUN wget` instead of ADD for remote URLs, and delete the downloaded archive in the same layer to minimize size.",
791
870
  key: "docker-doctor/no-add-remote",
792
871
  message: "Avoid using ADD with remote URLs"
793
872
  };
@@ -810,15 +889,19 @@ const allComposeRules = [...composeRules];
810
889
  const allRules = [...allDockerfileRules, ...allComposeRules];
811
890
  const findRule = (key) => allRules.find((rule) => rule.key === key);
812
891
 
892
+ //#endregion
893
+ //#region ../core/src/runners/resolve-severity.ts
894
+ const resolveSeverity = (rule, rulesConfig, categoriesConfig) => rulesConfig?.[rule.key] ?? categoriesConfig?.[rule.category] ?? rule.defaultSeverity;
895
+
813
896
  //#endregion
814
897
  //#region ../core/src/runners/dockerfile-runner.ts
815
- const runDockerfileRules = (instructions, file, projectFiles, rulesConfig) => {
898
+ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categoriesConfig) => {
816
899
  const diagnostics = [];
817
900
  for (const rule of allDockerfileRules) {
818
- const configSeverity = rulesConfig?.[rule.key];
819
- if (configSeverity === "off") continue;
901
+ const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
902
+ if (severity === "off") continue;
820
903
  const ruleDiagnostics = rule.check(instructions, file, { projectFiles });
821
- if (configSeverity) for (const diag of ruleDiagnostics) diag.severity = configSeverity;
904
+ if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
822
905
  diagnostics.push(...ruleDiagnostics);
823
906
  }
824
907
  return diagnostics;
@@ -826,13 +909,13 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig) => {
826
909
 
827
910
  //#endregion
828
911
  //#region ../core/src/runners/compose-runner.ts
829
- const runComposeRules = (composeContent, file, rulesConfig) => {
912
+ const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
830
913
  const diagnostics = [];
831
914
  for (const rule of allComposeRules) {
832
- const configSeverity = rulesConfig?.[rule.key];
833
- if (configSeverity === "off") continue;
915
+ const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
916
+ if (severity === "off") continue;
834
917
  const ruleDiagnostics = rule.check(composeContent, file);
835
- if (configSeverity) for (const diag of ruleDiagnostics) diag.severity = configSeverity;
918
+ if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
836
919
  diagnostics.push(...ruleDiagnostics);
837
920
  }
838
921
  return diagnostics;
@@ -900,6 +983,30 @@ const validateConfig = (input) => {
900
983
  return result;
901
984
  };
902
985
 
986
+ //#endregion
987
+ //#region ../core/src/config/unknown-keys.ts
988
+ const KNOWN_CATEGORIES = [
989
+ "Best Practices",
990
+ "Compose",
991
+ "Image Size",
992
+ "Performance",
993
+ "Security"
994
+ ];
995
+ const keysOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [];
996
+ const collectUnknownConfigKeys = (raw) => {
997
+ if (typeof raw !== "object" || raw === null) return {
998
+ categories: [],
999
+ rules: []
1000
+ };
1001
+ const knownRuleKeys = new Set(allRules.map((rule) => rule.key));
1002
+ const knownCategories = new Set(KNOWN_CATEGORIES);
1003
+ const { categories, rules } = raw;
1004
+ return {
1005
+ categories: keysOf(categories).filter((key) => !knownCategories.has(key)),
1006
+ rules: keysOf(rules).filter((key) => !knownRuleKeys.has(key))
1007
+ };
1008
+ };
1009
+
903
1010
  //#endregion
904
1011
  //#region ../core/src/config/loader.ts
905
1012
  const fileExists = async (filePath) => {
@@ -929,7 +1036,12 @@ const importConfig = async (filePath) => {
929
1036
  throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
930
1037
  }
931
1038
  };
932
- const loadConfig = async (rootDir, customPath) => {
1039
+ const warnUnknownKeys = (raw, onWarning) => {
1040
+ const unknown = collectUnknownConfigKeys(raw);
1041
+ for (const key of unknown.rules) onWarning(`Unknown rule "${key}" in config — it matches no rule and has no effect.`);
1042
+ for (const key of unknown.categories) onWarning(`Unknown category "${key}" in config — categories are case-sensitive (e.g. "Best Practices", "Security").`);
1043
+ };
1044
+ const loadConfig = async (rootDir, customPath, onWarning) => {
933
1045
  let configObject = null;
934
1046
  if (customPath) {
935
1047
  const fullPath = node_path.default.resolve(rootDir, customPath);
@@ -962,7 +1074,9 @@ const loadConfig = async (rootDir, customPath) => {
962
1074
  }
963
1075
  if (!configObject) return {};
964
1076
  try {
965
- return validateConfig(configObject);
1077
+ const config = validateConfig(configObject);
1078
+ if (onWarning) warnUnknownKeys(configObject, onWarning);
1079
+ return config;
966
1080
  } catch (error) {
967
1081
  const msg = error instanceof Error ? error.message : String(error);
968
1082
  throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
@@ -1103,4 +1217,4 @@ Object.defineProperty(exports, 'version', {
1103
1217
  return version;
1104
1218
  }
1105
1219
  });
1106
- //# sourceMappingURL=src-DOPSHVJr.cjs.map
1220
+ //# sourceMappingURL=src-ILbJuJmE.cjs.map