@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.
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { parse } from "yaml";
4
+ import { LineCounter, isAlias, isMap, isScalar, isSeq, parse, parseDocument } from "yaml";
5
5
 
6
6
  //#region package.json
7
- var version = "0.4.2";
7
+ var version = "0.4.4";
8
8
 
9
9
  //#endregion
10
10
  //#region ../core/src/project-info/discover.ts
@@ -107,7 +107,6 @@ const processHeredocLine = (state, trimmed) => {
107
107
  };
108
108
  const processInstructionLine = (state, trimmed, lineNum) => {
109
109
  let lineContent = trimmed;
110
- if (lineContent.startsWith("#")) return;
111
110
  const hasContinuation = lineContent.endsWith("\\");
112
111
  if (hasContinuation) lineContent = lineContent.slice(0, -1).trim();
113
112
  if (state.currentInstruction) state.currentArgs += (state.currentArgs ? " " : "") + lineContent;
@@ -130,7 +129,8 @@ const parseDockerfile = (content) => {
130
129
  const trimmed = rawLine.trim();
131
130
  const lineNum = i + 1;
132
131
  const insideHeredoc = state.heredocQueue.length > 0;
133
- if (!state.currentInstruction && !insideHeredoc && (trimmed === "" || trimmed.startsWith("#"))) continue;
132
+ if (!insideHeredoc && trimmed.startsWith("#")) continue;
133
+ if (!state.currentInstruction && !insideHeredoc && trimmed === "") continue;
134
134
  state.rawAccumulator.push(rawLine);
135
135
  if (insideHeredoc) processHeredocLine(state, trimmed);
136
136
  else processInstructionLine(state, trimmed, lineNum);
@@ -173,10 +173,117 @@ const parseCompose = (content, filepath) => {
173
173
  });
174
174
  }
175
175
  };
176
+ /**
177
+ * Builds a {@link ComposeLocator} over the same source text a compose object
178
+ * was parsed from, so rules can attach line numbers to their diagnostics.
179
+ *
180
+ * Keys pulled in via YAML merge keys (`<<: *anchor`) have no concrete node
181
+ * at the merge site, so paths through them resolve to `undefined` — callers
182
+ * fall back to an unnumbered diagnostic, which matches the old behavior.
183
+ */
184
+ const createComposeLocator = (content) => {
185
+ const lineCounter = new LineCounter();
186
+ const doc = parseDocument(content, {
187
+ lineCounter,
188
+ merge: true
189
+ });
190
+ return (path) => {
191
+ let node = doc.contents;
192
+ let offset;
193
+ for (const segment of path) {
194
+ if (isAlias(node)) node = node.resolve(doc);
195
+ if (isMap(node)) {
196
+ const pair = node.items.find((item) => isScalar(item.key) && String(item.key.value) === String(segment));
197
+ if (!pair || !isScalar(pair.key)) return;
198
+ offset = pair.key.range?.[0];
199
+ node = pair.value;
200
+ } else if (isSeq(node) && typeof segment === "number") {
201
+ const item = node.items[segment];
202
+ if (item === void 0 || item === null) return;
203
+ offset = item.range?.[0];
204
+ node = item;
205
+ } else return;
206
+ }
207
+ return offset === void 0 ? void 0 : lineCounter.linePos(offset).line;
208
+ };
209
+ };
176
210
 
177
211
  //#endregion
178
- //#region ../core/src/rules/best-practices.ts
179
- const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
212
+ //#region ../core/src/parsers/exec-form.ts
213
+ const parseExecForm = (args) => {
214
+ const trimmed = args.trim();
215
+ if (!trimmed.startsWith("[")) return null;
216
+ let parsed;
217
+ try {
218
+ parsed = JSON.parse(trimmed);
219
+ } catch {
220
+ return null;
221
+ }
222
+ if (!Array.isArray(parsed)) return null;
223
+ if (!parsed.every((el) => typeof el === "string")) return null;
224
+ return parsed;
225
+ };
226
+
227
+ //#endregion
228
+ //#region ../core/src/parsers/image-ref.ts
229
+ const parseImageRef = (ref) => {
230
+ if (ref.includes("${") || ref.startsWith("$")) return {
231
+ isVariable: true,
232
+ name: ref
233
+ };
234
+ let remainder = ref;
235
+ let digest;
236
+ const atIndex = remainder.indexOf("@");
237
+ if (atIndex !== -1) {
238
+ digest = remainder.slice(atIndex + 1);
239
+ remainder = remainder.slice(0, atIndex);
240
+ }
241
+ let tag;
242
+ const lastColonIndex = remainder.lastIndexOf(":");
243
+ const lastSlashIndex = remainder.lastIndexOf("/");
244
+ if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
245
+ tag = remainder.slice(lastColonIndex + 1);
246
+ remainder = remainder.slice(0, lastColonIndex);
247
+ }
248
+ let registry;
249
+ const firstSlashIndex = remainder.indexOf("/");
250
+ if (firstSlashIndex !== -1) {
251
+ const firstSegment = remainder.slice(0, firstSlashIndex);
252
+ if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
253
+ registry = firstSegment;
254
+ remainder = remainder.slice(firstSlashIndex + 1);
255
+ }
256
+ }
257
+ return {
258
+ digest,
259
+ isVariable: false,
260
+ name: remainder,
261
+ registry,
262
+ tag
263
+ };
264
+ };
265
+ const parseFromArgs = (args) => {
266
+ const parts = args.split(/\s+/u).filter(Boolean);
267
+ const asIndex = parts.findIndex((p) => p.toLowerCase() === "as");
268
+ return {
269
+ base: (asIndex === -1 ? parts : parts.slice(0, asIndex)).find((p) => !p.startsWith("--")) ?? null,
270
+ stage: asIndex === -1 ? null : parts[asIndex + 1] ?? null
271
+ };
272
+ };
273
+ const isScratch = (base) => base?.toLowerCase() === "scratch";
274
+ const collectStageAliases = (instructions) => {
275
+ const aliases = /* @__PURE__ */ new Set();
276
+ for (const inst of instructions) {
277
+ if (inst.instruction !== "FROM") continue;
278
+ const { stage } = parseFromArgs(inst.args);
279
+ if (stage) aliases.add(stage.toLowerCase());
280
+ }
281
+ return aliases;
282
+ };
283
+
284
+ //#endregion
285
+ //#region ../core/src/rules/create-diagnostic.ts
286
+ const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
180
287
  file,
181
288
  help,
182
289
  line,
@@ -184,16 +291,19 @@ const createDiagnostic$4 = (file, ruleKey, severity, message, help, line) => ({
184
291
  rule: ruleKey,
185
292
  severity
186
293
  });
294
+
295
+ //#endregion
296
+ //#region ../core/src/rules/best-practices.ts
187
297
  const requireHealthcheck = {
188
298
  category: "Best Practices",
189
299
  check(instructions, file) {
190
300
  const hasHealthcheck = instructions.some((inst) => inst.instruction === "HEALTHCHECK");
191
301
  const hasExposedPortsOrEntry = instructions.some((inst) => inst.instruction === "EXPOSE" || inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT");
192
- 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)];
302
+ 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)];
193
303
  return [];
194
304
  },
195
305
  defaultSeverity: "info",
196
- 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.",
306
+ 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.",
197
307
  key: "docker-doctor/require-healthcheck",
198
308
  message: "Add a HEALTHCHECK instruction"
199
309
  };
@@ -206,7 +316,7 @@ const preferCopyOverAdd = {
206
316
  if (!src) continue;
207
317
  const isRemote = src.startsWith("http://") || src.startsWith("https://");
208
318
  const isArchive = src.endsWith(".tar") || src.endsWith(".tar.gz") || src.endsWith(".tgz") || src.endsWith(".zip");
209
- 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));
319
+ 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));
210
320
  }
211
321
  return diagnostics;
212
322
  },
@@ -219,25 +329,22 @@ const useExecForm = {
219
329
  category: "Best Practices",
220
330
  check(instructions, file) {
221
331
  const diagnostics = [];
222
- for (const inst of instructions) if (inst.instruction === "CMD" || inst.instruction === "ENTRYPOINT") {
223
- const args = inst.args.trim();
224
- 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));
225
- }
332
+ 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));
226
333
  return diagnostics;
227
334
  },
228
335
  defaultSeverity: "warning",
229
- help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. ENTRYPOINT [\"node\", \"index.js\"]) so OS signals (like SIGTERM) are forwarded correctly.",
336
+ help: "Write CMD/ENTRYPOINT instructions as JSON arrays (e.g. `ENTRYPOINT [\"node\", \"index.js\"]`) so OS signals (like SIGTERM) are forwarded correctly.",
230
337
  key: "docker-doctor/use-exec-form",
231
338
  message: "Use exec form for CMD and ENTRYPOINT"
232
339
  };
233
340
  const requireLabels = {
234
341
  category: "Best Practices",
235
342
  check(instructions, file) {
236
- 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)];
343
+ 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)];
237
344
  return [];
238
345
  },
239
346
  defaultSeverity: "info",
240
- help: "Use LABEL instructions (e.g. LABEL org.opencontainers.image.authors=\"...\") to document ownership, license, version, and build info.",
347
+ help: "Use LABEL instructions (e.g. `LABEL org.opencontainers.image.authors=\"...\"`) to document ownership, license, version, and build info.",
241
348
  key: "docker-doctor/require-labels",
242
349
  message: "Add LABEL metadata to images"
243
350
  };
@@ -248,28 +355,53 @@ const combineAptUpdateInstall = {
248
355
  for (const inst of instructions) if (inst.instruction === "RUN") {
249
356
  const hasUpdate = inst.args.includes("apt-get update");
250
357
  const hasInstall = inst.args.includes("apt-get install");
251
- 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));
252
- 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));
358
+ 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));
359
+ 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));
253
360
  }
254
361
  return diagnostics;
255
362
  },
256
363
  defaultSeverity: "warning",
257
- 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/*').",
364
+ 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/*`).",
258
365
  key: "docker-doctor/combine-apt-update-install",
259
366
  message: "Combine apt-get update and apt-get install"
260
367
  };
368
+ const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
369
+ const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
370
+ const shellDirectiveEnablesPipefail = (args) => {
371
+ const argv = parseExecForm(args);
372
+ return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
373
+ };
261
374
  const usePipefail = {
262
375
  category: "Best Practices",
263
376
  check(instructions, file) {
264
377
  const diagnostics = [];
265
- for (const inst of instructions) if (inst.instruction === "RUN") {
266
- const { raw } = inst;
267
- 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));
378
+ const stagePipefail = /* @__PURE__ */ new Map();
379
+ let shellHasPipefail = false;
380
+ let currentStage = null;
381
+ for (const inst of instructions) {
382
+ if (inst.instruction === "FROM") {
383
+ const { base, stage } = parseFromArgs(inst.args);
384
+ const parentStage = isScratch(base) ? null : base?.toLowerCase() ?? null;
385
+ shellHasPipefail = parentStage !== null && stagePipefail.get(parentStage) === true;
386
+ currentStage = stage?.toLowerCase() ?? null;
387
+ if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
388
+ continue;
389
+ }
390
+ if (inst.instruction === "SHELL") {
391
+ shellHasPipefail = shellDirectiveEnablesPipefail(inst.args);
392
+ if (currentStage) stagePipefail.set(currentStage, shellHasPipefail);
393
+ continue;
394
+ }
395
+ if (inst.instruction !== "RUN") continue;
396
+ const { args } = inst;
397
+ if (!HAS_PIPE_RE.test(args)) continue;
398
+ const execArgv = parseExecForm(args);
399
+ 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));
268
400
  }
269
401
  return diagnostics;
270
402
  },
271
403
  defaultSeverity: "warning",
272
- 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 && ...']).",
404
+ 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.",
273
405
  key: "docker-doctor/use-pipefail",
274
406
  message: "Use pipefail to catch pipeline command failures"
275
407
  };
@@ -279,12 +411,12 @@ const absoluteWorkdir = {
279
411
  const diagnostics = [];
280
412
  for (const inst of instructions) if (inst.instruction === "WORKDIR") {
281
413
  const path = inst.args.trim();
282
- 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));
414
+ 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));
283
415
  }
284
416
  return diagnostics;
285
417
  },
286
418
  defaultSeverity: "warning",
287
- help: "Always specify absolute paths for WORKDIR instructions (e.g. WORKDIR /app).",
419
+ help: "Always specify absolute paths for WORKDIR instructions (e.g. `WORKDIR /app`).",
288
420
  key: "docker-doctor/absolute-workdir",
289
421
  message: "Use absolute paths for WORKDIR"
290
422
  };
@@ -292,11 +424,11 @@ const avoidRunCd = {
292
424
  category: "Best Practices",
293
425
  check(instructions, file) {
294
426
  const diagnostics = [];
295
- 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));
427
+ 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));
296
428
  return diagnostics;
297
429
  },
298
430
  defaultSeverity: "info",
299
- help: "Use the WORKDIR instruction instead of 'cd' inside RUN to establish directory context.",
431
+ help: "Use the WORKDIR instruction instead of `cd` inside RUN to establish directory context.",
300
432
  key: "docker-doctor/avoid-run-cd",
301
433
  message: "Avoid changing directories with cd in RUN"
302
434
  };
@@ -312,7 +444,7 @@ const sortMultilineArgs = {
312
444
  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);
313
445
  if (packages.length > 1) {
314
446
  const sorted = packages.toSorted((a, b) => a.localeCompare(b));
315
- 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));
447
+ 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));
316
448
  }
317
449
  }
318
450
  }
@@ -327,11 +459,11 @@ const useraddNoLogInit = {
327
459
  category: "Best Practices",
328
460
  check(instructions, file) {
329
461
  const diagnostics = [];
330
- 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));
462
+ 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));
331
463
  return diagnostics;
332
464
  },
333
465
  defaultSeverity: "warning",
334
- help: "Pass '--no-log-init' flag to useradd (e.g., 'RUN useradd --no-log-init -r -g mygroup myuser').",
466
+ help: "Pass `--no-log-init` flag to useradd (e.g., `RUN useradd --no-log-init -r -g mygroup myuser`).",
335
467
  key: "docker-doctor/useradd-no-log-init",
336
468
  message: "Use --no-log-init with useradd"
337
469
  };
@@ -350,47 +482,40 @@ const bestPracticesRules = [
350
482
 
351
483
  //#endregion
352
484
  //#region ../core/src/rules/compose.ts
353
- const createDiagnostic$3 = (file, ruleKey, severity, message, help) => ({
354
- file,
355
- help,
356
- message,
357
- rule: ruleKey,
358
- severity
359
- });
360
485
  const noVersionKey = {
361
486
  category: "Compose",
362
- check(composeContent, file) {
363
- 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)];
487
+ check(composeContent, file, context) {
488
+ 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"]))];
364
489
  return [];
365
490
  },
366
491
  defaultSeverity: "warning",
367
- help: "The 'version' key is deprecated by the Compose specification. Omitting it defaults to the latest specification.",
492
+ help: "The `version` key is obsolete in the Compose specification. Omitting it defaults to the latest specification.",
368
493
  key: "docker-doctor/no-version-key",
369
- message: "Remove the 'version' key from Compose file"
494
+ message: "Remove the `version` key from the Compose file"
370
495
  };
371
496
  const requireResourceLimits = {
372
497
  category: "Compose",
373
- check(composeContent, file) {
498
+ check(composeContent, file, context) {
374
499
  const diagnostics = [];
375
500
  if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
376
501
  const { services } = composeContent;
377
502
  if (services && typeof services === "object") {
378
503
  for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
379
504
  const limits = (config.deploy?.resources)?.limits;
380
- 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));
505
+ 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])));
381
506
  }
382
507
  }
383
508
  }
384
509
  return diagnostics;
385
510
  },
386
511
  defaultSeverity: "warning",
387
- help: "Add resource limits (e.g. deploy.resources.limits) to prevent a single service from starving host resources in production.",
512
+ help: "Add resource limits (e.g. `deploy.resources.limits`) to prevent a single service from starving host resources in production.",
388
513
  key: "docker-doctor/require-resource-limits",
389
514
  message: "Define resource limits for services"
390
515
  };
391
516
  const requireRestartPolicy = {
392
517
  category: "Compose",
393
- check(composeContent, file) {
518
+ check(composeContent, file, context) {
394
519
  const diagnostics = [];
395
520
  if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
396
521
  const { services } = composeContent;
@@ -398,34 +523,38 @@ const requireRestartPolicy = {
398
523
  for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
399
524
  const hasRestart = "restart" in config;
400
525
  const hasDeployRestart = config.deploy?.restart_policy !== void 0;
401
- 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));
526
+ 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])));
402
527
  }
403
528
  }
404
529
  }
405
530
  return diagnostics;
406
531
  },
407
532
  defaultSeverity: "warning",
408
- help: "Define 'restart: always' or 'restart: unless-stopped' (or deploy.restart_policy) so services restart on crashes or host reboot.",
533
+ help: "Define `restart: always` or `restart: unless-stopped` (or `deploy.restart_policy`) so services restart on crashes or host reboot.",
409
534
  key: "docker-doctor/require-restart-policy",
410
535
  message: "Set restart policy for services"
411
536
  };
412
537
  const useDependsOnCondition = {
413
538
  category: "Compose",
414
- check(composeContent, file) {
539
+ check(composeContent, file, context) {
415
540
  const diagnostics = [];
416
541
  if (composeContent && typeof composeContent === "object" && "services" in composeContent) {
417
542
  const { services } = composeContent;
418
543
  if (services && typeof services === "object") {
419
544
  for (const [name, config] of Object.entries(services)) if (config && typeof config === "object") {
420
545
  const dependsOn = config.depends_on;
421
- 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));
546
+ 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?.([
547
+ "services",
548
+ name,
549
+ "depends_on"
550
+ ]) ?? context?.locate?.(["services", name])));
422
551
  }
423
552
  }
424
553
  }
425
554
  return diagnostics;
426
555
  },
427
556
  defaultSeverity: "info",
428
- help: "Instead of a simple service list, use 'depends_on: { dependency: { condition: service_healthy } }' to ensure dependencies are fully ready before starting.",
557
+ help: "Instead of a simple service list, use `depends_on: { dependency: { condition: service_healthy } }` to ensure dependencies are fully ready before starting.",
429
558
  key: "docker-doctor/use-depends-on-condition",
430
559
  message: "Use long-form depends_on with healthcheck conditions"
431
560
  };
@@ -436,126 +565,89 @@ const composeRules = [
436
565
  useDependsOnCondition
437
566
  ];
438
567
 
439
- //#endregion
440
- //#region ../core/src/parsers/image-ref.ts
441
- const parseImageRef = (ref) => {
442
- if (ref.includes("${") || ref.startsWith("$")) return {
443
- isVariable: true,
444
- name: ref
445
- };
446
- let remainder = ref;
447
- let digest;
448
- const atIndex = remainder.indexOf("@");
449
- if (atIndex !== -1) {
450
- digest = remainder.slice(atIndex + 1);
451
- remainder = remainder.slice(0, atIndex);
452
- }
453
- let tag;
454
- const lastColonIndex = remainder.lastIndexOf(":");
455
- const lastSlashIndex = remainder.lastIndexOf("/");
456
- if (lastColonIndex !== -1 && lastColonIndex > lastSlashIndex) {
457
- tag = remainder.slice(lastColonIndex + 1);
458
- remainder = remainder.slice(0, lastColonIndex);
459
- }
460
- let registry;
461
- const firstSlashIndex = remainder.indexOf("/");
462
- if (firstSlashIndex !== -1) {
463
- const firstSegment = remainder.slice(0, firstSlashIndex);
464
- if (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost") {
465
- registry = firstSegment;
466
- remainder = remainder.slice(firstSlashIndex + 1);
467
- }
468
- }
469
- return {
470
- digest,
471
- isVariable: false,
472
- name: remainder,
473
- registry,
474
- tag
475
- };
476
- };
477
- const collectStageAliases = (instructions) => {
478
- const aliases = /* @__PURE__ */ new Set();
479
- for (const inst of instructions) {
480
- if (inst.instruction !== "FROM") continue;
481
- const match = /\sas\s+(?<alias>\S+)/iu.exec(inst.args);
482
- if (match?.groups?.alias) aliases.add(match.groups.alias.toLowerCase());
483
- }
484
- return aliases;
485
- };
486
-
487
568
  //#endregion
488
569
  //#region ../core/src/rules/image-size.ts
489
- const createDiagnostic$2 = (file, ruleKey, severity, message, help, line) => ({
490
- file,
491
- help,
492
- line,
493
- message,
494
- rule: ruleKey,
495
- severity
496
- });
497
570
  const preferSlimBase = {
498
571
  category: "Image Size",
499
572
  check(instructions, file) {
500
573
  const diagnostics = [];
501
574
  const stageAliases = collectStageAliases(instructions);
502
575
  for (const inst of instructions) if (inst.instruction === "FROM") {
503
- const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
504
- if (!imagePart || imagePart === "scratch") continue;
576
+ const imagePart = parseFromArgs(inst.args).base;
577
+ if (!imagePart || isScratch(imagePart)) continue;
505
578
  const ref = parseImageRef(imagePart);
506
579
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
507
580
  if (ref.digest) continue;
508
581
  if (!ref.tag) continue;
509
582
  const haystack = `${ref.name} ${ref.tag}`.toLowerCase();
510
- 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));
583
+ 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));
511
584
  }
512
585
  return diagnostics;
513
586
  },
514
587
  defaultSeverity: "info",
515
- help: "Prefer tags with '-slim', '-alpine', or use distroless base images to minimize the default operating system footprint.",
588
+ help: "Prefer tags with `-slim`, `-alpine`, or use distroless base images to minimize the default operating system footprint.",
516
589
  key: "docker-doctor/prefer-slim-base",
517
- message: "Use slim, alpine, or distroless base images"
590
+ message: "Prefer slim, alpine, or distroless base images"
518
591
  };
592
+ const BUILDKIT_MOUNT_FLAG_RE = /--mount=(?<spec>\S+)/gu;
593
+ const CACHE_TARGET_KEY_RE = /^(?:target|dst|destination)=/u;
594
+ 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)));
595
+ const APT_CACHE_DIRS = ["/var/lib/apt", "/var/cache/apt"];
596
+ const APK_CACHE_DIRS = ["/var/cache/apk", "/etc/apk/cache"];
597
+ const hasCacheMountFor = (args, cacheDirs) => cacheMountTargets(args).some((target) => cacheDirs.some((dir) => target === dir || target.startsWith(`${dir}/`)));
519
598
  const cleanPackageCache = {
520
599
  category: "Image Size",
521
600
  check(instructions, file) {
522
601
  const diagnostics = [];
523
602
  for (const inst of instructions) if (inst.instruction === "RUN") {
524
603
  const { args } = inst;
525
- 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));
526
- 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));
604
+ 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));
605
+ 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));
527
606
  }
528
607
  return diagnostics;
529
608
  },
530
609
  defaultSeverity: "warning",
531
- 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'.",
610
+ 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`.",
532
611
  key: "docker-doctor/clean-package-cache",
533
612
  message: "Clean up package manager cache in the same RUN layer"
534
613
  };
614
+ 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");
535
615
  const avoidDevDependencies = {
536
616
  category: "Image Size",
537
617
  check(instructions, file) {
538
618
  const diagnostics = [];
539
- let isLastStage = false;
540
- let fromCount = 0;
541
- for (const inst of instructions) if (inst.instruction === "FROM") fromCount += 1;
542
- let currentStage = 0;
543
- for (const inst of instructions) {
544
- if (inst.instruction === "FROM") {
545
- currentStage += 1;
546
- isLastStage = currentStage === fromCount;
547
- }
548
- if (isLastStage && inst.instruction === "RUN") {
549
- const { args } = inst;
550
- 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));
619
+ const stages = [];
620
+ for (const inst of instructions) if (inst.instruction === "FROM") {
621
+ const { base, stage } = parseFromArgs(inst.args);
622
+ stages.push({
623
+ base: base?.toLowerCase() ?? "",
624
+ name: stage?.toLowerCase() ?? null,
625
+ runs: []
626
+ });
627
+ } else if (inst.instruction === "RUN" && stages.length > 0) stages.at(-1)?.runs.push(inst);
628
+ if (stages.length === 0) return diagnostics;
629
+ const auditedIndices = [];
630
+ let index = stages.length - 1;
631
+ while (index >= 0) {
632
+ auditedIndices.push(index);
633
+ const { base } = stages[index];
634
+ index = stages.slice(0, index).findIndex((s) => s.name !== null && s.name === base);
635
+ }
636
+ const finalIndex = stages.length - 1;
637
+ for (const stageIndex of auditedIndices.toReversed()) {
638
+ const stage = stages[stageIndex];
639
+ for (const inst of stage.runs) {
640
+ if (!installsDevDependencies(inst.args)) continue;
641
+ const where = stageIndex === finalIndex ? "in the final stage" : `in stage '${stage.name}', whose layers the final stage inherits,`;
642
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Running package install '${inst.args}' ${where} without omitting devDependencies.`, this.help, inst.line));
551
643
  }
552
644
  }
553
645
  return diagnostics;
554
646
  },
555
647
  defaultSeverity: "warning",
556
- help: "For Node.js, run 'npm prune --production' or install only production dependencies ('npm ci --omit=dev') in the runtime stage.",
648
+ help: "For Node.js, run `npm prune --production` or install only production dependencies (`npm ci --omit=dev`) in the runtime stage.",
557
649
  key: "docker-doctor/avoid-dev-dependencies",
558
- message: "Avoid installing development dependencies in final production stage"
650
+ message: "Avoid installing dev dependencies in the final stage"
559
651
  };
560
652
  const imageSizeRules = [
561
653
  preferSlimBase,
@@ -565,26 +657,18 @@ const imageSizeRules = [
565
657
 
566
658
  //#endregion
567
659
  //#region ../core/src/rules/performance.ts
568
- const createDiagnostic$1 = (file, ruleKey, severity, message, help, line) => ({
569
- file,
570
- help,
571
- line,
572
- message,
573
- rule: ruleKey,
574
- severity
575
- });
576
660
  const useMultiStage = {
577
661
  category: "Performance",
578
662
  check(instructions, file) {
579
663
  if (instructions.filter((inst) => inst.instruction === "FROM").length === 1) {
580
- 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)];
664
+ 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)];
581
665
  }
582
666
  return [];
583
667
  },
584
668
  defaultSeverity: "info",
585
669
  help: "Use multi-stage builds (multiple FROM statements) to separate build dependencies from the runtime image and reduce size.",
586
670
  key: "docker-doctor/use-multi-stage",
587
- message: "Consider using multi-stage builds"
671
+ message: "Use multi-stage builds"
588
672
  };
589
673
  const orderLayers = {
590
674
  category: "Performance",
@@ -601,7 +685,7 @@ const orderLayers = {
601
685
  }
602
686
  if (inst.instruction === "RUN" && copyAllLine !== -1) {
603
687
  const args = inst.args.toLowerCase();
604
- 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));
688
+ 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));
605
689
  }
606
690
  }
607
691
  return diagnostics;
@@ -621,14 +705,14 @@ const minimizeLayers = {
621
705
  if (consecutiveRunCount === 0) firstRunLine = inst.line;
622
706
  consecutiveRunCount += 1;
623
707
  } else {
624
- 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));
708
+ 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));
625
709
  consecutiveRunCount = 0;
626
710
  }
627
- 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));
711
+ 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));
628
712
  return diagnostics;
629
713
  },
630
714
  defaultSeverity: "info",
631
- help: "Combine consecutive RUN instructions using '&&' and '\\' to reduce the total layer count and image size.",
715
+ help: "Combine consecutive RUN instructions using `&&` and `\\` to reduce the total layer count and image size.",
632
716
  key: "docker-doctor/minimize-layers",
633
717
  message: "Minimize the number of image layers"
634
718
  };
@@ -649,13 +733,13 @@ const useDockerignore = {
649
733
  return src === "." || src === "./" || src === "*";
650
734
  }
651
735
  return false;
652
- }) && 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)];
736
+ }) && 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)];
653
737
  return [];
654
738
  },
655
739
  defaultSeverity: "warning",
656
740
  help: "Create a .dockerignore file in the same directory as the Dockerfile to prevent copying unnecessary files (like node_modules, logs, build artifacts).",
657
741
  key: "docker-doctor/use-dockerignore",
658
- message: "Ensure .dockerignore is used"
742
+ message: "Add a .dockerignore file"
659
743
  };
660
744
  const performanceRules = [
661
745
  useMultiStage,
@@ -666,14 +750,6 @@ const performanceRules = [
666
750
 
667
751
  //#endregion
668
752
  //#region ../core/src/rules/security.ts
669
- const createDiagnostic = (file, ruleKey, severity, message, help, line) => ({
670
- file,
671
- help,
672
- line,
673
- message,
674
- rule: ruleKey,
675
- severity
676
- });
677
753
  const isRootUser = (value) => {
678
754
  const [user] = value.split(":");
679
755
  return user === "root" || user === "0";
@@ -681,22 +757,28 @@ const isRootUser = (value) => {
681
757
  const noRootUser = {
682
758
  category: "Security",
683
759
  check(instructions, file) {
760
+ const stageUser = /* @__PURE__ */ new Map();
761
+ let currentStage = null;
684
762
  let lastUser = "root";
685
763
  let lastUserLine = 1;
686
764
  for (const inst of instructions) if (inst.instruction === "FROM") {
687
- lastUser = "root";
765
+ const { base, stage } = parseFromArgs(inst.args);
766
+ lastUser = stageUser.get(base?.toLowerCase() ?? "") ?? "root";
688
767
  lastUserLine = inst.line;
768
+ currentStage = stage?.toLowerCase() ?? null;
769
+ if (currentStage) stageUser.set(currentStage, lastUser);
689
770
  } else if (inst.instruction === "USER") {
690
771
  lastUser = inst.args.trim().toLowerCase();
691
772
  lastUserLine = inst.line;
773
+ if (currentStage) stageUser.set(currentStage, lastUser);
692
774
  }
693
775
  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)];
694
776
  return [];
695
777
  },
696
778
  defaultSeverity: "warning",
697
- help: "Add a non-root user (e.g., 'USER node' or 'USER 1000') to improve security.",
779
+ help: "Add a non-root user (e.g., `USER node` or `USER 1000`) to improve security.",
698
780
  key: "docker-doctor/no-root-user",
699
- message: "Container should not run as root user"
781
+ message: "Run the container as a non-root user"
700
782
  };
701
783
  const noSecretsInEnv = {
702
784
  category: "Security",
@@ -737,7 +819,7 @@ const noSecretsInEnv = {
737
819
  defaultSeverity: "error",
738
820
  help: "Use Docker Secrets, build arguments passed at runtime, or environment variables at runtime instead of baking them into the image.",
739
821
  key: "docker-doctor/no-secrets-in-env",
740
- message: "Do not store secrets in ENV or ARG instructions"
822
+ message: "Avoid storing secrets in ENV or ARG instructions"
741
823
  };
742
824
  const pinImageVersion = {
743
825
  category: "Security",
@@ -745,8 +827,8 @@ const pinImageVersion = {
745
827
  const diagnostics = [];
746
828
  const stageAliases = collectStageAliases(instructions);
747
829
  for (const inst of instructions) if (inst.instruction === "FROM") {
748
- const imagePart = inst.args.split(/\s+/u).find((p) => !p.startsWith("--"));
749
- if (!imagePart || imagePart === "scratch") continue;
830
+ const imagePart = parseFromArgs(inst.args).base;
831
+ if (!imagePart || isScratch(imagePart)) continue;
750
832
  const ref = parseImageRef(imagePart);
751
833
  if (ref.isVariable || stageAliases.has(imagePart.toLowerCase())) continue;
752
834
  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));
@@ -755,9 +837,9 @@ const pinImageVersion = {
755
837
  return diagnostics;
756
838
  },
757
839
  defaultSeverity: "warning",
758
- help: "Specify a concrete tag instead of 'latest' or no tag (e.g., 'node:22.2.0-alpine' instead of 'node').",
840
+ help: "Specify a concrete tag instead of `latest` or no tag (e.g., `node:22.2.0-alpine` instead of `node`).",
759
841
  key: "docker-doctor/pin-image-version",
760
- message: "Always pin base image versions to specific tags"
842
+ message: "Pin base images to a specific tag or digest"
761
843
  };
762
844
  const noAddRemote = {
763
845
  category: "Security",
@@ -771,7 +853,7 @@ const noAddRemote = {
771
853
  return diagnostics;
772
854
  },
773
855
  defaultSeverity: "warning",
774
- 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.",
856
+ 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.",
775
857
  key: "docker-doctor/no-add-remote",
776
858
  message: "Avoid using ADD with remote URLs"
777
859
  };
@@ -814,12 +896,12 @@ const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categ
814
896
 
815
897
  //#endregion
816
898
  //#region ../core/src/runners/compose-runner.ts
817
- const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig) => {
899
+ const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, locate) => {
818
900
  const diagnostics = [];
819
901
  for (const rule of allComposeRules) {
820
902
  const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
821
903
  if (severity === "off") continue;
822
- const ruleDiagnostics = rule.check(composeContent, file);
904
+ const ruleDiagnostics = rule.check(composeContent, file, { locate });
823
905
  if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
824
906
  diagnostics.push(...ruleDiagnostics);
825
907
  }
@@ -888,6 +970,30 @@ const validateConfig = (input) => {
888
970
  return result;
889
971
  };
890
972
 
973
+ //#endregion
974
+ //#region ../core/src/config/unknown-keys.ts
975
+ const KNOWN_CATEGORIES = [
976
+ "Best Practices",
977
+ "Compose",
978
+ "Image Size",
979
+ "Performance",
980
+ "Security"
981
+ ];
982
+ const keysOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [];
983
+ const collectUnknownConfigKeys = (raw) => {
984
+ if (typeof raw !== "object" || raw === null) return {
985
+ categories: [],
986
+ rules: []
987
+ };
988
+ const knownRuleKeys = new Set(allRules.map((rule) => rule.key));
989
+ const knownCategories = new Set(KNOWN_CATEGORIES);
990
+ const { categories, rules } = raw;
991
+ return {
992
+ categories: keysOf(categories).filter((key) => !knownCategories.has(key)),
993
+ rules: keysOf(rules).filter((key) => !knownRuleKeys.has(key))
994
+ };
995
+ };
996
+
891
997
  //#endregion
892
998
  //#region ../core/src/config/loader.ts
893
999
  const fileExists = async (filePath) => {
@@ -917,7 +1023,12 @@ const importConfig = async (filePath) => {
917
1023
  throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
918
1024
  }
919
1025
  };
920
- const loadConfig = async (rootDir, customPath) => {
1026
+ const warnUnknownKeys = (raw, onWarning) => {
1027
+ const unknown = collectUnknownConfigKeys(raw);
1028
+ for (const key of unknown.rules) onWarning(`Unknown rule "${key}" in config — it matches no rule and has no effect.`);
1029
+ for (const key of unknown.categories) onWarning(`Unknown category "${key}" in config — categories are case-sensitive (e.g. "Best Practices", "Security").`);
1030
+ };
1031
+ const loadConfig = async (rootDir, customPath, onWarning) => {
921
1032
  let configObject = null;
922
1033
  if (customPath) {
923
1034
  const fullPath = path.resolve(rootDir, customPath);
@@ -950,7 +1061,9 @@ const loadConfig = async (rootDir, customPath) => {
950
1061
  }
951
1062
  if (!configObject) return {};
952
1063
  try {
953
- return validateConfig(configObject);
1064
+ const config = validateConfig(configObject);
1065
+ if (onWarning) warnUnknownKeys(configObject, onWarning);
1066
+ return config;
954
1067
  } catch (error) {
955
1068
  const msg = error instanceof Error ? error.message : String(error);
956
1069
  throw new ConfigError({ message: `Invalid configuration format: ${msg}` });
@@ -1019,5 +1132,5 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1019
1132
  });
1020
1133
 
1021
1134
  //#endregion
1022
- export { runDockerfileRules as a, parseCompose as c, version as d, runComposeRules as i, parseDockerfile as l, calculateScore as n, allRules as o, loadConfig as r, findRule as s, toJsonReport as t, discoverProject as u };
1023
- //# sourceMappingURL=src-BuUGk4ND.mjs.map
1135
+ export { runDockerfileRules as a, createComposeLocator as c, discoverProject as d, version as f, runComposeRules as i, parseCompose as l, calculateScore as n, allRules as o, loadConfig as r, findRule as s, toJsonReport as t, parseDockerfile as u };
1136
+ //# sourceMappingURL=src-CvOv-hL3.mjs.map