@docker-doctor/cli 0.4.2 → 0.4.4

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.2";
36
+ var version = "0.4.4";
37
37
 
38
38
  //#endregion
39
39
  //#region ../core/src/project-info/discover.ts
@@ -136,7 +136,6 @@ const processHeredocLine = (state, trimmed) => {
136
136
  };
137
137
  const processInstructionLine = (state, trimmed, lineNum) => {
138
138
  let lineContent = trimmed;
139
- if (lineContent.startsWith("#")) return;
140
139
  const hasContinuation = lineContent.endsWith("\\");
141
140
  if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
142
141
  if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
@@ -159,7 +158,8 @@ const parseDockerfile = (content) => {
159
158
  const trimmed = rawLine.trim();
160
159
  const lineNum = i + 1;
161
160
  const insideHeredoc = state.heredocQueue.length > 0;
162
- if (!state.currentInstruction && !insideHeredoc && (trimmed === "" || trimmed.startsWith("#"))) continue;
161
+ if (!insideHeredoc && trimmed.startsWith("#")) continue;
162
+ if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
163
163
  state.rawAccumulator.push(rawLine);
164
164
  if (insideHeredoc) processHeredocLine(state, trimmed);
165
165
  else processInstructionLine(state, trimmed, lineNum);
@@ -202,10 +202,117 @@ const parseCompose = (content, filepath) => {
202
202
  });
203
203
  }
204
204
  };
205
+ /**
206
+ * Builds a {@link ComposeLocator} over the same source text a compose object
207
+ * was parsed from, so rules can attach line numbers to their diagnostics.
208
+ *
209
+ * Keys pulled in via YAML merge keys (`<<: *anchor`) have no concrete node
210
+ * at the merge site, so paths through them resolve to `undefined` — callers
211
+ * fall back to an unnumbered diagnostic, which matches the old behavior.
212
+ */
213
+ const createComposeLocator = (content) => {
214
+ const lineCounter = new yaml.LineCounter();
215
+ const doc = (0, yaml.parseDocument)(content, {
216
+ lineCounter,
217
+ merge: true
218
+ });
219
+ return (path) => {
220
+ let node = doc.contents;
221
+ let offset;
222
+ for (const segment of path) {
223
+ if ((0, yaml.isAlias)(node)) node = node.resolve(doc);
224
+ if ((0, yaml.isMap)(node)) {
225
+ const pair = node.items.find((item) => (0, yaml.isScalar)(item.key) && String(item.key.value) === String(segment));
226
+ if (!pair || !(0, yaml.isScalar)(pair.key)) return;
227
+ offset = pair.key.range?.[0];
228
+ node = pair.value;
229
+ } else if ((0, yaml.isSeq)(node) && typeof segment === "number") {
230
+ const item = node.items[segment];
231
+ if (item === void 0 || item === null) return;
232
+ offset = item.range?.[0];
233
+ node = item;
234
+ } else return;
235
+ }
236
+ return offset === void 0 ? void 0 : lineCounter.linePos(offset).line;
237
+ };
238
+ };
205
239
 
206
240
  //#endregion
207
- //#region ../core/src/rules/best-practices.ts
208
- const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
241
+ //#region ../core/src/parsers/exec-form.ts
242
+ const parseExecForm = (args) => {
243
+ const trimmed = args.trim();
244
+ if (!trimmed.startsWith("[")) return null;
245
+ let parsed;
246
+ try {
247
+ parsed = JSON.parse(trimmed);
248
+ } catch {
249
+ return null;
250
+ }
251
+ if (!Array.isArray(parsed)) return null;
252
+ if (!parsed.every((el) => typeof el === "string")) return null;
253
+ return parsed;
254
+ };
255
+
256
+ //#endregion
257
+ //#region ../core/src/parsers/image-ref.ts
258
+ const parseImageRef = (ref) => {
259
+ if (ref.includes("${") || ref.startsWith("$")) return {
260
+ isVariable: true,
261
+ name: ref
262
+ };
263
+ let remainder = ref;
264
+ let digest;
265
+ const atIndex = remainder.indexOf("@");
266
+ if (atIndex !== -1) {
267
+ digest = remainder.slice(atIndex + 1);
268
+ remainder = remainder.slice(0, atIndex);
269
+ }
270
+ let tag;
271
+ const lastColonIndex = remainder.lastIndexOf(":");
272
+ const lastSlashIndex = remainder.lastIndexOf("/");
273
+ if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
274
+ tag = remainder.slice(lastColonIndex + 1);
275
+ remainder = remainder.slice(0, lastColonIndex);
276
+ }
277
+ let registry;
278
+ const firstSlashIndex = remainder.indexOf("/");
279
+ if (firstSlashIndex !== -1) {
280
+ const firstSegment = remainder.slice(0, firstSlashIndex);
281
+ if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
282
+ registry = firstSegment;
283
+ remainder = remainder.slice(firstSlashIndex + 1);
284
+ }
285
+ }
286
+ return {
287
+ digest,
288
+ isVariable: false,
289
+ name: remainder,
290
+ registry,
291
+ tag
292
+ };
293
+ };
294
+ const parseFromArgs = (args) => {
295
+ const parts = args.split(/\s+/u).filter(Boolean);
296
+ const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
297
+ return {
298
+ base: (asIndex === -1 ? parts : parts.slice(0, asIndex)).find((p) => !p.startsWith("--")) ?? null,
299
+ stage: asIndex === -1 ? null : parts[asIndex + 1] ?? null
300
+ };
301
+ };
302
+ const isScratch = (base) => base?.toLowerCase() === "scratch";
303
+ const collectStageAliases = (instructions) => {
304
+ const aliases = /* @__PURE__ */ new Set();
305
+ for (const inst of instructions) {
306
+ if (inst.instruction !== "FROM") continue;
307
+ const { stage } = parseFromArgs(inst.args);
308
+ if (stage) aliases.add(stage.toLowerCase());
309
+ }
310
+ return aliases;
311
+ };
312
+
313
+ //#endregion
314
+ //#region ../core/src/rules/create-diagnostic.ts
315
+ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
209
316
  file,
210
317
  help,
211
318
  line,
@@ -213,16 +320,19 @@ const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
213
320
  rule: ruleKey,
214
321
  severity
215
322
  });
323
+
324
+ //#endregion
325
+ //#region ../core/src/rules/best-practices.ts
216
326
  const requireHealthcheck = {
217
327
  category: "Best Practices",
218
328
  check(instructions, file) {
219
329
  const hasHealthcheck = instructions.some((inst) => inst.instruction === "HEALTHCHECK");
220
330
  const hasExposedPortsOrEntry = instructions.some((inst) => inst.instruction === "EXPOSE" || inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT");
221
- if (!hasHealthcheck && hasExposedPortsOrEntry) return [createDiagnostic$4(file, this.key, this.defaultSeverity, "No HEALTHCHECK instruction found. Containers running services should expose healthchecks to enable auto-healing.", this.help, 1)];
331
+ if (!hasHealthcheck && hasExposedPortsOrEntry) return [createDiagnostic(file, this.key, this.defaultSeverity, "No HEALTHCHECK instruction found. Containers running services should expose healthchecks to enable auto-healing.", this.help, 1)];
222
332
  return [];
223
333
  },
224
334
  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.",
335
+ 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
336
  key: "docker-doctor/require-healthcheck",
227
337
  message: "Add a HEALTHCHECK instruction"
228
338
  };
@@ -235,7 +345,7 @@ const preferCopyOverAdd = {
235
345
  if (!src) continue;
236
346
  const isRemote = src.startsWith("http://") || src.startsWith("https://");
237
347
  const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
238
- if (!isRemote && !isArchive) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `ADD instruction used for regular files: '${inst.args}'. COPY is simpler and less prone to magic side effects.`, this.help, inst.line));
348
+ 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));
239
349
  }
240
350
  return diagnostics;
241
351
  },
@@ -248,25 +358,22 @@ const useExecForm = {
248
358
  category: "Best Practices",
249
359
  check(instructions, file) {
250
360
  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
- }
361
+ for (const inst of instructions) if ((inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") && parseExecForm(inst.args) === null) diagnostics.push(createDiagnostic(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
362
  return diagnostics;
256
363
  },
257
364
  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.",
365
+ help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. `ENTRYPOINT [\"node\", \"index.js\"]`) so OS signals (like SIGTERM) are forwarded correctly.",
259
366
  key: "docker-doctor/use-exec-form",
260
367
  message: "Use exec form for CMD and ENTRYPOINT"
261
368
  };
262
369
  const requireLabels = {
263
370
  category: "Best Practices",
264
371
  check(instructions, file) {
265
- if (!instructions.some((inst) => inst.instruction === "LABEL")) return [createDiagnostic$4(file, this.key, this.defaultSeverity, "No LABEL metadata was found in this Dockerfile. Adding labels helps identify build information, maintainers, and descriptions.", this.help, 1)];
372
+ if (!instructions.some((inst) => inst.instruction === "LABEL")) return [createDiagnostic(file, this.key, this.defaultSeverity, "No LABEL metadata was found in this Dockerfile. Adding labels helps identify build information, maintainers, and descriptions.", this.help, 1)];
266
373
  return [];
267
374
  },
268
375
  defaultSeverity: "info",
269
- help: "Use LABEL instructions (e.g. LABEL org.opencontainers.image.authors=\"...\") to document ownership, license, version, and build info.",
376
+ help: "Use LABEL instructions (e.g. `LABEL org.opencontainers.image.authors=\"...\"`) to document ownership, license, version, and build info.",
270
377
  key: "docker-doctor/require-labels",
271
378
  message: "Add LABEL metadata to images"
272
379
  };
@@ -277,28 +384,53 @@ const combineAptUpdateInstall = {
277
384
  for (const inst of instructions) if (inst.instruction === "RUN") {
278
385
  const hasUpdate = inst.args.includes("apt-get update");
279
386
  const hasInstall = inst.args.includes("apt-get install");
280
- if (hasUpdate && !hasInstall) diagnostics.push(createDiagnostic$4(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));
281
- else if (hasInstall && !hasUpdate) diagnostics.push(createDiagnostic$4(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));
387
+ 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));
388
+ 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));
282
389
  }
283
390
  return diagnostics;
284
391
  },
285
392
  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/*').",
393
+ 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
394
  key: "docker-doctor/combine-apt-update-install",
288
395
  message: "Combine apt-get update and apt-get install"
289
396
  };
397
+ const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
398
+ const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
399
+ const shellDirectiveEnablesPipefail = (args) => {
400
+ const argv = parseExecForm(args);
401
+ return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
402
+ };
290
403
  const usePipefail = {
291
404
  category: "Best Practices",
292
405
  check(instructions, file) {
293
406
  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));
407
+ const stagePipefail = /* @__PURE__ */ new Map();
408
+ let shellHasPipefail = false;
409
+ let currentStage = null;
410
+ for (const inst of instructions) {
411
+ if (inst.instruction === "FROM") {
412
+ const { base, stage } = parseFromArgs(inst.args);
413
+ const parentStage = isScratch(base) ? null : base?.toLowerCase() ?? null;
414
+ shellHasPipefail = parentStage !== null && stagePipefail.get(parentStage) === true;
415
+ currentStage = stage?.toLowerCase() ?? null;
416
+ if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
417
+ continue;
418
+ }
419
+ if (inst.instruction === "SHELL") {
420
+ shellHasPipefail = shellDirectiveEnablesPipefail(inst.args);
421
+ if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
422
+ continue;
423
+ }
424
+ if (inst.instruction !== "RUN") continue;
425
+ const { args } = inst;
426
+ if (!HAS_PIPE_RE.test(args)) continue;
427
+ const execArgv = parseExecForm(args);
428
+ if (!(execArgv === null ? shellHasPipefail || PIPEFAIL_SETTING_RE.test(args) : PIPEFAIL_SETTING_RE.test(execArgv.join(" ")))) diagnostics.push(createDiagnostic(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
429
  }
298
430
  return diagnostics;
299
431
  },
300
432
  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 && ...']).",
433
+ 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
434
  key: "docker-doctor/use-pipefail",
303
435
  message: "Use pipefail to catch pipeline command failures"
304
436
  };
@@ -308,12 +440,12 @@ const absoluteWorkdir = {
308
440
  const diagnostics = [];
309
441
  for (const inst of instructions) if (inst.instruction === "WORKDIR") {
310
442
  const path = inst.args.trim();
311
- if (!/^(?:\/|\\|\$|[a-zA-Z]:)/u.test(path)) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, `WORKDIR specifies a relative path '${path}'. For clarity and reliability, always use absolute paths.`, this.help, inst.line));
443
+ if (!/^(?:\/|\\|\$|[a-zA-Z]:)/u.test(path)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `WORKDIR specifies a relative path '${path}'. For clarity and reliability, always use absolute paths.`, this.help, inst.line));
312
444
  }
313
445
  return diagnostics;
314
446
  },
315
447
  defaultSeverity: "warning",
316
- help: "Always specify absolute paths for WORKDIR instructions (e.g. WORKDIR /app).",
448
+ help: "Always specify absolute paths for WORKDIR instructions (e.g. `WORKDIR /app`).",
317
449
  key: "docker-doctor/absolute-workdir",
318
450
  message: "Use absolute paths for WORKDIR"
319
451
  };
@@ -321,11 +453,11 @@ const avoidRunCd = {
321
453
  category: "Best Practices",
322
454
  check(instructions, file) {
323
455
  const diagnostics = [];
324
- for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(inst.args)) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "Avoid using 'cd' in RUN instructions. Use WORKDIR instead to change the working directory stably across layers.", this.help, inst.line));
456
+ for (const inst of instructions) if (inst.instruction === "RUN" && /\bcd\b/u.test(inst.args)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "Avoid using 'cd' in RUN instructions. Use WORKDIR instead to change the working directory stably across layers.", this.help, inst.line));
325
457
  return diagnostics;
326
458
  },
327
459
  defaultSeverity: "info",
328
- help: "Use the WORKDIR instruction instead of 'cd' inside RUN to establish directory context.",
460
+ help: "Use the WORKDIR instruction instead of `cd` inside RUN to establish directory context.",
329
461
  key: "docker-doctor/avoid-run-cd",
330
462
  message: "Avoid changing directories with cd in RUN"
331
463
  };
@@ -341,7 +473,7 @@ const sortMultilineArgs = {
341
473
  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);
342
474
  if (packages.length > 1) {
343
475
  const sorted = packages.toSorted((a, b) => a.localeCompare(b));
344
- if (!packages.every((val, idx) => val === sorted[idx])) diagnostics.push(createDiagnostic$4(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));
476
+ 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));
345
477
  }
346
478
  }
347
479
  }
@@ -356,11 +488,11 @@ const useraddNoLogInit = {
356
488
  category: "Best Practices",
357
489
  check(instructions, file) {
358
490
  const diagnostics = [];
359
- for (const inst of instructions) if (inst.instruction === "RUN" && /\buseradd\b/u.test(inst.args) && !inst.args.includes("--no-log-init")) diagnostics.push(createDiagnostic$4(file, this.key, this.defaultSeverity, "RUN instruction runs 'useradd' without '--no-log-init'. This can cause excessive disk space usage / exhaustion under Go's sparse tar archive bug when large UIDs are used.", this.help, inst.line));
491
+ for (const inst of instructions) if (inst.instruction === "RUN" && /\buseradd\b/u.test(inst.args) && !inst.args.includes("--no-log-init")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, "RUN instruction runs 'useradd' without '--no-log-init'. This can cause excessive disk space usage / exhaustion under Go's sparse tar archive bug when large UIDs are used.", this.help, inst.line));
360
492
  return diagnostics;
361
493
  },
362
494
  defaultSeverity: "warning",
363
- help: "Pass '--no-log-init' flag to useradd (e.g., 'RUN useradd --no-log-init -r -g mygroup myuser').",
495
+ help: "Pass `--no-log-init` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
364
496
  key: "docker-doctor/useradd-no-log-init",
365
497
  message: "Use --no-log-init with useradd"
366
498
  };
@@ -379,47 +511,40 @@ const bestPracticesRules = [
379
511
 
380
512
  //#endregion
381
513
  //#region ../core/src/rules/compose.ts
382
- const createDiagnostic$3 = (file, ruleKey, severity, message, help) => ({
383
- file,
384
- help,
385
- message,
386
- rule: ruleKey,
387
- severity
388
- });
389
514
  const noVersionKey = {
390
515
  category: "Compose",
391
- check(composeContent, file) {
392
- if (composeContent && typeof composeContent === "object" && "version" in composeContent) return [createDiagnostic$3(file, this.key, this.defaultSeverity, "The 'version' property is deprecated. Remove it to use standard Compose spec behavior.", this.help)];
516
+ check(composeContent, file, context) {
517
+ if (composeContent && typeof composeContent === "object" && "version" in composeContent) return [createDiagnostic(file, this.key, this.defaultSeverity, "The 'version' property is deprecated. Remove it to use standard Compose spec behavior.", this.help, context?.locate?.(["version"]))];
393
518
  return [];
394
519
  },
395
520
  defaultSeverity: "warning",
396
- help: "The 'version' key is deprecated by the Compose specification. Omitting it defaults to the latest specification.",
521
+ help: "The `version` key is obsolete in the Compose specification. Omitting it defaults to the latest specification.",
397
522
  key: "docker-doctor/no-version-key",
398
- message: "Remove the 'version' key from Compose file"
523
+ message: "Remove the `version` key from the Compose file"
399
524
  };
400
525
  const requireResourceLimits = {
401
526
  category: "Compose",
402
- check(composeContent, file) {
527
+ check(composeContent, file, context) {
403
528
  const diagnostics = [];
404
529
  if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
405
530
  const { services } = composeContent;
406
531
  if (services && typeof services === "object") {
407
532
  for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
408
533
  const limits = (config.deploy?.resources)?.limits;
409
- if (!limits || !limits.cpus && !limits.memory) diagnostics.push(createDiagnostic$3(file, this.key, this.defaultSeverity, `Service '${name}' does not have CPU or memory limits defined. A resource leak in this service could crash the host.`, this.help));
534
+ if (!limits || !limits.cpus && !limits.memory) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' does not have CPU or memory limits defined. A resource leak in this service could crash the host.`, this.help, context?.locate?.(["services", name])));
410
535
  }
411
536
  }
412
537
  }
413
538
  return diagnostics;
414
539
  },
415
540
  defaultSeverity: "warning",
416
- help: "Add resource limits (e.g. deploy.resources.limits) to prevent a single service from starving host resources in production.",
541
+ help: "Add resource limits (e.g. `deploy.resources.limits`) to prevent a single service from starving host resources in production.",
417
542
  key: "docker-doctor/require-resource-limits",
418
543
  message: "Define resource limits for services"
419
544
  };
420
545
  const requireRestartPolicy = {
421
546
  category: "Compose",
422
- check(composeContent, file) {
547
+ check(composeContent, file, context) {
423
548
  const diagnostics = [];
424
549
  if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
425
550
  const { services } = composeContent;
@@ -427,34 +552,38 @@ const requireRestartPolicy = {
427
552
  for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
428
553
  const hasRestart = "restart" in config;
429
554
  const hasDeployRestart = config.deploy?.restart_policy !== void 0;
430
- if (!hasRestart && !hasDeployRestart) diagnostics.push(createDiagnostic$3(file, this.key, this.defaultSeverity, `Service '${name}' has no restart policy configured. It will not restart if it crashes or if the host reboots.`, this.help));
555
+ if (!hasRestart && !hasDeployRestart) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' has no restart policy configured. It will not restart if it crashes or if the host reboots.`, this.help, context?.locate?.(["services", name])));
431
556
  }
432
557
  }
433
558
  }
434
559
  return diagnostics;
435
560
  },
436
561
  defaultSeverity: "warning",
437
- help: "Define 'restart: always' or 'restart: unless-stopped' (or deploy.restart_policy) so services restart on crashes or host reboot.",
562
+ help: "Define `restart: always` or `restart: unless-stopped` (or `deploy.restart_policy`) so services restart on crashes or host reboot.",
438
563
  key: "docker-doctor/require-restart-policy",
439
564
  message: "Set restart policy for services"
440
565
  };
441
566
  const useDependsOnCondition = {
442
567
  category: "Compose",
443
- check(composeContent, file) {
568
+ check(composeContent, file, context) {
444
569
  const diagnostics = [];
445
570
  if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
446
571
  const { services } = composeContent;
447
572
  if (services && typeof services === "object") {
448
573
  for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
449
574
  const dependsOn = config.depends_on;
450
- if (dependsOn && Array.isArray(dependsOn)) diagnostics.push(createDiagnostic$3(file, this.key, this.defaultSeverity, `Service '${name}' uses shorthand depends_on list. This only checks if containers are started, not if they are ready/healthy.`, this.help));
575
+ if (dependsOn && Array.isArray(dependsOn)) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Service '${name}' uses shorthand depends_on list. This only checks if containers are started, not if they are ready/healthy.`, this.help, context?.locate?.([
576
+ "services",
577
+ name,
578
+ "depends_on"
579
+ ]) ?? context?.locate?.(["services", name])));
451
580
  }
452
581
  }
453
582
  }
454
583
  return diagnostics;
455
584
  },
456
585
  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.",
586
+ help: "Instead of a simple service list, use `depends_on: { dependency: { condition: service_healthy } }` to ensure dependencies are fully ready before starting.",
458
587
  key: "docker-doctor/use-depends-on-condition",
459
588
  message: "Use long-form depends_on with healthcheck conditions"
460
589
  };
@@ -465,126 +594,89 @@ const composeRules = [
465
594
  useDependsOnCondition
466
595
  ];
467
596
 
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
597
  //#endregion
517
598
  //#region ../core/src/rules/image-size.ts
518
- const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
519
- file,
520
- help,
521
- line,
522
- message,
523
- rule: ruleKey,
524
- severity
525
- });
526
599
  const preferSlimBase = {
527
600
  category: "Image Size",
528
601
  check(instructions, file) {
529
602
  const diagnostics = [];
530
603
  const stageAliases = collectStageAliases(instructions);
531
604
  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;
605
+ const imagePart = parseFromArgs(inst.args).base;
606
+ if (!imagePart || isScratch(imagePart)) continue;
534
607
  const ref = parseImageRef(imagePart);
535
608
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
536
609
  if (ref.digest) continue;
537
610
  if (!ref.tag) continue;
538
611
  const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
539
- 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));
612
+ if (!(haystack.includes("alpine") || haystack.includes("slim") || haystack.includes("distroless") || haystack.includes("busybox"))) diagnostics.push(createDiagnostic(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));
540
613
  }
541
614
  return diagnostics;
542
615
  },
543
616
  defaultSeverity: "info",
544
- help: "Prefer tags with '-slim', '-alpine', or use distroless base images to minimize the default operating system footprint.",
617
+ help: "Prefer tags with `-slim`, `-alpine`, or use distroless base images to minimize the default operating system footprint.",
545
618
  key: "docker-doctor/prefer-slim-base",
546
- message: "Use slim, alpine, or distroless base images"
619
+ message: "Prefer slim, alpine, or distroless base images"
547
620
  };
621
+ const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
622
+ const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
623
+ const cacheMountTargets = (args) => [...args.matchAll(BUILDKIT_MOUNT_FLAG_RE)].map((match) => (match.groups?.spec ?? "").split(",")).filter((options) => options.includes("type=cache")).flatMap((options) => options.filter((option) => CACHE_TARGET_KEY_RE.test(option)).map((option) => option.slice(option.indexOf("=") + 1)));
624
+ const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
625
+ const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
626
+ const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
548
627
  const cleanPackageCache = {
549
628
  category: "Image Size",
550
629
  check(instructions, file) {
551
630
  const diagnostics = [];
552
631
  for (const inst of instructions) if (inst.instruction === "RUN") {
553
632
  const { args } = inst;
554
- if (args.includes("apt-get install") && !args.includes("rm -rf /var/lib/apt/lists")) diagnostics.push(createDiagnostic$2(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));
555
- if (args.includes("apk add") && !args.includes("--no-cache") && !args.includes("rm -rf /var/cache/apk")) diagnostics.push(createDiagnostic$2(file, this.key, this.defaultSeverity, `Running 'apk add' without '--no-cache' or cleaning the apk cache. This increases layer size.`, this.help, inst.line));
633
+ 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));
634
+ 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));
556
635
  }
557
636
  return diagnostics;
558
637
  },
559
638
  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'.",
639
+ 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
640
  key: "docker-doctor/clean-package-cache",
562
641
  message: "Clean up package manager cache in the same RUN layer"
563
642
  };
643
+ 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
644
  const avoidDevDependencies = {
565
645
  category: "Image Size",
566
646
  check(instructions, file) {
567
647
  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));
648
+ const stages = [];
649
+ for (const inst of instructions) if (inst.instruction === "FROM") {
650
+ const { base, stage } = parseFromArgs(inst.args);
651
+ stages.push({
652
+ base: base?.toLowerCase() ?? "",
653
+ name: stage?.toLowerCase() ?? null,
654
+ runs: []
655
+ });
656
+ } else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
657
+ if (stages.length === 0) return diagnostics;
658
+ const auditedIndices = [];
659
+ let index = stages.length - 1;
660
+ while (index >= 0) {
661
+ auditedIndices.push(index);
662
+ const { base } = stages[index];
663
+ index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
664
+ }
665
+ const finalIndex = stages.length - 1;
666
+ for (const stageIndex of auditedIndices.toReversed()) {
667
+ const stage = stages[stageIndex];
668
+ for (const inst of stage.runs) {
669
+ if (!installsDevDependencies(inst.args)) continue;
670
+ const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
671
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
580
672
  }
581
673
  }
582
674
  return diagnostics;
583
675
  },
584
676
  defaultSeverity: "warning",
585
- help: "For Node.js, run 'npm prune --production' or install only production dependencies ('npm ci --omit=dev') in the runtime stage.",
677
+ help: "For Node.js, run `npm prune --production` or install only production dependencies (`npm ci --omit=dev`) in the runtime stage.",
586
678
  key: "docker-doctor/avoid-dev-dependencies",
587
- message: "Avoid installing development dependencies in final production stage"
679
+ message: "Avoid installing dev dependencies in the final stage"
588
680
  };
589
681
  const imageSizeRules = [
590
682
  preferSlimBase,
@@ -594,26 +686,18 @@ const imageSizeRules = [
594
686
 
595
687
  //#endregion
596
688
  //#region ../core/src/rules/performance.ts
597
- const createDiagnostic$1 = (file, ruleKey, severity, message, help, line) => ({
598
- file,
599
- help,
600
- line,
601
- message,
602
- rule: ruleKey,
603
- severity
604
- });
605
689
  const useMultiStage = {
606
690
  category: "Performance",
607
691
  check(instructions, file) {
608
692
  if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
609
- 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$1(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)];
693
+ 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)];
610
694
  }
611
695
  return [];
612
696
  },
613
697
  defaultSeverity: "info",
614
698
  help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
615
699
  key: "docker-doctor/use-multi-stage",
616
- message: "Consider using multi-stage builds"
700
+ message: "Use multi-stage builds"
617
701
  };
618
702
  const orderLayers = {
619
703
  category: "Performance",
@@ -630,7 +714,7 @@ const orderLayers = {
630
714
  }
631
715
  if (inst.instruction === "RUN" && copyAllLine !== -1) {
632
716
  const args = inst.args.toLowerCase();
633
- if (args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install") || args.includes("bun install") || args.includes("pip install") || args.includes("cargo fetch")) diagnostics.push(createDiagnostic$1(file, this.key, this.defaultSeverity, `Running package installation command '${inst.args}' after copying application files (at line ${copyAllLine}). This invalidates the cache on any code changes.`, this.help, inst.line));
717
+ if (args.includes("npm install") || args.includes("npm ci") || args.includes("yarn install") || args.includes("bun install") || args.includes("pip install") || args.includes("cargo fetch")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package installation command '${inst.args}' after copying application files (at line ${copyAllLine}). This invalidates the cache on any code changes.`, this.help, inst.line));
634
718
  }
635
719
  }
636
720
  return diagnostics;
@@ -650,14 +734,14 @@ const minimizeLayers = {
650
734
  if (consecutiveRunCount === 0) firstRunLine = inst.line;
651
735
  consecutiveRunCount += 1;
652
736
  } else {
653
- if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic$1(file, this.key, this.defaultSeverity, `Found ${consecutiveRunCount} consecutive RUN instructions starting at line ${firstRunLine}. Consider combining them into a single RUN layer.`, this.help, firstRunLine));
737
+ if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Found ${consecutiveRunCount} consecutive RUN instructions starting at line ${firstRunLine}. Consider combining them into a single RUN layer.`, this.help, firstRunLine));
654
738
  consecutiveRunCount = 0;
655
739
  }
656
- if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic$1(file, this.key, this.defaultSeverity, `Found ${consecutiveRunCount} consecutive RUN instructions starting at line ${firstRunLine}. Consider combining them into a single RUN layer.`, this.help, firstRunLine));
740
+ if (consecutiveRunCount > 2) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Found ${consecutiveRunCount} consecutive RUN instructions starting at line ${firstRunLine}. Consider combining them into a single RUN layer.`, this.help, firstRunLine));
657
741
  return diagnostics;
658
742
  },
659
743
  defaultSeverity: "info",
660
- help: "Combine consecutive RUN instructions using '&&' and '\\' to reduce the total layer count and image size.",
744
+ help: "Combine consecutive RUN instructions using `&&` and `\\` to reduce the total layer count and image size.",
661
745
  key: "docker-doctor/minimize-layers",
662
746
  message: "Minimize the number of image layers"
663
747
  };
@@ -678,13 +762,13 @@ const useDockerignore = {
678
762
  return src === "." || src === "./" || src === "*";
679
763
  }
680
764
  return false;
681
- }) && 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)];
765
+ }) && context?.projectFiles && !hasDockerignoreFor(file, context.projectFiles)) return [createDiagnostic(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)];
682
766
  return [];
683
767
  },
684
768
  defaultSeverity: "warning",
685
769
  help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
686
770
  key: "docker-doctor/use-dockerignore",
687
- message: "Ensure .dockerignore is used"
771
+ message: "Add a .dockerignore file"
688
772
  };
689
773
  const performanceRules = [
690
774
  useMultiStage,
@@ -695,14 +779,6 @@ const performanceRules = [
695
779
 
696
780
  //#endregion
697
781
  //#region ../core/src/rules/security.ts
698
- const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
699
- file,
700
- help,
701
- line,
702
- message,
703
- rule: ruleKey,
704
- severity
705
- });
706
782
  const isRootUser = (value) => {
707
783
  const [user] = value.split(":");
708
784
  return user === "root" || user === "0";
@@ -710,22 +786,28 @@ const isRootUser = (value) => {
710
786
  const noRootUser = {
711
787
  category: "Security",
712
788
  check(instructions, file) {
789
+ const stageUser = /* @__PURE__ */ new Map();
790
+ let currentStage = null;
713
791
  let lastUser = "root";
714
792
  let lastUserLine = 1;
715
793
  for (const inst of instructions) if (inst.instruction === "FROM") {
716
- lastUser = "root";
794
+ const { base, stage } = parseFromArgs(inst.args);
795
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
717
796
  lastUserLine = inst.line;
797
+ currentStage = stage?.toLowerCase() ?? null;
798
+ if (currentStage) stageUser.set(currentStage, lastUser);
718
799
  } else if (inst.instruction === "USER") {
719
800
  lastUser = inst.args.trim().toLowerCase();
720
801
  lastUserLine = inst.line;
802
+ if (currentStage) stageUser.set(currentStage, lastUser);
721
803
  }
722
804
  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
805
  return [];
724
806
  },
725
807
  defaultSeverity: "warning",
726
- help: "Add a non-root user (e.g., 'USER node' or 'USER 1000') to improve security.",
808
+ help: "Add a non-root user (e.g., `USER node` or `USER 1000`) to improve security.",
727
809
  key: "docker-doctor/no-root-user",
728
- message: "Container should not run as root user"
810
+ message: "Run the container as a non-root user"
729
811
  };
730
812
  const noSecretsInEnv = {
731
813
  category: "Security",
@@ -766,7 +848,7 @@ const noSecretsInEnv = {
766
848
  defaultSeverity: "error",
767
849
  help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
768
850
  key: "docker-doctor/no-secrets-in-env",
769
- message: "Do not store secrets in ENV or ARG instructions"
851
+ message: "Avoid storing secrets in ENV or ARG instructions"
770
852
  };
771
853
  const pinImageVersion = {
772
854
  category: "Security",
@@ -774,8 +856,8 @@ const pinImageVersion = {
774
856
  const diagnostics = [];
775
857
  const stageAliases = collectStageAliases(instructions);
776
858
  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;
859
+ const imagePart = parseFromArgs(inst.args).base;
860
+ if (!imagePart || isScratch(imagePart)) continue;
779
861
  const ref = parseImageRef(imagePart);
780
862
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
781
863
  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 +866,9 @@ const pinImageVersion = {
784
866
  return diagnostics;
785
867
  },
786
868
  defaultSeverity: "warning",
787
- help: "Specify a concrete tag instead of 'latest' or no tag (e.g., 'node:22.2.0-alpine' instead of 'node').",
869
+ help: "Specify a concrete tag instead of `latest` or no tag (e.g., `node:22.2.0-alpine` instead of `node`).",
788
870
  key: "docker-doctor/pin-image-version",
789
- message: "Always pin base image versions to specific tags"
871
+ message: "Pin base images to a specific tag or digest"
790
872
  };
791
873
  const noAddRemote = {
792
874
  category: "Security",
@@ -800,7 +882,7 @@ const noAddRemote = {
800
882
  return diagnostics;
801
883
  },
802
884
  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.",
885
+ 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
886
  key: "docker-doctor/no-add-remote",
805
887
  message: "Avoid using ADD with remote URLs"
806
888
  };
@@ -843,12 +925,12 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categ
843
925
 
844
926
  //#endregion
845
927
  //#region ../core/src/runners/compose-runner.ts
846
- const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
928
+ const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, locate) => {
847
929
  const diagnostics = [];
848
930
  for (const rule of allComposeRules) {
849
931
  const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
850
932
  if (severity === "off") continue;
851
- const ruleDiagnostics = rule.check(composeContent, file);
933
+ const ruleDiagnostics = rule.check(composeContent, file, { locate });
852
934
  if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
853
935
  diagnostics.push(...ruleDiagnostics);
854
936
  }
@@ -917,6 +999,30 @@ const validateConfig = (input) => {
917
999
  return result;
918
1000
  };
919
1001
 
1002
+ //#endregion
1003
+ //#region ../core/src/config/unknown-keys.ts
1004
+ const KNOWN_CATEGORIES = [
1005
+ "Best Practices",
1006
+ "Compose",
1007
+ "Image Size",
1008
+ "Performance",
1009
+ "Security"
1010
+ ];
1011
+ const keysOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [];
1012
+ const collectUnknownConfigKeys = (raw) => {
1013
+ if (typeof raw !== "object" || raw === null) return {
1014
+ categories: [],
1015
+ rules: []
1016
+ };
1017
+ const knownRuleKeys = new Set(allRules.map((rule) => rule.key));
1018
+ const knownCategories = new Set(KNOWN_CATEGORIES);
1019
+ const { categories, rules } = raw;
1020
+ return {
1021
+ categories: keysOf(categories).filter((key) => !knownCategories.has(key)),
1022
+ rules: keysOf(rules).filter((key) => !knownRuleKeys.has(key))
1023
+ };
1024
+ };
1025
+
920
1026
  //#endregion
921
1027
  //#region ../core/src/config/loader.ts
922
1028
  const fileExists = async (filePath) => {
@@ -946,7 +1052,12 @@ const importConfig = async (filePath) => {
946
1052
  throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
947
1053
  }
948
1054
  };
949
- const loadConfig = async (rootDir, customPath) => {
1055
+ const warnUnknownKeys = (raw, onWarning) => {
1056
+ const unknown = collectUnknownConfigKeys(raw);
1057
+ for (const key of unknown.rules) onWarning(`Unknown rule "${key}" in config — it matches no rule and has no effect.`);
1058
+ for (const key of unknown.categories) onWarning(`Unknown category "${key}" in config — categories are case-sensitive (e.g. "Best Practices", "Security").`);
1059
+ };
1060
+ const loadConfig = async (rootDir, customPath, onWarning) => {
950
1061
  let configObject = null;
951
1062
  if (customPath) {
952
1063
  const fullPath = node_path.default.resolve(rootDir, customPath);
@@ -979,7 +1090,9 @@ const loadConfig = async (rootDir, customPath) => {
979
1090
  }
980
1091
  if (!configObject) return {};
981
1092
  try {
982
- return validateConfig(configObject);
1093
+ const config = validateConfig(configObject);
1094
+ if (onWarning) warnUnknownKeys(configObject, onWarning);
1095
+ return config;
983
1096
  } catch (error) {
984
1097
  const msg = error instanceof Error ? error.message : String(error);
985
1098
  throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
@@ -1066,6 +1179,12 @@ Object.defineProperty(exports, 'calculateScore', {
1066
1179
  return calculateScore;
1067
1180
  }
1068
1181
  });
1182
+ Object.defineProperty(exports, 'createComposeLocator', {
1183
+ enumerable: true,
1184
+ get: function () {
1185
+ return createComposeLocator;
1186
+ }
1187
+ });
1069
1188
  Object.defineProperty(exports, 'discoverProject', {
1070
1189
  enumerable: true,
1071
1190
  get: function () {
@@ -1120,4 +1239,4 @@ Object.defineProperty(exports, 'version', {
1120
1239
  return version;
1121
1240
  }
1122
1241
  });
1123
- //# sourceMappingURL=src-909bBBPN.cjs.map
1242
+ //# sourceMappingURL=src-DwuAaQcq.cjs.map