Design notes

Parser

parser.parse_text works in three steps.

  1. Directives. Leading # syntax=, # escape= and # check= lines are read until the first line that is not a directive (a comment or blank line ends the directive block, as in Docker). escape switches the continuation character to a backtick for Windows Dockerfiles.
  2. Logical lines. A line ending in the escape character continues on the next line. Comment lines and blank lines inside a continuation are dropped, exactly as BuildKit does, and continuation lines are joined without inserting a space. Each Instruction keeps its 1-based start and end line so findings point at the right place and the fixer can replace the exact range.
  3. Heredocs. For RUN, COPY and ADD (also under ONBUILD), every <<WORD, <<-WORD and <<'WORD' marker on the logical line consumes the following lines up to the delimiter. <<- strips leading tabs. The bodies are stored on the instruction and are what rule logic sees as the shell script:
RUN <<EOF
apt-get update
apt-get install -y curl
EOF

is analysed as two commands, so DW005 and DW006 fire just as they would for the && form. RUN python3 <<PY keeps the command prefix.

Variable scoping

Docker's rules, as implemented in _assign_stages:

Worked example:

ARG PY=3.12
FROM python:${PY}-slim AS base     # FROM expands with the global scope: tag = 3.12-slim
WORKDIR /opt/app-$PY               # PY is NOT in scope here: stays "$PY" -> DW040
ARG PY
WORKDIR /opt/app-$PY               # now "/opt/app-3.12"

expand() supports $V, ${V}, ${V:-d}, ${V-d}, ${V:+a}, ${V+a}, ${V:?msg} and \$. Anything unresolved is reported back so rules can avoid false positives (for example FROM python:${TAG} with no default is never reported as :latest).

DW040 only fires for variables that are declared as global ARGs and used in a stage without redeclaration. Variables that merely are not declared anywhere are ignored, because the base image may define them through its own ENV.

Shell analysis

shell.parse_script tokenises each line with shlex in punctuation mode, which keeps &&, ||, ;, | and redirections as separate tokens while honouring quotes. Redirections (including 2>&1) are dropped, leading VAR=value assignments are recorded, and wrappers (sudo, exec, nohup, time) are stripped (sudo sets a flag used by DW028). Newlines (heredoc bodies) are statement separators. Each Cmd records the operator that follows it, which is how curl ... | sh is detected: a curl or wget command whose separator is a pipe, followed by a shell or interpreter.

Cmd offers tool (normalising pip3.11 and python -m pip to pip), subcommand(), operands() and has_flag(long, short), where the short form also matches bundled flags (-yq contains y). deps.detect_dep_install builds on this to recognise installs and the manifest files they need:

Command Manifests copied first
npm ci / npm install (no packages) package.json, package-lock.json
yarn / yarn install package.json, yarn.lock
pnpm install package.json, pnpm-lock.yaml
pip install -r F -c C F, C
poetry install --no-root pyproject.toml, poetry.lock
pipenv install Pipfile, Pipfile.lock
go mod download go.mod, go.sum
bundle install Gemfile, Gemfile.lock

Rule engine

A rule is a function check(ctx) -> Iterable[Hit] registered with @rule(id, name, severity, summary, explanation, fix, kind, fixable). The engine converts hits to findings, applies severity overrides, skips rules listed in ignore, per-file ignores and --ignore, and finally drops hits suppressed inline. Inline suppression looks at the hit's line for a trailing # dockwise ignore=... comment and walks upward over contiguous comment lines; Compose hits also check the service's own line, so a comment above web: silences the whole service.

Some rules need the build context: DW025 (no .dockerignore), DW026 (heavy directories like node_modules or .git, and secret files such as .env, that the ignore file does not exclude, with sizes) and DW035 (an npm install is only flagged when a lockfile exists). .dockerignore matching follows Docker: patterns are evaluated in order, the last match wins, ! negates, and a pattern matching a directory covers everything below it.

Compose

compose.load_compose_text composes the YAML node tree (for line numbers) and loads the data. String values are interpolated with .env values ($$ escapes a dollar): image: postgres:${PG_VERSION:-16} resolves to postgres:16, and ${TAG} with no value stays literal so tag checks skip it while DWC017 reports the undefined variable. ComposeFile.line("services", "db", "ports", 0) walks the node tree to the offending key or list item.

Layer optimizer

Docker rebuilds a layer when its own instruction changed or any earlier layer in the stage was rebuilt. For a COPY/ADD the inputs are the files copied; for everything else the inputs are only the parent layer. Therefore, for layer i:

rebuilds(i) = number of commits touching the union of all local COPY/ADD sources up to i

One git log --since="<N> days ago" --format=%H -- <paths> over the union gives the exact number of distinct commits (no double counting). When a source is the whole context (.), .dockerignore patterns are passed as :(exclude) pathspecs so churn that never reaches the image (docs, tests) does not count.

Worked example from the demo repository (27 commits in the window, 2 of which touched package.json or package-lock.json):

COPY . .            own 27   rebuilds 27
RUN npm install     inputs: package.json + lock -> 2 commits, but rebuilds 27

The recommendation compares rebuilds of the install layer with the commit count of just its manifests. Copying only the manifests first makes the install layer rebuild 2 times instead of 27, saving about 25 rebuilds. A second recommendation covers RUN layers that cannot read copied files (package manager installs, user creation): they can move above the first COPY and then never rebuild because of source changes. A third flags adjacent local COPYs where the first changes more often than the second.

Without git history the same algorithm runs on a heuristic weight per path (whole context 20, source paths 10, manifests and lockfiles 2), so the ordering advice still works but the counts are relative, not commits.