@docker-doctor/cli 0.5.1 → 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.
@@ -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.5.1";
36
+ var version = "0.5.2";
37
37
 
38
38
  //#endregion
39
39
  //#region ../core/src/project-info/ignore.ts
@@ -100,6 +100,7 @@ const walk = async (dir, fileList = []) => {
100
100
  }));
101
101
  return fileList;
102
102
  };
103
+ const toPosixRelative = (rootDir, file) => node_path.default.relative(rootDir, file).split(node_path.default.sep).join("/");
103
104
  const discoverProject = async (rootDir, options) => {
104
105
  const allFiles = await walk(rootDir);
105
106
  const dockerfiles = [];
@@ -107,10 +108,13 @@ const discoverProject = async (rootDir, options) => {
107
108
  const dockerignores = [];
108
109
  const isIgnored = createIgnoreMatcher(options?.ignoreFiles);
109
110
  for (const file of allFiles) {
110
- const relative = node_path.default.relative(rootDir, file);
111
+ const relative = toPosixRelative(rootDir, file);
111
112
  if (isIgnored(relative)) continue;
112
113
  const base = node_path.default.basename(file).toLowerCase();
113
- if (base === ".dockerignore") dockerignores.push(relative);
114
+ if (base.endsWith(".dockerignore")) {
115
+ dockerignores.push(relative);
116
+ continue;
117
+ }
114
118
  if (base === "dockerfile" || base.startsWith("dockerfile.") || base.endsWith(".dockerfile")) dockerfiles.push(relative);
115
119
  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);
116
120
  }
@@ -121,6 +125,72 @@ const discoverProject = async (rootDir, options) => {
121
125
  };
122
126
  };
123
127
 
128
+ //#endregion
129
+ //#region ../core/src/errors/config-error.ts
130
+ var ConfigError = class extends Error {
131
+ _tag = "ConfigError";
132
+ constructor(options) {
133
+ super(options.message);
134
+ this.name = "ConfigError";
135
+ }
136
+ };
137
+
138
+ //#endregion
139
+ //#region ../core/src/errors/parse-error.ts
140
+ var ParseError = class extends Error {
141
+ _tag = "ParseError";
142
+ file;
143
+ constructor(options) {
144
+ super(options.message);
145
+ this.name = "ParseError";
146
+ this.file = options.file;
147
+ }
148
+ };
149
+
150
+ //#endregion
151
+ //#region ../core/src/parsers/shell-quotes.ts
152
+ /**
153
+ * Replaces every character inside a single- or double-quoted region with a
154
+ * space, keeping the quotes themselves and the string length, so callers can
155
+ * run position-sensitive regexes over shell text without matching inside
156
+ * string literals. A backslash inside double quotes escapes the next
157
+ * character; single quotes have no escapes (POSIX sh). An unclosed quote
158
+ * masks to the end of the text.
159
+ *
160
+ * What this deliberately does NOT do: it is not a tokenizer. It performs no
161
+ * variable or command expansion, knows nothing about subshells, backslash
162
+ * line continuations, `$'...'` or `<<` heredoc bodies, and does not split
163
+ * words. It only blanks quoted spans so regexes keep their positions.
164
+ */
165
+ const maskQuotedText = (text) => {
166
+ let out = "";
167
+ let quote = null;
168
+ let index = 0;
169
+ while (index < text.length) {
170
+ const char = text[index];
171
+ if (quote === null) {
172
+ if (char === "\"" || char === "'") quote = char;
173
+ out += char;
174
+ index += 1;
175
+ continue;
176
+ }
177
+ if (char === quote) {
178
+ quote = null;
179
+ out += char;
180
+ index += 1;
181
+ continue;
182
+ }
183
+ if (quote === "\"" && char === "\\" && index + 1 < text.length) {
184
+ out += " ";
185
+ index += 2;
186
+ continue;
187
+ }
188
+ out += " ";
189
+ index += 1;
190
+ }
191
+ return out;
192
+ };
193
+
124
194
  //#endregion
125
195
  //#region ../core/src/parsers/dockerfile-parser.ts
126
196
  const DOCKERFILE_KEYWORDS = /* @__PURE__ */ new Set([
@@ -183,7 +253,10 @@ const matchInstructionKeyword = (lineContent) => {
183
253
  };
184
254
  return null;
185
255
  };
186
- const findHeredocDelimiters = (lineContent) => [...lineContent.matchAll(HEREDOC_OPENER_RE)].map((m) => m.groups?.delim ?? "");
256
+ const findHeredocDelimiters = (lineContent) => {
257
+ const masked = maskQuotedText(lineContent);
258
+ return [...lineContent.matchAll(HEREDOC_OPENER_RE)].filter((match) => masked[match.index] === "<").map((match) => match.groups?.delim ?? "");
259
+ };
187
260
  const processHeredocLine = (state, trimmed) => {
188
261
  if (trimmed === state.heredocQueue[0]) state.heredocQueue.shift();
189
262
  else state.currentArgs += (state.currentArgs ? " " : "") + trimmed;
@@ -206,7 +279,7 @@ const processInstructionLine = (state, trimmed, lineNum) => {
206
279
  if (state.heredocQueue.length > 0) return;
207
280
  if (!hasContinuation) closeInstruction(state);
208
281
  };
209
- const parseDockerfile = (content) => {
282
+ const parseDockerfile = (content, file = "Dockerfile") => {
210
283
  const state = createParserState();
211
284
  const lines = content.split(/\r?\n/u);
212
285
  for (const [i, rawLine] of lines.entries()) {
@@ -219,32 +292,14 @@ const parseDockerfile = (content) => {
219
292
  if (insideHeredoc) processHeredocLine(state, trimmed);
220
293
  else processInstructionLine(state, trimmed, lineNum);
221
294
  }
295
+ if (state.heredocQueue.length > 0) throw new ParseError({
296
+ file,
297
+ message: `Unterminated heredoc: delimiter "${state.heredocQueue[0]}" (opened on line ${state.startLine}) never closed. BuildKit rejects this Dockerfile too.`
298
+ });
222
299
  closeInstruction(state);
223
300
  return state.instructions;
224
301
  };
225
302
 
226
- //#endregion
227
- //#region ../core/src/errors/config-error.ts
228
- var ConfigError = class extends Error {
229
- _tag = "ConfigError";
230
- constructor(options) {
231
- super(options.message);
232
- this.name = "ConfigError";
233
- }
234
- };
235
-
236
- //#endregion
237
- //#region ../core/src/errors/parse-error.ts
238
- var ParseError = class extends Error {
239
- _tag = "ParseError";
240
- file;
241
- constructor(options) {
242
- super(options.message);
243
- this.name = "ParseError";
244
- this.file = options.file;
245
- }
246
- };
247
-
248
303
  //#endregion
249
304
  //#region ../core/src/parsers/compose-parser.ts
250
305
  const parseCompose = (content, filepath) => {
@@ -478,6 +533,7 @@ const combineAptUpdateInstall = {
478
533
  };
479
534
  const PIPEFAIL_SETTING_RE = /(?:^|\s)-[A-Za-z]*o\s+pipefail\b/u;
480
535
  const HAS_PIPE_RE = /(?<!\|)\|(?!\|)/u;
536
+ const CD_COMMAND_RE = /(?:^|&&|\|\||;|\||\(|`|\$\()\s*cd(?=\s|$)/u;
481
537
  const shellDirectiveEnablesPipefail = (args) => {
482
538
  const argv = parseExecForm(args);
483
539
  return argv !== null && PIPEFAIL_SETTING_RE.test(argv.join(" "));
@@ -505,8 +561,9 @@ const usePipefail = {
505
561
  }
506
562
  if (inst.instruction !== "RUN") continue;
507
563
  const { args } = inst;
508
- if (!HAS_PIPE_RE.test(args)) continue;
509
564
  const execArgv = parseExecForm(args);
565
+ const pipeText = execArgv === null ? maskQuotedText(args) : execArgv.join(" ");
566
+ if (!HAS_PIPE_RE.test(pipeText)) continue;
510
567
  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));
511
568
  }
512
569
  return diagnostics;
@@ -535,7 +592,7 @@ const avoidRunCd = {
535
592
  category: "Best Practices",
536
593
  check(instructions, file) {
537
594
  const diagnostics = [];
538
- 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));
595
+ 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));
539
596
  return diagnostics;
540
597
  },
541
598
  defaultSeverity: "info",
@@ -598,12 +655,13 @@ const bestPracticesRules = [
598
655
  * with a null body (`web:` with nothing under it) is returned as an empty
599
656
  * config so rules still check it: it is the least-configured service in
600
657
  * the file, not a service to skip. Scalar and array bodies are invalid
601
- * compose and are dropped.
658
+ * compose and are dropped. A `services:` written as a YAML sequence is
659
+ * invalid Compose and yields no services.
602
660
  */
603
661
  const composeServices = (composeContent) => {
604
662
  if (!composeContent || typeof composeContent !== "object" || !("services" in composeContent)) return [];
605
663
  const { services } = composeContent;
606
- if (!services || typeof services !== "object") return [];
664
+ if (!services || typeof services !== "object" || Array.isArray(services)) return [];
607
665
  const entries = [];
608
666
  for (const [name, config] of Object.entries(services)) if (config === null || config === void 0) entries.push([name, {}]);
609
667
  else if (typeof config === "object" && !Array.isArray(config)) entries.push([name, config]);
@@ -1271,12 +1329,16 @@ const resolveSeverity = (rule, rulesConfig, categoriesConfig) => rulesConfig?.[r
1271
1329
  //#endregion
1272
1330
  //#region ../core/src/runners/dockerfile-runner.ts
1273
1331
  const runDockerfileRules = (instructions, file, projectFiles, rulesConfig, categoriesConfig) => {
1332
+ if (instructions.length === 0) return [];
1274
1333
  const diagnostics = [];
1275
1334
  for (const rule of allDockerfileRules) {
1276
1335
  const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
1277
1336
  if (severity === "off") continue;
1278
1337
  const ruleDiagnostics = rule.check(instructions, file, { projectFiles });
1279
- if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
1338
+ for (const diag of ruleDiagnostics) {
1339
+ diag.category = rule.category;
1340
+ if (severity !== rule.defaultSeverity) diag.severity = severity;
1341
+ }
1280
1342
  diagnostics.push(...ruleDiagnostics);
1281
1343
  }
1282
1344
  return diagnostics;
@@ -1290,7 +1352,10 @@ const runComposeRules = (composeContent, file, rulesConfig, categoriesConfig, lo
1290
1352
  const severity = resolveSeverity(rule, rulesConfig, categoriesConfig);
1291
1353
  if (severity === "off") continue;
1292
1354
  const ruleDiagnostics = rule.check(composeContent, file, { locate });
1293
- if (severity !== rule.defaultSeverity) for (const diag of ruleDiagnostics) diag.severity = severity;
1355
+ for (const diag of ruleDiagnostics) {
1356
+ diag.category = rule.category;
1357
+ if (severity !== rule.defaultSeverity) diag.severity = severity;
1358
+ }
1294
1359
  diagnostics.push(...ruleDiagnostics);
1295
1360
  }
1296
1361
  return diagnostics;
@@ -1400,6 +1465,16 @@ const parseConfigFile = async (filePath, format, parse) => {
1400
1465
  throw new ConfigError({ message: `Failed to parse config ${format}: ${msg}` });
1401
1466
  }
1402
1467
  };
1468
+ const TYPESCRIPT_CONFIG_PATTERN = /\.[cm]?ts$/u;
1469
+ const TYPE_STRIPPING_FAILURES = {
1470
+ 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)",
1471
+ 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"
1472
+ };
1473
+ const typeStrippingFailure = (filePath, error) => {
1474
+ if (!TYPESCRIPT_CONFIG_PATTERN.test(filePath)) return;
1475
+ if (typeof error !== "object" || error === null || !("code" in error) || typeof error.code !== "string") return;
1476
+ return TYPE_STRIPPING_FAILURES[error.code];
1477
+ };
1403
1478
  const importConfig = async (filePath) => {
1404
1479
  if (filePath.endsWith(".json")) return parseConfigFile(filePath, "JSON", JSON.parse);
1405
1480
  if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) return parseConfigFile(filePath, "YAML", yaml.parse);
@@ -1407,6 +1482,8 @@ const importConfig = async (filePath) => {
1407
1482
  const configModule = await import(filePath);
1408
1483
  return configModule.default || configModule;
1409
1484
  } catch (error) {
1485
+ const reason = typeStrippingFailure(filePath, error);
1486
+ 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.` });
1410
1487
  const msg = error instanceof Error ? error.message : String(error);
1411
1488
  throw new ConfigError({ message: `Failed to load config file ${filePath}: ${msg}` });
1412
1489
  }
@@ -1501,9 +1578,15 @@ const calculateScore = (diagnostics) => {
1501
1578
 
1502
1579
  //#endregion
1503
1580
  //#region ../core/src/report.ts
1504
- const REPORT_SCHEMA_VERSION = 2;
1581
+ const REPORT_SCHEMA_VERSION = 3;
1582
+ const resolveCategory = (diagnostic) => {
1583
+ const category = diagnostic.category ?? findRule(diagnostic.rule)?.category;
1584
+ if (!category) throw new Error(`Diagnostic for unknown rule "${diagnostic.rule}" has no category`);
1585
+ return category;
1586
+ };
1505
1587
  const toJsonReport = (diagnostics, score, label, project) => ({
1506
1588
  diagnostics: diagnostics.map((d) => ({
1589
+ category: resolveCategory(d),
1507
1590
  column: d.column,
1508
1591
  file: d.file,
1509
1592
  help: d.help,
@@ -1514,7 +1597,7 @@ const toJsonReport = (diagnostics, score, label, project) => ({
1514
1597
  })),
1515
1598
  label,
1516
1599
  project,
1517
- schemaVersion: 2,
1600
+ schemaVersion: 3,
1518
1601
  score,
1519
1602
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1520
1603
  });
@@ -1598,4 +1681,4 @@ Object.defineProperty(exports, 'version', {
1598
1681
  return version;
1599
1682
  }
1600
1683
  });
1601
- //# sourceMappingURL=src-DYLANvt2.cjs.map
1684
+ //# sourceMappingURL=src-D3jzc3GX.cjs.map