Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The `azldev-mage-builder` MCP server can run build commands without requesting p
- `mage check all` (NOT `golangci-lint run`) - Runs all quality checks
- `mage scenario` (NOT manual test commands) - Runs end-to-end tests (SLOW)
- `mage e2e` - Runs the heavier `//go:build e2e` tests against real upstream repos (NOT run by `mage scenario`/`mage all`; intended for CI only)
- `mage mutation ./internal/<pkg>` - Mutation testing (gremlins) to audit unit-test *quality* (does a test actually catch a bug?), not just coverage. Available as the `mage_mutation` MCP tool. Slower than unit tests; scope to a package for quick feedback (`./` runs the whole repo in a few min). Console shows only survivors/uncovered; full JSON report at `out/mutation-report.json`. On-demand audit tool, NOT part of `mage all`/CI. See `testing.instructions.md`.

- `run-azldev-from-out-bin` MCP server **IF** available (NOT `go run ./cmd/azldev` or `./azldev`)

Expand All @@ -30,6 +31,8 @@ Documentation structure: `docs/user/reference/cli/` (auto-generated CLI docs, re

The TOML config files in `defaultconfigs/` are loaded via `internal/projectconfig/`.

**CRITICAL when editing config structs or fingerprinting:** Adding a field to `ComponentConfig` or its component structs (or touching `projectV1` / fingerprint tags / golden vectors) has silent traps and required manual steps. You MUST read `.github/instructions/projectconfig-fingerprint.instructions.md` before such changes. This also applies to CONSUMERS of fingerprint data (e.g., checking changed components between two commits via lockfile data).

**IMPORTANT**: Code generation runs automatically with build/test commands. `mage generate` (runs `go generate` for each package in parallel) is a prerequisite for building and runs automatically with `mage build` and `mage unit`. `mage docs` rebuilds the binary and updates the JSON schema (`schemas/azldev.schema.json`) and CLI docs (`docs/user/reference/cli/`). Run `mage docs` explicitly after changing config structs or Cobra command descriptions so that checked-in generated files stay current (checked by PR gates).

**CRITICAL**: Run `mage scenarioUpdate` when test expectations change (updates snapshots).
Expand Down
19 changes: 8 additions & 11 deletions .github/instructions/go.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,19 +98,16 @@ description: "Instructions for working on the azldev Go codebase. IMPORTANT: Alw
}
```

## Component Fingerprinting — `fingerprint:"-"` Tags
## Component fingerprinting & config structs

Structs in `internal/projectconfig/` are hashed by `hashstructure.Hash()` to detect component changes. Fields **included by default** (safe: false positive > false negative).
Editing structs in `internal/projectconfig/` or the fingerprint substrate in
`internal/fingerprint/` - adding a field, touching `projectV1`, fingerprint tags,
or golden vectors - has silent traps where a field vanishes or a frozen byte moves
with the compiler still green. **Those rules live in
[`projectconfig-fingerprint.instructions.md`](./projectconfig-fingerprint.instructions.md)**,
read it before such changes.

When adding a new field to a fingerprinted struct, ask: **"Does changing this field change the build output?"**
- **Yes** (build flags, spec source, defines, etc.) → do nothing, included automatically.
- **No** (human docs, scheduling hints, CI policy, metadata, back-references) → add `fingerprint:"-"` to the struct tag and register the exclusion in `expectedExclusions` in `internal/projectconfig/fingerprint_test.go`.

If a parent struct field is already excluded (e.g. `Failure ComponentBuildFailureConfig ... fingerprint:"-"`), do **not** also tag the inner struct's fields — `hashstructure` skips the entire subtree.

Run `mage unit` to verify — the guard test will catch unregistered exclusions or missing tags.

### Cmdline Returns
## Cmdline Returns

CLI commands should return meaningful structured results. azldev has output formatting helpers to facilitate this (for example, `RunFunc*` wrappers handle formatting, so callers typically should not call `reflectable.FormatValue` directly).

Expand Down
133 changes: 133 additions & 0 deletions .github/instructions/projectconfig-fingerprint.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
applyTo: "internal/projectconfig/**/*.go,internal/fingerprint/**/*.go"
description: "Rules for interacting with project config structs and the fingerprint substrate. IMPORTANT: read before adding or changing any field on a config struct, or touching projectV1 / golden vectors / fingerprint tags."
---

# Config structs & fingerprinting

Editing config structs (`internal/projectconfig/`) and the fingerprint substrate
(`internal/fingerprint/`) has several **silent traps** - the compiler stays green
while a field quietly vanishes or a frozen byte moves. Follow these rules.

## `ComponentConfig.WithAbsolutePaths` hand-lists its fields

`(*ComponentConfig).WithAbsolutePaths` in `internal/projectconfig/component.go`
builds its result with an **explicit field-by-field struct literal**, not
`deep.MustCopy`. It is called for *every* component at load time (via
`mergeComponents` in `loader.go`), so any field missing from that literal is
**silently zeroed on load** - it parses from TOML fine, then disappears before
resolution, fingerprinting, or rendering. No error, no panic, just lost data.

> It is hand-written (unlike every sibling `WithAbsolutePaths`, which uses
> `deep.MustCopy`) for one reason: it must **alias** the cyclic, project-wide
> `SourceConfigFile` pointer rather than deep-copy it. Do not "simplify" it to
> a blanket `deep.MustCopy` - that reintroduces the cycle.

**Every new field on `ComponentConfig` MUST be added to this literal.**

## Adding a field to a config struct

1. **Add the field** with a `toml:` key and a **mandatory** `fingerprint:` tag.
An absent tag fails `TestAllFingerprintedFieldsHaveDecision`
(`internal/projectconfig/fingerprint_test.go`).
- Build input → `fingerprint:"v1..*"` (measured).
- Not a build input → `fingerprint:"-"` **and** register the field in
`expectedExclusions` in that same test file. If the field's **parent struct is
already excluded** (e.g. `ComponentBuildConfig.Failure`), do *not* tag the
inner struct's fields - that subtree is pruned from both the decision walk and
`projectV1`, so its fields are neither policed nor measured.
2. **If the field is on `ComponentConfig` directly → add it to
`WithAbsolutePaths`.** Skip this and it never loads.
3. **If you added a new struct *type* to the fingerprinted graph**, add it to
`FingerprintedStructTypes()` in `internal/fingerprint/project.go` - the single
source of truth that **both** the mandatory-tag decision test
(`internal/projectconfig/fingerprint_test.go`) and the emission probe
(`measuredLeafEmitKeys` in `internal/fingerprint/project_internal_test.go`) walk.
Add it there and nowhere else.
4. **If measured, emit it in `projectV1`** (`internal/fingerprint/project.go`,
hand-written as of v1) inside the correct sub-projector, under its **frozen `toml`
emit-key** (or an explicit `key=` in the tag - never the Go field name).
- Removing a measured field won't compile (good); a Go rename is byte-neutral
**only if** the `toml` key / `key=` is unchanged.
5. **Set the field non-zero in `emissionProbeConfig()`**
(`internal/fingerprint/project_internal_test.go`). The emission probe FAILS
until you do - that is the guard against "measured but forgot to emit".
6. **Append a `<toml-key>-set` golden vector** via
`go test ./internal/fingerprint -run TestGoldenVectors -update-golden`.
7. **Regenerate the schema in all three places** if the field is user-facing (has
a `toml` key / `jsonschema` tag): `mage docs` updates
`schemas/azldev.schema.json` **and** the CLI reference docs, while
**`mage scenarioUpdate`** updates the `config generate-schema` scenario
snapshots (`TestSnapshots*config_generate-schema*`), which embed the schema
again. Running only `mage docs` leaves those snapshots red. Also hand-update the
relevant `docs/user/reference/config/` page (e.g. `components.md`).

**Build-meaningful zero value?** A field whose *zero value* is itself a deliberate
build setting (e.g. a `bool` where `false` carries meaning) is tagged
`fingerprint:"!v1..*"` (always-emit) and wired with `builder.emitAlways(...)`, **not**
`builder.emit(...)` - the omit-if-zero `emit` would drop it at its zero value and lose
that signal. It MUST get a golden vector **at its zero value**; the
differ-from-`minimal` guard in `golden_internal_test.go` then proves it actually emits.

## Golden vectors: append-only, and the frozen/growing split

- `maximalConfig()` is **FROZEN** - it is a golden-corpus config at the time of
the initial implementation of that fingerprint algorithm, so its digest is
pinned. **Never add a new field to `maximalConfig()`** (it would move the frozen
`maximal` digest, a hard CI failure). Field growth goes in
`emissionProbeConfig()` only.
- Existing golden digests are **append-only**: `-update-golden` may add a new
`(name -> digest)` entry, but a CI check FATALs any commit that *modifies* an
existing digest. A moved frozen byte is a hard failure, not something a reviewer
must eyeball.
- A new **omit-if-zero measured field that no config sets is drift-neutral**: it
emits no bytes, so no existing lock should move and no component version bump
is needed. Only a component that actually sets the field drifts.
- **One isolation vector per new field is enough.** Add a single `<toml-key>-set`
vector that sets *only* the new field (like `single-overlay` /
`defines-empty-value`). Projection slots are length-prefixed and independent, so
a field's encoding is fully pinned in isolation - do **not** add a "maximal + new
field" or "previous + new field" combinatorial vector (and you cannot touch the
frozen `maximal`). Add *extra* vectors only for genuine internal discrimination
cases the field introduces (empty-vs-absent, map-key membership, etc.).

## Other config struct / fingerprinting pitfalls

- **Strict parsing.** The loader runs `DisallowUnknownFields()`
(`internal/projectconfig/loader.go`). Renaming or removing a `toml` key breaks
every existing on-disk config that still uses it. Load-time config migration is
deferred (lazy schema migration RFC PR E); until it exists, a `toml`-key rename
needs a coordinated, version-pinned rollout, not a bare rename.
(`fingerprint:"...,key=<old>"` keeps the *fingerprint* byte-neutral, but does
**not** keep old TOML parseable.)
- **`omitempty` + go-toml/v2 (`v2.3.1`).** Emptiness is decided by reflecting the
**exported** fields *before* any `TextMarshaler` runs. A struct that marshals via
`TextMarshaler` but holds its real value in an **unexported** field is treated as
empty and **dropped even when set**. Keep round-tripped state in an exported field
or a plain string.
- **`MergeUpdatesFrom` uses mergo with `WithOverride` + `WithAppendSlice`.** Slices
*append* across merge layers, and the same intent can resolve to `nil` *or* `[]`
depending on merge order. That is exactly why the projection's omit predicate
treats a nil-or-empty scalar slice as a zero value (both collapse to no emitted
bytes) at the hash boundary - do not rely on raw slice identity for a
fingerprinted field.
- **A nested-struct field surfaces asymmetrically in output.** `encoding/json`
`omitempty` does **not** drop an empty struct, so `component list -O json` shows
`"foo":{}` on every component, while `config dump` (go-toml) *does* honor struct
`omitempty` and omits it. Neither is fingerprint drift (the projector omits on
projected emptiness) - don't mistake the JSON `{}` for churn.

## Standing invariants (do not break)

- **No reader recomputes a historical fingerprint.** Synthetic history and
historic-overlay application read **stored** lock strings only
(`synthistory.go`); only `computeCurrentFingerprint` (current tree) and the
`update` re-stamp call `ComputeIdentity`.
- **Stored fingerprint is the atomic `v1:sha256:<digest>` token**; lock *format*
`version` stays `1` (the content version lives in the token prefix). A pre-`v1`
(prefix-less) token reconciles by **force-rehash** - the existing string
inequality re-stamps it; do not make that compare version-aware before the replay
registry (RFC PR C) exists.
- **`projectV1` output is frozen.** A new byte encoding is a new content version,
never an edit to `projectV1`'s output. The golden vectors enforce this. The only
allowed changes to `projectV1` are purely additive.
26 changes: 26 additions & 0 deletions .github/instructions/testing.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ repositories (e.g. `microsoft/azurelinux`) and can be expensive. They reuse
the same scenario container framework (`cmdtest`/`containertest`) but skip the
in-memory project synthesis layer.

## Assessing Test Quality (Mutation Testing)

Coverage shows whether a line *ran*; mutation testing shows whether a test would
*catch a bug* in it. Run [gremlins](https://github.com/go-gremlins/gremlins) via:

- `mage mutation ./internal/<pkg>` — scope to a package for quick feedback (pass `./` to run the
whole repo, which takes a few minutes).
- `mage mutationDiff <ref>` — only mutate lines changed vs a git ref (e.g. `main`).

Also available to agents as the `mage_mutation` MCP tool (takes a `path`). The console lists only
the mutants worth acting on (LIVED + NOT COVERED) plus a summary; a full JSON report covering every
mutant is written to `out/mutation-report.json`.

Reading results: a **KILLED** mutant was caught by a test, a **LIVED** mutant is a
real assertion gap to fix, **NOT COVERED** means no test exercises that code.

Caveats — agents MUST respect these:
- Only **unit tests** run; scenario/e2e tests are build-tag gated and never execute.
The `scenario/` and `magefiles/` trees and generated `*_mocks.go` are excluded.
- Some **LIVED mutants are equivalent** (e.g. `len(x) > 0` vs `>= 0` when the loop
body no-ops on an empty collection) and cannot be killed — inspect before adding a
test; do NOT contort tests to kill an equivalent mutant.
- It is **slower than unit tests** and **not a CI gate** — an on-demand audit tool. Do NOT add it
to `mage all` or block on it.

## Test Environment

Use `testutils.NewTestEnv(t)` for tests that need an `azldev.Env`. It provides:
Expand Down Expand Up @@ -190,6 +215,7 @@ New component subcommands (`internal/app/azldev/cmds/component/`) require:
- `mage scenarioUpdate` — update snapshot baselines (review diffs!)
- `mage generate` — standalone codegen (also runs implicitly with unit/build)
- `mage docs` — build binary + regenerate CLI docs and JSON schema
- `mage mutation ./internal/<pkg>` / `mage mutationDiff <ref>` — mutation testing to audit unit-test quality (SLOW; scope required; see "Assessing Test Quality" above)

## Common Pitfalls

Expand Down
43 changes: 43 additions & 0 deletions docs/developer/how-to/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This document provides guidelines for writing and maintaining tests within the A

- [Unit Tests](#unit-tests)
- [Scenario Tests](#scenario-tests)
- [Mutation Testing](#mutation-testing)
- [Test Utilities](#test-utilities)
- [Mocking dependencies](#mocking-dependencies)
- [Generating Mocks](#generating-mocks)
Expand Down Expand Up @@ -35,6 +36,48 @@ This document provides guidelines for writing and maintaining tests within the A
- Use `mage scenarioUpdate` when test expectations change and snapshots need updating
- Use existing test helpers in `scenario/internal/cmdtest`

## Mutation Testing

Mutation testing measures how good the tests actually are (as opposed to mere line coverage) by
introducing small changes ("mutants") into the source and checking whether the test suite catches
them. A mutant that tests fail on is *killed*; one that passes is a *lived* mutant and points to a
real gap in test assertions. The project uses [gremlins](https://github.com/go-gremlins/gremlins),
pinned in `tools/gremlins`.

Mutation testing recompiles and reruns the tests for every mutant, so it's slower than unit tests;
running the whole repo (`./`) takes a few minutes. Scope it for quicker feedback:

```bash
# Scope to a single package.
mage mutation ./internal/rpm

# Scope to only the lines changed relative to a git ref (fastest; ideal on a branch).
mage mutationDiff main
```

Interpreting the output:

- **Test efficacy** is the percentage of `KILLED` mutants over `KILLED + LIVED`. Higher is better.
- **LIVED** mutants are the actionable signal: a change the tests did not detect.
- **NOT COVERED** mutants are in code with no test coverage at all.
- Some `LIVED` mutants are *equivalent* (e.g. `len(x) > 0` vs `>= 0` when the loop body no-ops on an
empty slice) and cannot be killed. Inspect before "fixing".

The console output is filtered to just the actionable mutants (`LIVED` and `NOT COVERED`) plus the
summary totals; a full JSON report covering every mutant (including `KILLED`) is written to
`out/mutation-report.json` for tooling or detailed review.

Mutation testing only exercises **unit tests**. Scenario tests are gated behind the `scenario` build
tag and are not run by gremlins, so they never slow down a mutation run. The targets also exclude the
`scenario/` and `magefiles/` trees and generated mocks from the mutant set to avoid `NOT COVERED`
noise.

> **Note**: The mage targets pass a generous `--timeout-coefficient` to gremlins. The per-mutant
> timeout is derived from the baseline test duration, but each mutant also recompiles; on this
> repo's fast suites the default timeout is too tight to cover that build, so mutants get
> mis-reported as `TIMED OUT` (which gremlins counts as killed, inflating efficacy to a bogus
> 100%). If you run `gremlins` directly, pass `--timeout-coefficient 20` to get accurate results.

## Test Utilities

- Create reusable test fixtures when appropriate
Expand Down
2 changes: 2 additions & 0 deletions docs/developer/reference/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ This repo contains configs for the `golangci-lint` linter. It is installed as pa
| `mage unit` | Run unit tests | After writing tests |
| `mage scenario` | Run scenario tests (SLOW) | For major changes |
| `mage scenarioUpdate` | Update test snapshots | When test expectations change |
| `mage mutation <path>` | Run mutation testing on a package; writes `out/mutation-report.json` | To assess test quality of a package |
| `mage mutationDiff <ref>` | Run mutation testing on lines changed vs a git ref | To check test quality of a branch's changes |
| `mage check all` | Run all quality checks | Before committing |
| `mage fix all` | Auto-fix code issues | When linting fails |
| `mage generate` | Run `go generate ./...` (mockgen, stringer, etc.) | Seldom needed directly; runs automatically with build/test |
Expand Down
Loading
Loading