@docker-doctor/cli 0.4.2 → 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.
package/dist/index.d.mts CHANGED
@@ -60,7 +60,7 @@ declare const findRule: (key: string) => RuleDefinition | undefined;
60
60
  declare const defineConfig: (config: DockerDoctorConfig) => DockerDoctorConfig;
61
61
  //#endregion
62
62
  //#region ../core/src/config/loader.d.ts
63
- declare const loadConfig: (rootDir: string, customPath?: string) => Promise<DockerDoctorConfig>;
63
+ declare const loadConfig: (rootDir: string, customPath?: string, onWarning?: (message: string) => void) => Promise<DockerDoctorConfig>;
64
64
  //#endregion
65
65
  //#region ../core/src/scoring.d.ts
66
66
  declare const calculateScore: (diagnostics: Diagnostic[]) => {
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as runDockerfileRules, c as parseCompose, d as version$1, i as runComposeRules, l as parseDockerfile, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as discoverProject } from "./src-BuUGk4ND.mjs";
2
+ import { a as runDockerfileRules, c as parseCompose, d as version$1, i as runComposeRules, l as parseDockerfile, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as discoverProject } from "./src-TK9DRoRo.mjs";
3
3
 
4
4
  //#region ../core/src/config/define-config.ts
5
5
  const defineConfig = (config) => config;
@@ -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.2";
36
+ var version = "0.4.3";
37
37
 
38
38
  //#endregion
39
39
  //#region ../core/src/project-info/discover.ts
@@ -203,6 +203,79 @@ const parseCompose = (content, filepath) => {
203
203
  }
204
204
  };
205
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
+
206
279
  //#endregion
207
280
  //#region ../core/src/rules/best-practices.ts
208
281
  const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
@@ -222,7 +295,7 @@ const requireHealthcheck = {
222
295
  return [];
223
296
  },
224
297
  defaultSeverity: "info",
225
- 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.",
226
299
  key: "docker-doctor/require-healthcheck",
227
300
  message: "Add a HEALTHCHECK instruction"
228
301
  };
@@ -248,14 +321,11 @@ const useExecForm = {
248
321
  category: "Best Practices",
249
322
  check(instructions, file) {
250
323
  const diagnostics = [];
251
- for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
252
- const args = inst.args.trim();
253
- 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));
254
- }
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));
255
325
  return diagnostics;
256
326
  },
257
327
  defaultSeverity: "warning",
258
- 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.",
259
329
  key: "docker-doctor/use-exec-form",
260
330
  message: "Use exec form for CMD and ENTRYPOINT"
261
331
  };
@@ -266,7 +336,7 @@ const requireLabels = {
266
336
  return [];
267
337
  },
268
338
  defaultSeverity: "info",
269
- 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.",
270
340
  key: "docker-doctor/require-labels",
271
341
  message: "Add LABEL metadata to images"
272
342
  };
@@ -283,22 +353,47 @@ const combineAptUpdateInstall = {
283
353
  return diagnostics;
284
354
  },
285
355
  defaultSeverity: "warning",
286
- 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/*`).",
287
357
  key: "docker-doctor/combine-apt-update-install",
288
358
  message: "Combine apt-get update and apt-get install"
289
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
+ };
290
366
  const usePipefail = {
291
367
  category: "Best Practices",
292
368
  check(instructions, file) {
293
369
  const diagnostics = [];
294
- for (const inst of instructions) if (inst.instruction === "RUN") {
295
- const { raw } = inst;
296
- 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));
297
392
  }
298
393
  return diagnostics;
299
394
  },
300
395
  defaultSeverity: "warning",
301
- 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.",
302
397
  key: "docker-doctor/use-pipefail",
303
398
  message: "Use pipefail to catch pipeline command failures"
304
399
  };
@@ -313,7 +408,7 @@ const absoluteWorkdir = {
313
408
  return diagnostics;
314
409
  },
315
410
  defaultSeverity: "warning",
316
- 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`).",
317
412
  key: "docker-doctor/absolute-workdir",
318
413
  message: "Use absolute paths for WORKDIR"
319
414
  };
@@ -325,7 +420,7 @@ const avoidRunCd = {
325
420
  return diagnostics;
326
421
  },
327
422
  defaultSeverity: "info",
328
- 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.",
329
424
  key: "docker-doctor/avoid-run-cd",
330
425
  message: "Avoid changing directories with cd in RUN"
331
426
  };
@@ -360,7 +455,7 @@ const useraddNoLogInit = {
360
455
  return diagnostics;
361
456
  },
362
457
  defaultSeverity: "warning",
363
- 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`).",
364
459
  key: "docker-doctor/useradd-no-log-init",
365
460
  message: "Use --no-log-init with useradd"
366
461
  };
@@ -393,9 +488,9 @@ const noVersionKey = {
393
488
  return [];
394
489
  },
395
490
  defaultSeverity: "warning",
396
- 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.",
397
492
  key: "docker-doctor/no-version-key",
398
- message: "Remove the 'version' key from Compose file"
493
+ message: "Remove the `version` key from the Compose file"
399
494
  };
400
495
  const requireResourceLimits = {
401
496
  category: "Compose",
@@ -413,7 +508,7 @@ const requireResourceLimits = {
413
508
  return diagnostics;
414
509
  },
415
510
  defaultSeverity: "warning",
416
- 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.",
417
512
  key: "docker-doctor/require-resource-limits",
418
513
  message: "Define resource limits for services"
419
514
  };
@@ -434,7 +529,7 @@ const requireRestartPolicy = {
434
529
  return diagnostics;
435
530
  },
436
531
  defaultSeverity: "warning",
437
- 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.",
438
533
  key: "docker-doctor/require-restart-policy",
439
534
  message: "Set restart policy for services"
440
535
  };
@@ -454,7 +549,7 @@ const useDependsOnCondition = {
454
549
  return diagnostics;
455
550
  },
456
551
  defaultSeverity: "info",
457
- 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.",
458
553
  key: "docker-doctor/use-depends-on-condition",
459
554
  message: "Use long-form depends_on with healthcheck conditions"
460
555
  };
@@ -465,54 +560,6 @@ const composeRules = [
465
560
  useDependsOnCondition
466
561
  ];
467
562
 
468
- //#endregion
469
- //#region ../core/src/parsers/image-ref.ts
470
- const parseImageRef = (ref) => {
471
- if (ref.includes("${") || ref.startsWith("$")) return {
472
- isVariable: true,
473
- name: ref
474
- };
475
- let remainder = ref;
476
- let digest;
477
- const atIndex = remainder.indexOf("@");
478
- if (atIndex !== -1) {
479
- digest = remainder.slice(atIndex + 1);
480
- remainder = remainder.slice(0, atIndex);
481
- }
482
- let tag;
483
- const lastColonIndex = remainder.lastIndexOf(":");
484
- const lastSlashIndex = remainder.lastIndexOf("/");
485
- if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
486
- tag = remainder.slice(lastColonIndex + 1);
487
- remainder = remainder.slice(0, lastColonIndex);
488
- }
489
- let registry;
490
- const firstSlashIndex = remainder.indexOf("/");
491
- if (firstSlashIndex !== -1) {
492
- const firstSegment = remainder.slice(0, firstSlashIndex);
493
- if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
494
- registry = firstSegment;
495
- remainder = remainder.slice(firstSlashIndex + 1);
496
- }
497
- }
498
- return {
499
- digest,
500
- isVariable: false,
501
- name: remainder,
502
- registry,
503
- tag
504
- };
505
- };
506
- const collectStageAliases = (instructions) => {
507
- const aliases = /* @__PURE__ */ new Set();
508
- for (const inst of instructions) {
509
- if (inst.instruction !== "FROM") continue;
510
- const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
511
- if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
512
- }
513
- return aliases;
514
- };
515
-
516
563
  //#endregion
517
564
  //#region ../core/src/rules/image-size.ts
518
565
  const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
@@ -529,8 +576,8 @@ const preferSlimBase = {
529
576
  const diagnostics = [];
530
577
  const stageAliases = collectStageAliases(instructions);
531
578
  for (const inst of instructions) if (inst.instruction === "FROM") {
532
- const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
533
- if (!imagePart || imagePart === "scratch") continue;
579
+ const imagePart = parseFromArgs(inst.args).base;
580
+ if (!imagePart || isScratch(imagePart)) continue;
534
581
  const ref = parseImageRef(imagePart);
535
582
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
536
583
  if (ref.digest) continue;
@@ -541,9 +588,9 @@ const preferSlimBase = {
541
588
  return diagnostics;
542
589
  },
543
590
  defaultSeverity: "info",
544
- 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.",
545
592
  key: "docker-doctor/prefer-slim-base",
546
- message: "Use slim, alpine, or distroless base images"
593
+ message: "Prefer slim, alpine, or distroless base images"
547
594
  };
548
595
  const cleanPackageCache = {
549
596
  category: "Image Size",
@@ -557,34 +604,47 @@ const cleanPackageCache = {
557
604
  return diagnostics;
558
605
  },
559
606
  defaultSeverity: "warning",
560
- 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`.",
561
608
  key: "docker-doctor/clean-package-cache",
562
609
  message: "Clean up package manager cache in the same RUN layer"
563
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");
564
612
  const avoidDevDependencies = {
565
613
  category: "Image Size",
566
614
  check(instructions, file) {
567
615
  const diagnostics = [];
568
- let isLastStage = false;
569
- let fromCount = 0;
570
- for (const inst of instructions) if (inst.instruction === "FROM") fromCount += 1;
571
- let currentStage = 0;
572
- for (const inst of instructions) {
573
- if (inst.instruction === "FROM") {
574
- currentStage += 1;
575
- isLastStage = currentStage === fromCount;
576
- }
577
- if (isLastStage && inst.instruction === "RUN") {
578
- const { args } = inst;
579
- 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));
580
640
  }
581
641
  }
582
642
  return diagnostics;
583
643
  },
584
644
  defaultSeverity: "warning",
585
- 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.",
586
646
  key: "docker-doctor/avoid-dev-dependencies",
587
- message: "Avoid installing development dependencies in final production stage"
647
+ message: "Avoid installing dev dependencies in the final stage"
588
648
  };
589
649
  const imageSizeRules = [
590
650
  preferSlimBase,
@@ -613,7 +673,7 @@ const useMultiStage = {
613
673
  defaultSeverity: "info",
614
674
  help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
615
675
  key: "docker-doctor/use-multi-stage",
616
- message: "Consider using multi-stage builds"
676
+ message: "Use multi-stage builds"
617
677
  };
618
678
  const orderLayers = {
619
679
  category: "Performance",
@@ -657,7 +717,7 @@ const minimizeLayers = {
657
717
  return diagnostics;
658
718
  },
659
719
  defaultSeverity: "info",
660
- 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.",
661
721
  key: "docker-doctor/minimize-layers",
662
722
  message: "Minimize the number of image layers"
663
723
  };
@@ -684,7 +744,7 @@ const useDockerignore = {
684
744
  defaultSeverity: "warning",
685
745
  help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
686
746
  key: "docker-doctor/use-dockerignore",
687
- message: "Ensure .dockerignore is used"
747
+ message: "Add a .dockerignore file"
688
748
  };
689
749
  const performanceRules = [
690
750
  useMultiStage,
@@ -710,22 +770,28 @@ const isRootUser = (value) => {
710
770
  const noRootUser = {
711
771
  category: "Security",
712
772
  check(instructions, file) {
773
+ const stageUser = /* @__PURE__ */ new Map();
774
+ let currentStage = null;
713
775
  let lastUser = "root";
714
776
  let lastUserLine = 1;
715
777
  for (const inst of instructions) if (inst.instruction === "FROM") {
716
- lastUser = "root";
778
+ const { base, stage } = parseFromArgs(inst.args);
779
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
717
780
  lastUserLine = inst.line;
781
+ currentStage = stage?.toLowerCase() ?? null;
782
+ if (currentStage) stageUser.set(currentStage, lastUser);
718
783
  } else if (inst.instruction === "USER") {
719
784
  lastUser = inst.args.trim().toLowerCase();
720
785
  lastUserLine = inst.line;
786
+ if (currentStage) stageUser.set(currentStage, lastUser);
721
787
  }
722
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)];
723
789
  return [];
724
790
  },
725
791
  defaultSeverity: "warning",
726
- 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.",
727
793
  key: "docker-doctor/no-root-user",
728
- message: "Container should not run as root user"
794
+ message: "Run the container as a non-root user"
729
795
  };
730
796
  const noSecretsInEnv = {
731
797
  category: "Security",
@@ -766,7 +832,7 @@ const noSecretsInEnv = {
766
832
  defaultSeverity: "error",
767
833
  help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
768
834
  key: "docker-doctor/no-secrets-in-env",
769
- message: "Do not store secrets in ENV or ARG instructions"
835
+ message: "Avoid storing secrets in ENV or ARG instructions"
770
836
  };
771
837
  const pinImageVersion = {
772
838
  category: "Security",
@@ -774,8 +840,8 @@ const pinImageVersion = {
774
840
  const diagnostics = [];
775
841
  const stageAliases = collectStageAliases(instructions);
776
842
  for (const inst of instructions) if (inst.instruction === "FROM") {
777
- const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
778
- if (!imagePart || imagePart === "scratch") continue;
843
+ const imagePart = parseFromArgs(inst.args).base;
844
+ if (!imagePart || isScratch(imagePart)) continue;
779
845
  const ref = parseImageRef(imagePart);
780
846
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
781
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));
@@ -784,9 +850,9 @@ const pinImageVersion = {
784
850
  return diagnostics;
785
851
  },
786
852
  defaultSeverity: "warning",
787
- 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`).",
788
854
  key: "docker-doctor/pin-image-version",
789
- message: "Always pin base image versions to specific tags"
855
+ message: "Pin base images to a specific tag or digest"
790
856
  };
791
857
  const noAddRemote = {
792
858
  category: "Security",
@@ -800,7 +866,7 @@ const noAddRemote = {
800
866
  return diagnostics;
801
867
  },
802
868
  defaultSeverity: "warning",
803
- 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.",
804
870
  key: "docker-doctor/no-add-remote",
805
871
  message: "Avoid using ADD with remote URLs"
806
872
  };
@@ -917,6 +983,30 @@ const validateConfig = (input) => {
917
983
  return result;
918
984
  };
919
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
+
920
1010
  //#endregion
921
1011
  //#region ../core/src/config/loader.ts
922
1012
  const fileExists = async (filePath) => {
@@ -946,7 +1036,12 @@ const importConfig = async (filePath) => {
946
1036
  throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
947
1037
  }
948
1038
  };
949
- 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) => {
950
1045
  let configObject = null;
951
1046
  if (customPath) {
952
1047
  const fullPath = node_path.default.resolve(rootDir, customPath);
@@ -979,7 +1074,9 @@ const loadConfig = async (rootDir, customPath) => {
979
1074
  }
980
1075
  if (!configObject) return {};
981
1076
  try {
982
- return validateConfig(configObject);
1077
+ const config = validateConfig(configObject);
1078
+ if (onWarning) warnUnknownKeys(configObject, onWarning);
1079
+ return config;
983
1080
  } catch (error) {
984
1081
  const msg = error instanceof Error ? error.message : String(error);
985
1082
  throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
@@ -1120,4 +1217,4 @@ Object.defineProperty(exports, 'version', {
1120
1217
  return version;
1121
1218
  }
1122
1219
  });
1123
- //# sourceMappingURL=src-909bBBPN.cjs.map
1220
+ //# sourceMappingURL=src-ILbJuJmE.cjs.map