@docker-doctor/cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -13,6 +13,7 @@ interface Diagnostic {
13
13
  severity: DiagnosticSeverity;
14
14
  message: string;
15
15
  help: string;
16
+ category?: RuleCategory;
16
17
  line?: number;
17
18
  column?: number;
18
19
  }
@@ -45,34 +46,34 @@ interface DockerDoctorConfig {
45
46
  }
46
47
  //#endregion
47
48
  //#region ../core/src/project-info/discover.d.ts
48
- declare const discoverProject: (rootDir: string, options?: {
49
+ export declare const discoverProject: (rootDir: string, options?: {
49
50
  ignoreFiles?: readonly string[];
50
51
  }) => Promise<ProjectInfo>;
51
52
  //#endregion
52
53
  //#region ../core/src/parsers/dockerfile-parser.d.ts
53
- declare const parseDockerfile: (content: string) => DockerfileInstruction[];
54
+ export declare const parseDockerfile: (content: string, file?: string) => DockerfileInstruction[];
54
55
  //#endregion
55
56
  //#region ../core/src/parsers/compose-parser.d.ts
56
- declare const parseCompose: (content: string, filepath: string) => unknown;
57
+ export declare const parseCompose: (content: string, filepath: string) => unknown;
57
58
  //#endregion
58
59
  //#region ../core/src/runners/dockerfile-runner.d.ts
59
- declare const runDockerfileRules: (instructions: DockerfileInstruction[], file: string, projectFiles: string[], rulesConfig?: Record<string, RuleSeverity>, categoriesConfig?: Record<string, RuleSeverity>) => Diagnostic[];
60
+ export declare const runDockerfileRules: (instructions: DockerfileInstruction[], file: string, projectFiles: string[], rulesConfig?: Record<string, RuleSeverity>, categoriesConfig?: Record<string, RuleSeverity>) => Diagnostic[];
60
61
  //#endregion
61
62
  //#region ../core/src/runners/compose-runner.d.ts
62
- declare const runComposeRules: (composeContent: unknown, file: string, rulesConfig?: Record<string, RuleSeverity>, categoriesConfig?: Record<string, RuleSeverity>, locate?: ComposeLocator) => Diagnostic[];
63
+ export declare const runComposeRules: (composeContent: unknown, file: string, rulesConfig?: Record<string, RuleSeverity>, categoriesConfig?: Record<string, RuleSeverity>, locate?: ComposeLocator) => Diagnostic[];
63
64
  //#endregion
64
65
  //#region ../core/src/rules/index.d.ts
65
- declare const allRules: RuleDefinition[];
66
- declare const findRule: (key: string) => RuleDefinition | undefined;
66
+ export declare const allRules: RuleDefinition[];
67
+ export declare const findRule: (key: string) => RuleDefinition | undefined;
67
68
  //#endregion
68
69
  //#region ../core/src/config/define-config.d.ts
69
- declare const defineConfig: (config: DockerDoctorConfig) => DockerDoctorConfig;
70
+ export declare const defineConfig: (config: DockerDoctorConfig) => DockerDoctorConfig;
70
71
  //#endregion
71
72
  //#region ../core/src/config/loader.d.ts
72
- declare const loadConfig: (rootDir: string, customPath?: string, onWarning?: (message: string) => void) => Promise<DockerDoctorConfig>;
73
+ export declare const loadConfig: (rootDir: string, customPath?: string, onWarning?: (message: string) => void) => Promise<DockerDoctorConfig>;
73
74
  //#endregion
74
75
  //#region ../core/src/scoring.d.ts
75
- declare const calculateScore: (diagnostics: Diagnostic[]) => {
76
+ export declare const calculateScore: (diagnostics: Diagnostic[]) => {
76
77
  score: number;
77
78
  label: string;
78
79
  };
@@ -80,6 +81,7 @@ declare const calculateScore: (diagnostics: Diagnostic[]) => {
80
81
  //#region ../core/src/report.d.ts
81
82
  interface JsonReport {
82
83
  diagnostics: {
84
+ category: RuleCategory;
83
85
  column?: number;
84
86
  file: string;
85
87
  help: string;
@@ -94,10 +96,10 @@ interface JsonReport {
94
96
  score: number;
95
97
  timestamp: string;
96
98
  }
97
- declare const toJsonReport: (diagnostics: Diagnostic[], score: number, label: string, project: ProjectInfo) => JsonReport;
99
+ export declare const toJsonReport: (diagnostics: Diagnostic[], score: number, label: string, project: ProjectInfo) => JsonReport;
98
100
  //#endregion
99
101
  //#region src/index.d.ts
100
- declare const version: string;
102
+ export declare const version: string;
101
103
  //#endregion
102
- export { type Diagnostic, type DockerDoctorConfig, type RuleCategory, type RuleSeverity, allRules, calculateScore, defineConfig, discoverProject, findRule, loadConfig, parseCompose, parseDockerfile, runComposeRules, runDockerfileRules, toJsonReport, version };
104
+ export type { Diagnostic, DockerDoctorConfig, RuleCategory, RuleSeverity };
103
105
  //# sourceMappingURL=index.d.cts.map
package/dist/index.d.mts CHANGED
@@ -13,6 +13,7 @@ interface Diagnostic {
13
13
  severity: DiagnosticSeverity;
14
14
  message: string;
15
15
  help: string;
16
+ category?: RuleCategory;
16
17
  line?: number;
17
18
  column?: number;
18
19
  }
@@ -45,34 +46,34 @@ interface DockerDoctorConfig {
45
46
  }
46
47
  //#endregion
47
48
  //#region ../core/src/project-info/discover.d.ts
48
- declare const discoverProject: (rootDir: string, options?: {
49
+ export declare const discoverProject: (rootDir: string, options?: {
49
50
  ignoreFiles?: readonly string[];
50
51
  }) => Promise<ProjectInfo>;
51
52
  //#endregion
52
53
  //#region ../core/src/parsers/dockerfile-parser.d.ts
53
- declare const parseDockerfile: (content: string) => DockerfileInstruction[];
54
+ export declare const parseDockerfile: (content: string, file?: string) => DockerfileInstruction[];
54
55
  //#endregion
55
56
  //#region ../core/src/parsers/compose-parser.d.ts
56
- declare const parseCompose: (content: string, filepath: string) => unknown;
57
+ export declare const parseCompose: (content: string, filepath: string) => unknown;
57
58
  //#endregion
58
59
  //#region ../core/src/runners/dockerfile-runner.d.ts
59
- declare const runDockerfileRules: (instructions: DockerfileInstruction[], file: string, projectFiles: string[], rulesConfig?: Record<string, RuleSeverity>, categoriesConfig?: Record<string, RuleSeverity>) => Diagnostic[];
60
+ export declare const runDockerfileRules: (instructions: DockerfileInstruction[], file: string, projectFiles: string[], rulesConfig?: Record<string, RuleSeverity>, categoriesConfig?: Record<string, RuleSeverity>) => Diagnostic[];
60
61
  //#endregion
61
62
  //#region ../core/src/runners/compose-runner.d.ts
62
- declare const runComposeRules: (composeContent: unknown, file: string, rulesConfig?: Record<string, RuleSeverity>, categoriesConfig?: Record<string, RuleSeverity>, locate?: ComposeLocator) => Diagnostic[];
63
+ export declare const runComposeRules: (composeContent: unknown, file: string, rulesConfig?: Record<string, RuleSeverity>, categoriesConfig?: Record<string, RuleSeverity>, locate?: ComposeLocator) => Diagnostic[];
63
64
  //#endregion
64
65
  //#region ../core/src/rules/index.d.ts
65
- declare const allRules: RuleDefinition[];
66
- declare const findRule: (key: string) => RuleDefinition | undefined;
66
+ export declare const allRules: RuleDefinition[];
67
+ export declare const findRule: (key: string) => RuleDefinition | undefined;
67
68
  //#endregion
68
69
  //#region ../core/src/config/define-config.d.ts
69
- declare const defineConfig: (config: DockerDoctorConfig) => DockerDoctorConfig;
70
+ export declare const defineConfig: (config: DockerDoctorConfig) => DockerDoctorConfig;
70
71
  //#endregion
71
72
  //#region ../core/src/config/loader.d.ts
72
- declare const loadConfig: (rootDir: string, customPath?: string, onWarning?: (message: string) => void) => Promise<DockerDoctorConfig>;
73
+ export declare const loadConfig: (rootDir: string, customPath?: string, onWarning?: (message: string) => void) => Promise<DockerDoctorConfig>;
73
74
  //#endregion
74
75
  //#region ../core/src/scoring.d.ts
75
- declare const calculateScore: (diagnostics: Diagnostic[]) => {
76
+ export declare const calculateScore: (diagnostics: Diagnostic[]) => {
76
77
  score: number;
77
78
  label: string;
78
79
  };
@@ -80,6 +81,7 @@ declare const calculateScore: (diagnostics: Diagnostic[]) => {
80
81
  //#region ../core/src/report.d.ts
81
82
  interface JsonReport {
82
83
  diagnostics: {
84
+ category: RuleCategory;
83
85
  column?: number;
84
86
  file: string;
85
87
  help: string;
@@ -94,10 +96,10 @@ interface JsonReport {
94
96
  score: number;
95
97
  timestamp: string;
96
98
  }
97
- declare const toJsonReport: (diagnostics: Diagnostic[], score: number, label: string, project: ProjectInfo) => JsonReport;
99
+ export declare const toJsonReport: (diagnostics: Diagnostic[], score: number, label: string, project: ProjectInfo) => JsonReport;
98
100
  //#endregion
99
101
  //#region src/index.d.ts
100
- declare const version: string;
102
+ export declare const version: string;
101
103
  //#endregion
102
- export { type Diagnostic, type DockerDoctorConfig, type RuleCategory, type RuleSeverity, allRules, calculateScore, defineConfig, discoverProject, findRule, loadConfig, parseCompose, parseDockerfile, runComposeRules, runDockerfileRules, toJsonReport, version };
104
+ export type { Diagnostic, DockerDoctorConfig, RuleCategory, RuleSeverity };
103
105
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as runDockerfileRules, d as discoverProject, f as version$1, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-CpGd43y0.mjs";
2
+ import { a as runDockerfileRules, d as discoverProject, f as version$1, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-BOGzkp17.mjs";
3
3
 
4
4
  //#region ../core/src/config/define-config.ts
5
5
  const defineConfig = (config) => config;
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { LineCounter, isAlias, isMap, isScalar, isSeq, parse, parseDocument } from "yaml";
5
5
 
6
6
  //#region package.json
7
- var version = "0.5.0";
7
+ var version = "0.5.2";
8
8
 
9
9
  //#endregion
10
10
  //#region ../core/src/project-info/ignore.ts
@@ -71,6 +71,7 @@ const walk = async (dir, fileList = []) => {
71
71
  }));
72
72
  return fileList;
73
73
  };
74
+ const toPosixRelative = (rootDir, file) => path.relative(rootDir, file).split(path.sep).join("/");
74
75
  const discoverProject = async (rootDir, options) => {
75
76
  const allFiles = await walk(rootDir);
76
77
  const dockerfiles = [];
@@ -78,10 +79,13 @@ const discoverProject = async (rootDir, options) => {
78
79
  const dockerignores = [];
79
80
  const isIgnored = createIgnoreMatcher(options?.ignoreFiles);
80
81
  for (const file of allFiles) {
81
- const relative = path.relative(rootDir, file);
82
+ const relative = toPosixRelative(rootDir, file);
82
83
  if (isIgnored(relative)) continue;
83
84
  const base = path.basename(file).toLowerCase();
84
- if (base === ".dockerignore") dockerignores.push(relative);
85
+ if (base.endsWith(".dockerignore")) {
86
+ dockerignores.push(relative);
87
+ continue;
88
+ }
85
89
  if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(relative);
86
90
  if (base === "docker-compose.yml" || base === "docker-compose.yaml" || base === "compose.yml" || base === "compose.yaml" || (base.startsWith("docker-compose.") || base.startsWith("compose.")) && (base.endsWith(".yml") || base.endsWith(".yaml"))) composeFiles.push(relative);
87
91
  }
@@ -92,6 +96,72 @@ const discoverProject = async (rootDir, options) => {
92
96
  };
93
97
  };
94
98
 
99
+ //#endregion
100
+ //#region ../core/src/errors/config-error.ts
101
+ var ConfigError = class extends Error {
102
+ _tag = "ConfigError";
103
+ constructor(options) {
104
+ super(options.message);
105
+ this.name = "ConfigError";
106
+ }
107
+ };
108
+
109
+ //#endregion
110
+ //#region ../core/src/errors/parse-error.ts
111
+ var ParseError = class extends Error {
112
+ _tag = "ParseError";
113
+ file;
114
+ constructor(options) {
115
+ super(options.message);
116
+ this.name = "ParseError";
117
+ this.file = options.file;
118
+ }
119
+ };
120
+
121
+ //#endregion
122
+ //#region ../core/src/parsers/shell-quotes.ts
123
+ /**
124
+ * Replaces every character inside a single- or double-quoted region with a
125
+ * space, keeping the quotes themselves and the string length, so callers can
126
+ * run position-sensitive regexes over shell text without matching inside
127
+ * string literals. A backslash inside double quotes escapes the next
128
+ * character; single quotes have no escapes (POSIX sh). An unclosed quote
129
+ * masks to the end of the text.
130
+ *
131
+ * What this deliberately does NOT do: it is not a tokenizer. It performs no
132
+ * variable or command expansion, knows nothing about subshells, backslash
133
+ * line continuations, `$'...'` or `<<` heredoc bodies, and does not split
134
+ * words. It only blanks quoted spans so regexes keep their positions.
135
+ */
136
+ const maskQuotedText = (text) => {
137
+ let out = "";
138
+ let quote = null;
139
+ let index = 0;
140
+ while (index < text.length) {
141
+ const char = text[index];
142
+ if (quote === null) {
143
+ if (char === "\"" || char === "'") quote = char;
144
+ out += char;
145
+ index += 1;
146
+ continue;
147
+ }
148
+ if (char === quote) {
149
+ quote = null;
150
+ out += char;
151
+ index += 1;
152
+ continue;
153
+ }
154
+ if (quote === "\"" && char === "\\" && index + 1 < text.length) {
155
+ out += " ";
156
+ index += 2;
157
+ continue;
158
+ }
159
+ out += " ";
160
+ index += 1;
161
+ }
162
+ return out;
163
+ };
164
+
95
165
  //#endregion
96
166
  //#region ../core/src/parsers/dockerfile-parser.ts
97
167
  const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
@@ -154,7 +224,10 @@ const matchInstructionKeyword = (lineContent) => {
154
224
  };
155
225
  return null;
156
226
  };
157
- const findHeredocDelimiters = (lineContent) => [...lineContent.matchAll(HEREDOC_OPENER_RE)].map((m) => m.groups?.delim ?? "");
227
+ const findHeredocDelimiters = (lineContent) => {
228
+ const masked = maskQuotedText(lineContent);
229
+ return [...lineContent.matchAll(HEREDOC_OPENER_RE)].filter((match) => masked[match.index] === "<").map((match) => match.groups?.delim ?? "");
230
+ };
158
231
  const processHeredocLine = (state, trimmed) => {
159
232
  if (trimmed === state.heredocQueue[0]) state.heredocQueue.shift();
160
233
  else state.currentArgs += (state.currentArgs ? " " : "") + trimmed;
@@ -177,7 +250,7 @@ const processInstructionLine = (state, trimmed, lineNum) => {
177
250
  if (state.heredocQueue.length > 0) return;
178
251
  if (!hasContinuation) closeInstruction(state);
179
252
  };
180
- const parseDockerfile = (content) => {
253
+ const parseDockerfile = (content, file = "Dockerfile") => {
181
254
  const state = createParserState();
182
255
  const lines = content.split(/\r?\n/u);
183
256
  for (const [i, rawLine] of lines.entries()) {
@@ -190,32 +263,14 @@ const parseDockerfile = (content) => {
190
263
  if (insideHeredoc) processHeredocLine(state, trimmed);
191
264
  else processInstructionLine(state, trimmed, lineNum);
192
265
  }
266
+ if (state.heredocQueue.length > 0) throw new ParseError({
267
+ file,
268
+ message: `Unterminated heredoc: delimiter "${state.heredocQueue[0]}" (opened on line ${state.startLine}) never closed. BuildKit rejects this Dockerfile too.`
269
+ });
193
270
  closeInstruction(state);
194
271
  return state.instructions;
195
272
  };
196
273
 
197
- //#endregion
198
- //#region ../core/src/errors/config-error.ts
199
- var ConfigError = class extends Error {
200
- _tag = "ConfigError";
201
- constructor(options) {
202
- super(options.message);
203
- this.name = "ConfigError";
204
- }
205
- };
206
-
207
- //#endregion
208
- //#region ../core/src/errors/parse-error.ts
209
- var ParseError = class extends Error {
210
- _tag = "ParseError";
211
- file;
212
- constructor(options) {
213
- super(options.message);
214
- this.name = "ParseError";
215
- this.file = options.file;
216
- }
217
- };
218
-
219
274
  //#endregion
220
275
  //#region ../core/src/parsers/compose-parser.ts
221
276
  const parseCompose = (content, filepath) => {
@@ -449,6 +504,7 @@ const combineAptUpdateInstall = {
449
504
  };
450
505
  const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
451
506
  const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
507
+ const CD_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*cd(?=\s|$)/u;
452
508
  const shellDirectiveEnablesPipefail = (args) => {
453
509
  const argv = parseExecForm(args);
454
510
  return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
@@ -476,8 +532,9 @@ const usePipefail = {
476
532
  }
477
533
  if (inst.instruction !== "RUN") continue;
478
534
  const { args } = inst;
479
- if (!HAS_PIPE_RE.test(args)) continue;
480
535
  const execArgv = parseExecForm(args);
536
+ const pipeText = execArgv === null ? maskQuotedText(args) : execArgv.join(" ");
537
+ if (!HAS_PIPE_RE.test(pipeText)) continue;
481
538
  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));
482
539
  }
483
540
  return diagnostics;
@@ -506,7 +563,7 @@ const avoidRunCd = {
506
563
  category: "Best Practices",
507
564
  check(instructions, file) {
508
565
  const diagnostics = [];
509
- 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));
566
+ for (const inst of instructions) if (inst.instruction === "RUN" && CD_COMMAND_RE.test(maskQuotedText(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));
510
567
  return diagnostics;
511
568
  },
512
569
  defaultSeverity: "info",
@@ -569,12 +626,13 @@ const bestPracticesRules = [
569
626
  * with a null body (`web:` with nothing under it) is returned as an empty
570
627
  * config so rules still check it: it is the least-configured service in
571
628
  * the file, not a service to skip. Scalar and array bodies are invalid
572
- * compose and are dropped.
629
+ * compose and are dropped. A `services:` written as a YAML sequence is
630
+ * invalid Compose and yields no services.
573
631
  */
574
632
  const composeServices = (composeContent) => {
575
633
  if (!composeContent || typeof composeContent !== "object" || !("services" in composeContent)) return [];
576
634
  const { services } = composeContent;
577
- if (!services || typeof services !== "object") return [];
635
+ if (!services || typeof services !== "object" || Array.isArray(services)) return [];
578
636
  const entries = [];
579
637
  for (const [name, config] of Object.entries(services)) if (config === null || config === void 0) entries.push([name, {}]);
580
638
  else if (typeof config === "object" && !Array.isArray(config)) entries.push([name, config]);
@@ -600,7 +658,9 @@ const requireResourceLimits = {
600
658
  const diagnostics = [];
601
659
  for (const [name, config] of composeServices(composeContent)) {
602
660
  const limits = (config.deploy?.resources)?.limits;
603
- 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])));
661
+ const hasDeployLimits = Boolean(limits?.cpus || limits?.memory);
662
+ const hasServiceLevelLimits = Boolean(config.mem_limit || config.cpus || config.cpu_quota);
663
+ if (!(hasDeployLimits || hasServiceLevelLimits)) 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])));
604
664
  }
605
665
  return diagnostics;
606
666
  },
@@ -717,24 +777,52 @@ const undefinedModelReference = {
717
777
  key: "docker-doctor/undefined-model-reference",
718
778
  message: "Service model references must be declared in top-level models"
719
779
  };
780
+ const collectModelBindings = (composeContent) => {
781
+ const bindings = [];
782
+ for (const [name, config] of Object.entries(topLevelModels(composeContent))) {
783
+ if (!config || typeof config !== "object") continue;
784
+ const { model } = config;
785
+ if (typeof model === "string") bindings.push({
786
+ model,
787
+ path: [
788
+ "models",
789
+ name,
790
+ "model"
791
+ ],
792
+ subject: `Model '${name}' artifact`
793
+ });
794
+ }
795
+ for (const [name, config] of composeServices(composeContent)) {
796
+ const { provider } = config;
797
+ if (!provider || typeof provider !== "object") continue;
798
+ const { type, options } = provider;
799
+ if (type !== "model" || !options || typeof options !== "object") continue;
800
+ const { model } = options;
801
+ if (typeof model === "string") bindings.push({
802
+ model,
803
+ path: [
804
+ "services",
805
+ name,
806
+ "provider",
807
+ "options",
808
+ "model"
809
+ ],
810
+ subject: `Service '${name}' model provider`
811
+ });
812
+ }
813
+ return bindings;
814
+ };
720
815
  const pinModelVersion = {
721
816
  category: "Compose",
722
817
  check(composeContent, file, context) {
723
818
  const diagnostics = [];
724
- for (const [name, config] of Object.entries(topLevelModels(composeContent))) {
725
- if (!config || typeof config !== "object") continue;
726
- const { model } = config;
727
- if (typeof model !== "string") continue;
819
+ for (const { subject, model, path } of collectModelBindings(composeContent)) {
728
820
  const ref = parseImageRef(model);
729
821
  if (ref.isVariable) continue;
730
822
  const issue = mutableRefIssue(ref);
731
823
  if (!issue) continue;
732
- const detail = issue === "untagged" ? `Model '${name}' artifact '${model}' does not specify a tag.` : `Model '${name}' artifact '${model}' uses the mutable 'latest' tag.`;
733
- diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch different weights.`, this.help, context?.locate?.([
734
- "models",
735
- name,
736
- "model"
737
- ])));
824
+ const detail = issue === "untagged" ? `${subject} '${model}' does not specify a tag.` : `${subject} '${model}' uses the mutable 'latest' tag.`;
825
+ diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `${detail} Every pull may fetch different weights.`, this.help, context?.locate?.(path)));
738
826
  }
739
827
  return diagnostics;
740
828
  },
@@ -748,18 +836,21 @@ const composeModelRules = [undefinedModelReference, pinModelVersion];
748
836
  //#endregion
749
837
  //#region ../core/src/rules/secret-keywords.ts
750
838
  const SECRET_KEY_PATTERNS = [
751
- /(?:^|[_-])password(?:[_-]|$)/iu,
839
+ /password(?:[_-]|$)/iu,
752
840
  /(?:^|[_-])secret(?:[_-]|$)/iu,
753
841
  /(?:^|[_-])token(?:[_-]|$)/iu,
754
842
  /(?:^|[_-])api_key(?:[_-]|$)/iu,
843
+ /(?:^|[_-])apikey(?:[_-]|$)/iu,
755
844
  /(?:^|[_-])private_key(?:[_-]|$)/iu,
756
- /(?:^|[_-])auth(?:[_-]|$)/iu
845
+ /(?:^|[_-])auth(?:[_-]|$)/iu,
846
+ /(?:^|[_-])pat(?:[_-]|$)/iu
757
847
  ];
758
848
  const isSecretKey = (key) => SECRET_KEY_PATTERNS.some((regex) => regex.test(key));
849
+ const CREDENTIAL_FREE_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@/\s]*(?:\/|$)/iu;
850
+ const isLiteralSecretValue = (value) => value.length > 0 && !value.startsWith("$") && !CREDENTIAL_FREE_URL.test(value);
759
851
 
760
852
  //#endregion
761
853
  //#region ../core/src/rules/compose-security.ts
762
- const DOCKER_SOCKET = "/var/run/docker.sock";
763
854
  const noPrivilegedService = {
764
855
  category: "Compose",
765
856
  check(composeContent, file, context) {
@@ -776,10 +867,54 @@ const noPrivilegedService = {
776
867
  key: "docker-doctor/no-privileged-service",
777
868
  message: "Do not run services in privileged mode"
778
869
  };
870
+ const DOCKER_SOCKET_TARGET = "/var/run/docker.sock";
871
+ const INTERPOLATION_WITH_DEFAULT = /\$\{[^}:?-]+:?-(?<fallback>[^}]*)\}/gu;
872
+ const INTERPOLATION_WITHOUT_DEFAULT = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu;
873
+ const resolveInterpolationDefaults = (value) => value.replace(INTERPOLATION_WITH_DEFAULT, (_match, fallback) => fallback).replace(INTERPOLATION_WITHOUT_DEFAULT, "");
874
+ const PATH_SHAPED = /^(?:[/.~]|[A-Za-z]:)/u;
875
+ const isDockerSocketPath = (rawPath) => {
876
+ const normalized = rawPath.replaceAll("\\", "/");
877
+ return PATH_SHAPED.test(normalized) && (normalized.endsWith("/docker.sock") || normalized.endsWith("/pipe/docker_engine"));
878
+ };
879
+ const splitShortSyntax = (spec) => {
880
+ const parts = [];
881
+ let depth = 0;
882
+ let current = "";
883
+ for (const char of spec) {
884
+ if (char === "{") depth += 1;
885
+ else if (char === "}") depth = Math.max(0, depth - 1);
886
+ if (char === ":" && depth === 0) {
887
+ parts.push(current);
888
+ current = "";
889
+ continue;
890
+ }
891
+ current += char;
892
+ }
893
+ parts.push(current);
894
+ return parts;
895
+ };
896
+ const volumeMount = (volume) => {
897
+ if (typeof volume === "string") {
898
+ const [source, target] = splitShortSyntax(volume);
899
+ return target === void 0 ? void 0 : {
900
+ source,
901
+ target
902
+ };
903
+ }
904
+ if (volume && typeof volume === "object") {
905
+ const { source, target } = volume;
906
+ return {
907
+ source: typeof source === "string" ? source : void 0,
908
+ target: typeof target === "string" ? target : void 0
909
+ };
910
+ }
911
+ };
779
912
  const mountsDockerSocket = (volume) => {
780
- if (typeof volume === "string") return volume.split(":")[0] === DOCKER_SOCKET;
781
- if (volume && typeof volume === "object") return volume.source === DOCKER_SOCKET;
782
- return false;
913
+ const mount = volumeMount(volume);
914
+ if (!mount?.source) return false;
915
+ const resolvedSource = resolveInterpolationDefaults(mount.source);
916
+ if (resolvedSource !== "") return isDockerSocketPath(resolvedSource);
917
+ return resolveInterpolationDefaults(mount.target ?? "") === DOCKER_SOCKET_TARGET;
783
918
  };
784
919
  const noDockerSocketMount = {
785
920
  category: "Compose",
@@ -802,7 +937,6 @@ const noDockerSocketMount = {
802
937
  key: "docker-doctor/no-docker-socket-mount",
803
938
  message: "Do not bind-mount the Docker socket into services"
804
939
  };
805
- const isLiteralSecretValue = (value) => typeof value === "string" && value.length > 0 && !value.startsWith("$");
806
940
  const noPlaintextSecrets = {
807
941
  category: "Compose",
808
942
  check(composeContent, file, context) {
@@ -818,7 +952,7 @@ const noPlaintextSecrets = {
818
952
  if (eqIndex <= 0) continue;
819
953
  const key = entry.slice(0, eqIndex);
820
954
  const value = entry.slice(eqIndex + 1);
821
- if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
955
+ if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
822
956
  "services",
823
957
  name,
824
958
  "environment",
@@ -826,7 +960,7 @@ const noPlaintextSecrets = {
826
960
  ]));
827
961
  }
828
962
  else if (environment && typeof environment === "object") {
829
- for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
963
+ for (const [key, value] of Object.entries(environment)) if (isSecretKey(key) && typeof value === "string" && isLiteralSecretValue(value)) flag(name, key, context?.locate?.([
830
964
  "services",
831
965
  name,
832
966
  "environment",
@@ -1074,7 +1208,7 @@ const noSecretsInEnv = {
1074
1208
  const match = args.match(/^(?<key>[^\s]+)\s+(?<value>.*)$/u);
1075
1209
  if (match?.groups) {
1076
1210
  const { key, value } = match.groups;
1077
- if (isSecretKey(key) && value && !value.startsWith("$") && !value.startsWith("{")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret found in ${inst.instruction}: '${key}'. Secrets baked into images can be extracted easily by anyone with image access.`, this.help, inst.line));
1211
+ if (isSecretKey(key) && isLiteralSecretValue(value) && !value.startsWith("{")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret found in ${inst.instruction}: '${key}'. Secrets baked into images can be extracted easily by anyone with image access.`, this.help, inst.line));
1078
1212
  }
1079
1213
  } else {
1080
1214
  const parts = args.split(/\s+/u);
@@ -1086,7 +1220,7 @@ const noSecretsInEnv = {
1086
1220
  key = part.slice(0, eqIndex);
1087
1221
  value = part.slice(eqIndex + 1);
1088
1222
  } else key = part;
1089
- if (isSecretKey(key) && value && !value.startsWith("$") && !value.startsWith("{")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret found in ${inst.instruction}: '${key}'. Secrets baked into images can be extracted easily by anyone with image access.`, this.help, inst.line));
1223
+ if (isSecretKey(key) && isLiteralSecretValue(value) && !value.startsWith("{")) diagnostics.push(createDiagnostic(file, this.key, this.defaultSeverity, `Potential secret found in ${inst.instruction}: '${key}'. Secrets baked into images can be extracted easily by anyone with image access.`, this.help, inst.line));
1090
1224
  }
1091
1225
  }
1092
1226
  }
@@ -1166,12 +1300,16 @@ const resolveSeverity = (rule, rulesConfig, categoriesConfig) => rulesConfig?.[r
1166
1300
  //#endregion
1167
1301
  //#region ../core/src/runners/dockerfile-runner.ts
1168
1302
  const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categoriesConfig) => {
1303
+ if (instructions.length === 0) return [];
1169
1304
  const diagnostics = [];
1170
1305
  for (const rule of allDockerfileRules) {
1171
1306
  const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
1172
1307
  if (severity === "off") continue;
1173
1308
  const ruleDiagnostics = rule.check(instructions, file, { projectFiles });
1174
- if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
1309
+ for (const diag of ruleDiagnostics) {
1310
+ diag.category = rule.category;
1311
+ if (severity !== rule.defaultSeverity) diag.severity = severity;
1312
+ }
1175
1313
  diagnostics.push(...ruleDiagnostics);
1176
1314
  }
1177
1315
  return diagnostics;
@@ -1185,7 +1323,10 @@ const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, lo
1185
1323
  const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
1186
1324
  if (severity === "off") continue;
1187
1325
  const ruleDiagnostics = rule.check(composeContent, file, { locate });
1188
- if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
1326
+ for (const diag of ruleDiagnostics) {
1327
+ diag.category = rule.category;
1328
+ if (severity !== rule.defaultSeverity) diag.severity = severity;
1329
+ }
1189
1330
  diagnostics.push(...ruleDiagnostics);
1190
1331
  }
1191
1332
  return diagnostics;
@@ -1295,6 +1436,16 @@ const parseConfigFile = async (filePath, format, parse) => {
1295
1436
  throw new ConfigError({ message: `Failed to parse config ${format}: ${msg}` });
1296
1437
  }
1297
1438
  };
1439
+ const TYPESCRIPT_CONFIG_PATTERN = /\.[cm]?ts$/u;
1440
+ const TYPE_STRIPPING_FAILURES = {
1441
+ ERR_UNKNOWN_FILE_EXTENSION: "this Node.js is not stripping TypeScript types, which needs 22.18 or newer with stripping left on (--no-experimental-strip-types turns it off)",
1442
+ ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING: "Node.js never strips TypeScript types for files under node_modules, on any version, so the config has to live outside it"
1443
+ };
1444
+ const typeStrippingFailure = (filePath, error) => {
1445
+ if (!TYPESCRIPT_CONFIG_PATTERN.test(filePath)) return;
1446
+ if (typeof error !== "object" || error === null || !("code" in error) || typeof error.code !== "string") return;
1447
+ return TYPE_STRIPPING_FAILURES[error.code];
1448
+ };
1298
1449
  const importConfig = async (filePath) => {
1299
1450
  if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
1300
1451
  if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", parse);
@@ -1302,6 +1453,8 @@ const importConfig = async (filePath) => {
1302
1453
  const configModule = await import(filePath);
1303
1454
  return configModule.default || configModule;
1304
1455
  } catch (error) {
1456
+ const reason = typeStrippingFailure(filePath, error);
1457
+ if (reason) throw new ConfigError({ message: `Cannot load ${filePath} on Node.js ${process.version}: ${reason}. A docker-doctor.config.yaml or .json needs no TypeScript support at all.` });
1305
1458
  const msg = error instanceof Error ? error.message : String(error);
1306
1459
  throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
1307
1460
  }
@@ -1396,9 +1549,15 @@ const calculateScore = (diagnostics) => {
1396
1549
 
1397
1550
  //#endregion
1398
1551
  //#region ../core/src/report.ts
1399
- const REPORT_SCHEMA_VERSION = 2;
1552
+ const REPORT_SCHEMA_VERSION = 3;
1553
+ const resolveCategory = (diagnostic) => {
1554
+ const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
1555
+ if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
1556
+ return category;
1557
+ };
1400
1558
  const toJsonReport = (diagnostics, score, label, project) => ({
1401
1559
  diagnostics: diagnostics.map((d) => ({
1560
+ category: resolveCategory(d),
1402
1561
  column: d.column,
1403
1562
  file: d.file,
1404
1563
  help: d.help,
@@ -1409,11 +1568,11 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1409
1568
  })),
1410
1569
  label,
1411
1570
  project,
1412
- schemaVersion: 2,
1571
+ schemaVersion: 3,
1413
1572
  score,
1414
1573
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1415
1574
  });
1416
1575
 
1417
1576
  //#endregion
1418
1577
  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 };
1419
- //# sourceMappingURL=src-CpGd43y0.mjs.map
1578
+ //# sourceMappingURL=src-BOGzkp17.mjs.map