# Nic Code Review

> Workflow, guardrails, and output format for reviewing NIC pull requests. Use when reviewing a PR locally (Copilot Chat, Claude, or other agent), when running the pr-review prompt, or when acting as the GitHub Copilot Code Review bot. Delegates codebase-specific detail to the domain skills (nic-structure, nic-add-feature, nic-add-policy, nic-docker-images, nic-ci-pipelines, nic-testing) rather than duplicating them.

- Skill: `nginx/nic-code-review` (Agent Skill)
- Install (CLI): `npx skillmds@latest add nginx/nic-code-review`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nginx/nic-code-review/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: nginx (https://skillmd.com/u/nginx)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/nginx/nic-code-review

---


# NIC Code Review

This skill defines **how** to review a NIC PR: the workflow, guardrails, dimension coverage, and output format. It intentionally does **not** restate the codebase-specific rules that already live in the domain skills -- load the referenced skill for depth on any topic. If you find yourself wanting to add a paragraph of file paths or function names here, add it to the relevant domain skill instead.

## When this skill applies

- Local review inside VS Code / IDE (Copilot Chat, Claude, or any agent)
- `.github/prompts/pr-review.prompt.md` invocation
- GitHub Copilot Code Review bot (reads `.github/copilot-instructions.md`, which references this skill)
- Any request phrased as "review this PR", "review the diff", "review my branch"

## Review guardrails

- Comment only at **>80% confidence**. If unsure, skip.
- Be **concise, actionable, file+line specific**. Point at the fix, not the theory.
- Prefer **one strong comment** over many weak ones.
- Do **not** rewrite the diff for the author, instead suggest the change and let them apply it.
- Do **not** compliment, restate the diff, or narrate what the PR does.
- Never post secrets, tokens, license keys, or any credential value in a review comment.
- Do not fabricate file paths, symbol names, or line numbers. Always verify before citing.

## Verify before flagging

Before writing any comment, you must confirm the claim against actual code or config. Speculation is not review. If you cannot verify it, do not write it.

| If the comment claims... | You must first... |
| --- | --- |
| "X is not tracked / covered / handled by tool Y" | Read Y's config (`renovate.json`, `.golangci.yml`, `Makefile`, workflow file). Default managers cover more than you think. |
| "This library / action does Z on failure / edge case" | Read the library docs or source, or find an existing call site in the repo that proves the behaviour. |
| "This shell / expression / YAML will evaluate as W" | Trace it end-to-end. GitHub Actions expression semantics, bash quoting, and YAML type coercion all have non-obvious rules. |
| "This is a security issue because untrusted input reaches sink S" | Identify the actual trust boundary. Inputs from repo-controlled workflows, composite action callers inside the same repo, and matrix values are not "untrusted" in the OWASP sense. |
| "The generated file / snapshot is wrong" | Re-run the generator (`make update-codegen`, `make update-crds`, `make telemetry-schema`, `make test-update-snaps`) and diff. Comment on the source, not the artifact. |
| "This will break at runtime" | Grep for at least one caller. Read the surrounding function. A missing nil check may already be guarded upstream. |
| "This NGINX directive does/does not do X" | Look it up on <https://nginx.org/en/docs/> (or the NGINX Plus docs for Plus-only directives) **before** commenting. Quote the directive's context, default, and version. |
| "This directive is allowed in this context" | Check the directive's `Context:` line in the nginx docs. `http`, `server`, `location`, `stream`, `upstream` are not interchangeable, and a wrong-context directive fails `nginx -t` at reload, not at build time. |
| "This is not how NIC exposes this feature" | Check <https://docs.nginx.com/nginx-ingress-controller/> and the existing annotation / CRD field for the same capability before claiming a new API is redundant or misnamed. |

If verification is impractical (e.g. it requires running the CI), drop the finding. Do not file it with a hedge.

## Verify against upstream NGINX before reviewing config behaviour

NIC generates NGINX configuration. A review that reasons about NGINX semantics from memory is unreliable -- directive contexts, defaults, and Plus-vs-OSS availability change between versions. Consult the authoritative source, then comment.

| What you need to know | Authoritative source |
| --- | --- |
| Does this directive exist? What is its context, syntax and default? | <https://nginx.org/en/docs/dirindex.html> |
| What do these variables resolve to? | <https://nginx.org/en/docs/varindex.html> |
| Is this module available in the OSS build we ship? | <https://nginx.org/en/docs/> module page + `build/Dockerfile` package list |
| Is this directive / module Plus-only? | <https://docs.nginx.com/nginx/admin-guide/> and the `nginx-plus` template variant |
| Exact upstream behaviour or edge case not covered by the docs | <https://github.com/nginx/nginx> source, or `njs` docs at <https://nginx.org/en/docs/njs/> |
| How does NIC already expose this? | <https://docs.nginx.com/nginx-ingress-controller/> plus `internal/configs/annotations.go` and `pkg/apis/configuration/v1/types.go` |
| NGINX App Protect WAF / DoS behaviour | <https://docs.nginx.com/nginx-app-protect-waf/> and <https://docs.nginx.com/nginx-app-protect-dos/> |

Rules for using these sources:

- **Check before you flag, and check before you approve.** A generated directive that is syntactically valid but in the wrong context still breaks the reload -- that is a Blocking finding, and it is only findable by reading the docs.
- When a finding rests on upstream behaviour, **cite the source** in the bullet so the author can verify it in one click.
- If the docs and the diff disagree, prefer the docs -- unless the PR description explains a deliberate deviation, in which case drop it.
- Do not cite a doc page you did not read. Fabricated citations are worse than no citation.
- Plus-only directives must appear only in `nginx-plus.*.tmpl`. If one leaks into the OSS template, NGINX OSS fails to start -- always Blocking.

## Confidence downgrades

Downgrade or drop a finding when any of these apply:

- The bug depends on a code path you have not read end-to-end -> drop.
- The behaviour depends on external tool internals (BuildKit cache, Docker registry retry, Kubernetes API server ordering) -> drop, unless you can cite the docs.
- The "vulnerability" requires an attacker who already controls the repo / workflow file -> Non-blocking hygiene note at most.
- The finding is "this could be better" without a concrete failure mode -> drop.

## Review workflow

1. **Read the PR title, description, and linked issue.** Understand intent before reading the diff.
2. **Get the diff.** Locally: `git diff origin/main...HEAD` or `gh pr diff <n>`. In agent context, use the `get_changed_files` tool.
3. **Classify the change** using the table below to pick the right sub-skills.
4. **Read the surrounding code**, not just the diff hunks, context often lives in the same file just outside the hunk.
5. **Verify NGINX / NIC semantics upstream** for any change that reaches a `.tmpl` file, an annotation, or a CRD field -- see the source table above.
6. **Walk the review dimensions** in order (Security -> Correctness -> Architecture -> Tests -> Build/chart/CI -> Docs and Examples), loading the referenced skills for depth.
7. **Verify claims before commenting.** Grep for the symbol, read the referenced file, run `make lint`/`make test` if in doubt.
8. **Run the completeness gate** below before writing anything.
9. **Produce the review** in the Output Format below.

## Severity ladder

Two severities, nothing else. No "nit", "minor", "praise", "FYI", "possibly blocking". No overall verdict such as "approve" or "request changes" -- the findings are the review.

| Severity | Test it must pass |
| --- | --- |
| **Blocking** | Verified, and you can name the trigger, the failure, and the blast radius in one sentence. Coverage gaps are blocking too. |
| **Non-blocking** | Verified, but the worst case is confusing code or future maintenance |

Unverified -> do not write it. There is no third bucket for hunches.

**Verified** means one of: a call site you read, command output (`make test`, `nginx -t`, `git diff`), or a doc page you opened. Reading the diff is not verification.

Blocking means something breaks for someone: NGINX fails to reload, a credential lands in an image layer, a generated artifact ships stale, a sanitisation guard is missing, or no test proves the new behaviour works. This applies equally to Go code, templates, the chart and workflow files -- a mutable action tag on a job holding `id-token: write` is as blocking as a Plus-only directive in an OSS template.

Non-blocking means the code works today but will cost someone time later: an error that drops its cause, a workflow condition that re-derives a value already exported as a job output, a missing negative test on a non-security path.

### Fixed verdicts

These recur in NIC. The verdict is settled -- do not re-litigate it per PR.

| Situation | Verdict |
| --- | --- |
| Plus-only directive reachable from an OSS template | Blocking -- NGINX OSS refuses to start |
| `.tmpl` edited, `__snapshots__` unchanged | Blocking -- no fixture exercises the new branch |
| `.tmpl` edited, snapshots regenerated, but no fixture field added | Blocking -- same defect, hidden by a reformat-only diff |
| Shared directive added to only one of the OSS/Plus template pair | Blocking -- edition drift |
| Plus-only directive added to the Plus template only, OSS snapshot unchanged | **Not a finding** -- this is correct |
| `types.go` changed without regenerated `pkg/**` or `config/crd/bases` | Blocking -- cite it even though `verify-codegen` also fails; you save a CI round-trip |
| `types.go` changed without regenerated `deploy/crds*.yaml` or `docs/crd/` | Blocking -- **CI never diffs these**, so stale bundles ship silently |
| Telemetry `Data`/`NICResourceCounts` changed without `make telemetry-schema` | Blocking -- `verify-codegen` fails |
| New pytest marker missing from `pyproject.toml` | Blocking -- `--strict-markers` fails the entire suite, not just the new test |
| `values.yaml` value's **type or shape changed** without updating `values.schema.json` | Blocking -- schema validation rejects the render and `helm install` fails |
| **New** `values.yaml` key absent from `values.schema.json` | Non-blocking -- the root schema has no `additionalProperties: false`, so it installs but gets no validation. Blocking only under `hostPort`/`containerPort`, which do set it |
| Plus credentials via `COPY` instead of `--secret` | Blocking -- credential persists in the image layer |
| GitHub Action pinned to a tag or branch instead of a SHA | Blocking -- supply chain |
| New workflow job missing its `github.repository` gate | Blocking -- `validate-workflow-gating.sh` fails and the job would run on forks |
| `docker build` step added to a publish-stage workflow | Blocking -- violates the internal/public repo split |
| User-controlled string reaching NGINX config with no `containsDangerousChars()`/`ValidateEscapedString()` guard | Blocking -- injection |
| Security or validation path changed with no negative test | Blocking |
| Error not wrapped with `%w` | Non-blocking -- **unless** a caller uses `errors.Is`/`errors.As` on it, then Blocking |
| `//nolint:gosec` without a same-line justification | Non-blocking |
| Missing negative test on a non-security path | Non-blocking |
| Naming or duplication | Non-blocking, and only with a named drift scenario. Otherwise drop |
| Formatting, import order, `golangci-lint`-enforced style | Drop -- tooling owns it |
| Contents of a generated file look wrong | Drop -- comment on the source that generated it |
| "This could be better" with no failure mode | Drop |
| Behaviour you could not trace to a call site | Drop |
| Deviation that looks deliberate but is explained nowhere | Drop |

### Tie-breaks

- Two rows disagree -> the **higher** severity wins.
- One defect is one bullet, even if it spans four lines.
- Never soften because the PR is large, urgent, or authored by a maintainer.
- More than five findings -> say so in one Summary line and list only the Blocking ones.

## Completeness gate

Before producing output, confirm you have checked each row that the diff touches. A silently missing artifact is the most common real defect in this repo and the easiest to miss by only reading the diff.

| If the diff touches... | Confirm the PR also contains... |
| --- | --- |
| Any `*.tmpl` | Regenerated `__snapshots__` **and** a new/extended fixture that renders the new directive. An unchanged snapshot after a template edit means the branch is untested -- Blocking |
| A template struct (`version1/config.go`, `version2/http.go`, `version2/stream.go`) | Snapshot diff showing the field rendered |
| One of `nginx.*.tmpl` / `nginx-plus.*.tmpl` | The sibling template updated, unless the directive is Plus-only -- then confirm it appears in the Plus template only |
| `pkg/apis/**/types.go` | Regenerated `pkg/**` (`make update-codegen`) and `config/crd/bases` (`make update-crds`). `deploy/crds*.yaml` and `docs/crd/` are regenerated by the same target but are **not** diffed by CI -- check them by hand |
| Telemetry `Data` / `NICResourceCounts` | Regenerated `internal/telemetry/*_generated.go` and `data.avdl` (`make telemetry-schema`) |
| `charts/nginx-ingress/values.yaml` | Matching `values.schema.json` entry, testdata file, helmunit case, `charts/tests/__snapshots__` diff. A **changed type/shape** without a schema update is Blocking; a **new key** absent from the schema is Non-blocking |
| Chart workload templates | All three of deployment / daemonset / statefulset, where the helper is shared |
| New `@pytest.mark.<name>` | Marker registered in `pyproject.toml` (`--strict-markers` is on) |
| Imports / dependencies | `go.mod` and `go.sum` tidy |
| `.github/workflows/**` | Correct `github.repository` gate for the stage (internal repo builds, public repo publishes), pinned action SHAs, matrix JSON in sync |
| A new user-controlled string reaching NGINX config | A `containsDangerousChars()` / `ValidateEscapedString()` guard **and** a negative test |

An unmet row is Blocking unless the Fixed verdicts table above assigns it a lower severity. Cite the missing artifact by path.

## Change type classification

Use this table to pick which domain skills to load; the referenced skill owns the up-to-date rules for that area.

| Change touches | Focus for the review | Cross-reference skill |
| --- | --- | --- |
| CRD types (`pkg/apis/**/types.go`) | CRD field, codegen, validation | `nic-add-feature`, `nic-add-policy` |
| Validation (`pkg/apis/**/validation/**`) | Validation, security (input sanitisation) | `nic-add-feature` |
| Controller (`internal/k8s/**`) | Sync flow, concurrency, secret handling | `nic-structure` |
| Config generation (`internal/configs/**` non-template) | Config assembly, layer boundary | `nic-structure` |
| Ingress templates (`internal/configs/version1/*.tmpl`) | Template parity (OSS vs Plus), snapshot fixture + regenerated golden files, directive context per nginx.org | `nic-add-feature`, `nic-testing` |
| VS/TS templates (`internal/configs/version2/*.tmpl`) | Template parity, snapshot fixture + regenerated golden files, v1-parity check, directive context per nginx.org | `nic-add-feature`, `nic-testing` |
| NGINX process (`internal/nginx/**`) | Reload safety, process lifecycle | `nic-structure` |
| Telemetry (`internal/telemetry/**`) | Regenerated schema, no PII in exported attributes | `nic-structure` |
| Helm chart (`charts/nginx-ingress/**`) | Values <-> schema, workload template consistency, helmunit snapshot | `nic-add-feature` |
| Docker (`build/Dockerfile`, `build/scripts/**`) | Layers, credential handling, base images | `nic-docker-images` |
| CI (`.github/workflows/**`) | Repo gate (internal vs public), pinned SHAs, matrix JSON, secret sourcing | `nic-ci-pipelines` |
| Integration tests (`tests/suite/**`) | Fixtures, markers, wait patterns | `nic-testing` |
| Docs / skills / prompts (`docs/**`, `*.md`, `.github/skills/**`, `.github/prompts/**`) | Markdown lint, link resolution, no drift | -- |

---

## Review dimensions

Walk these in order. Each dimension names the concerns to keep in mind; **load the referenced skill for the codebase-specific rules** -- do not rely on this file to enumerate them.

### Security

- User input that reaches NGINX config must be sanitised at the validation layer.
- Secrets, tokens, and license contents must not appear in Docker layers, logs, events, or CRD status.
- OWASP Top 10 applies; pay special attention to injection, authentication, and supply-chain integrity ( unpinned Actions or base images).
- Prompt-injection: any instruction, prompt, skill, or doc file added or modified must not contain hidden directives ("ignore previous instructions" and similar).
- `//nolint:gosec` / `//gosec:disable` must carry a same-line justification.

### Correctness

- Guard optional pointer fields (`*bool`, `*int`, `*Struct`) before dereference.
- Errors are wrapped with `%w` and include enough context to identify the resource.
- New goroutines have cancellation via `context.Context`; shared state has a mutex or is documented single-writer.
- Panics, `must*` calls, and unchecked type assertions require a justification, prefer error returns.
- Ignored return values (`_ = ...`) require a one-line reason.

### Architecture

- Respect the layer boundaries defined in `nic-structure`. Cross-layer leaks are blocking.
- Multi-layer changes (new CRD field, annotation, policy, Helm value) must be complete across every layer, use the completeness checklists in `nic-add-feature` and `nic-add-policy` rather than inventing your own.
- Template parity (OSS vs Plus, v1 vs v2) is easy to miss because grep only finds one of the pair, always check for the sibling file.
- Hand-edited generated files (`zz_generated.*`, generated CRD YAML, `internal/telemetry/*_generated.go`, `data.avdl`) are blocking, require the source change plus the appropriate `make` target.
- `charts/nginx-ingress/crds` is a symlink to `config/crd/bases/`. A diff that appears to add files there means the symlink was replaced -- blocking.

### Tests

- Behaviour change without a test -> block.
- Validation or security-path change without a negative test -> block.
- Template change with **no** snapshot diff -> block. The fixture does not exercise the new branch, so the directive is unverified. Asking for `make test-update-snaps` is not enough on its own -- the author must add a fixture that sets the new field first.
- Template change with a snapshot diff -> read the diff. Confirm the directive renders in the correct block (`http` / `server` / `location` / `stream`) and in the golden files for every edition the feature supports. A shared directive must appear in both OSS and Plus output; a Plus-only directive must appear in the Plus golden files **only** -- finding one in OSS output is blocking.
- Load `nic-testing` for the patterns (table-driven, snapshot, helmunit, pytest markers).

### Build, chart, CI

- Docker: load `nic-docker-images`. Highest-severity findings are credential leaks (`--secret` mount vs `COPY`) and unpinned bases.
- Helm: load `nic-add-feature`. A `values.yaml` type/shape change without a matching `values.schema.json` update breaks `helm install`; a new key missing from the schema only loses validation coverage. Treat them at the severities in the Fixed verdicts table.
- CI: load `nic-ci-pipelines`. Highest-severity findings are unpinned Actions, repository-secret usage instead of the OIDC / Key Vault flow, and a wrong `github.repository` gate -- release *builds* belong to `nginx/kubernetes-ingress-internal`, release *publishing* to the public repo. A `docker build` step added to a publish-stage workflow is blocking.

### Docs and Markdown

- No hard-coded product versions in evergreen docs -- reference `.github/data/version.txt` or the Renovate-managed pin.
- Table separator rows are `| --- | --- |` (MD060).
- Skill front matter needs `name:` and `description:`, and the description must state **when** to invoke the skill.
- Links in reviewed docs must resolve to real workspace paths.

---

## Do NOT comment on

- Formatting -- `make format` handles it.
- Import ordering -- goimports handles it.
- Style preferences already enforced by `golangci-lint`.
- Auto-generated files (`zz_generated.deepcopy.go`, `pkg/client/**`, `config/crd/bases/**`, chart CRDs, `internal/telemetry/*_generated.go`, snapshot files). If they look wrong, comment on the source that generated them.
- Test fixture YAMLs that only add data.
- Individual snapshot diff lines -- comment on the template change that produced them. (A *missing* snapshot diff is still a finding; see the completeness gate.)
- Personal preference nits ("I would name this X"). Suggest only if it hurts correctness or clarity.

### Common AI false-positive patterns to avoid

These are failure modes reviewers repeatedly hit. Skip the comment when you notice one.

- **Tooling-gap claims without reading the config.** ("Renovate won't update this", "golangci-lint doesn't cover that.") Read the config first, or omit the claim.
- **"Might break" without a call site.** If you cannot name a caller that hits the path, do not file it as Blocking.
- **Security theatre on internal inputs.** Shell injection warnings for values that come from the same repo's workflow files are hygiene at best, not vulnerabilities.
- **Speculating on library internals.** "BuildKit might corrupt the cache", "the client-go informer might miss the event" -- if you cannot cite the docs or source, drop it.
- **Duplicated / overlapping suggestions.** Merge related bullets into one; do not repeat the same fix on three lines of the same file.
- **Correcting yourself mid-review.** If you notice a finding is wrong while writing it, delete it. Do not ship "*(self-correction: not blocking)*" bullets.
- **Restating docs / obvious intent.** If the diff has a comment or PR description that explains the choice, do not challenge it without new information.

---

## Output format

Structure the review as follows. Omit any empty section.

```markdown
### Summary

One or two sentences: what the PR does and whether anything blocks merge.

### Blocking

- [file/path.go:LN](file/path.go#LN) -- Trigger, failure, blast radius. Suggested fix in one line.

### Non-blocking

- [file/path.go:LN](file/path.go#LN) -- Suggestion, one line.
```

Rules:

- Use workspace-relative paths in links.
- Group by severity, not by file.
- Each bullet is one line. If it needs more, it belongs in a follow-up comment on the PR, not the summary.
- When a finding rests on NGINX or NIC documented behaviour, append the source link to the bullet (e.g. `-- see <https://nginx.org/en/docs/http/ngx_http_core_module.html#location>`). Only link pages you actually read.
- If there is nothing to say in a section, omit the heading.

---

## Local invocation examples

- "Review my current branch against main"
- "Run the pr-review skill on this diff"

## GitHub Copilot Code Review bot

The bot reads `.github/copilot-instructions.md` on every PR. The `Skills` and `Code Review Checklist` sections there reference this file, so keep this skill authoritative and keep `copilot-instructions.md` short.

