Skip to content

Commit 7ffefaa

Browse files
committed
feat(fingerprint): add hand-written projectV1 + golden vectors (PR A2)
Add the v1 projection layer beside the existing hashstructure path, additive and not yet wired into ComputeIdentity: - projectV1 + frozen nested sub-projectors, emitting measured fields by literal Go path under their frozen TOML emit-key - canonicalizeForFingerprint: reflective nil-or-empty scalar-slice normalizer at the hash boundary, pruning at fingerprint:"-" edges (cycle-safe, no field inventory) - fingerprint:"v1..*" tags on every measured field across the 10 fingerprinted structs; hazard comments on each '-'-pruned composite; Packages kept measured - golden vectors with an append-only -update-golden guard; emission probe; composite-'!' placeholder gate; mandatory-tag decision test - frozen maximalConfig vs growing emissionProbeConfig split + documented additive-field workflow and naming convention No lock byte or scenario snapshot moves; hashstructure untouched.
1 parent 5f515a6 commit 7ffefaa

14 files changed

Lines changed: 926 additions & 67 deletions

File tree

.github/instructions/go.instructions.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,18 @@ description: "Instructions for working on the azldev Go codebase. IMPORTANT: Alw
9898
}
9999
```
100100

101-
## Component Fingerprinting`fingerprint:"-"` Tags
101+
## Component Fingerprinting: version-set tags + `projectV1`
102102

103-
Structs in `internal/projectconfig/` are hashed by `hashstructure.Hash()` to detect component changes. Fields **included by default** (safe: false positive > false negative).
103+
Structs in `internal/projectconfig/` feed the component fingerprint, and **every field must carry an explicit `fingerprint` tag** - an absent tag fails `TestAllFingerprintedFieldsHaveDecision`. (The live hash is still `hashstructure.Hash()`; the hand-written `projectV1` in `internal/fingerprint/project.go` is wired in parallel and replaces it at the reset.)
104104

105-
When adding a new field to a fingerprinted struct, ask: **"Does changing this field change the build output?"**
106-
- **Yes** (build flags, spec source, defines, etc.) → do nothing, included automatically.
107-
- **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`.
105+
When adding a field to a fingerprinted struct, ask: **"Does changing this field change the build output?"**
106+
- **No** (human docs, scheduling hints, CI policy, metadata, back-references) → tag it `fingerprint:"-"` and register the exclusion in `expectedExclusions` in `internal/projectconfig/fingerprint_test.go`.
107+
- **Yes** (build flags, spec source, defines, etc.) → tag it `fingerprint:"v1..*"` (omit-if-zero) and wire it into `projectV1`: add its `emit`/sub-projector line, set it in `emissionProbeConfig`, then append a `<toml-key>-set` golden vector with `go test ./internal/fingerprint -run TestGoldenVectors -update-golden`. The golden table is append-only - never edit an existing digest. The full contract lives in `goldenConfigs`'s doc comment.
108+
- **Build-meaningful zero?** A field whose *zero value* changes the build (e.g. a `bool` where `false` is a deliberate setting) is tagged `fingerprint:"!v1..*"` (always-emit) and wired with `builder.emitAlways(...)`, **not** `builder.emit(...)`; 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).
108109
109-
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.
110+
If a parent field is already excluded (e.g. `Failure ComponentBuildFailureConfig ... fingerprint:"-"`), do **not** tag the inner struct's fields - the excluded subtree is pruned from both the walk and `projectV1`.
110111

111-
Run `mage unit` to verifythe guard test will catch unregistered exclusions or missing tags.
112+
Run `mage unit` to verify: the decision test catches a missing tag, the emission probe catches a measured-but-unemitted field, and the golden vectors catch a moved digest.
112113

113114
### Cmdline Returns
114115

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package fingerprint
5+
6+
import (
7+
"crypto/sha256"
8+
"encoding/hex"
9+
"encoding/json"
10+
"flag"
11+
"fmt"
12+
"os"
13+
"testing"
14+
15+
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
// updateGolden, when set, appends newly-named golden vectors to the frozen table.
21+
// It MUST NOT change an existing entry's digest - see goldenPath's header comment.
22+
// Bootstrap a missing table with: go test ./internal/fingerprint -run TestGoldenVectors -update-golden
23+
//
24+
//nolint:gochecknoglobals // a test flag must be a package-level var to register at init.
25+
var updateGolden = flag.Bool("update-golden", false, "append new golden fingerprint vectors")
26+
27+
// goldenPath is the golden-vector file for the current content version. Each
28+
// version pins its own file (golden_v1.json, golden_v2.json, ...); a new version
29+
// adds a file rather than rewriting an existing one.
30+
//
31+
//nolint:gochecknoglobals // derived from currentContentVersion; cannot be const.
32+
var goldenPath = fmt.Sprintf("testdata/golden_v%d.json", currentContentVersion)
33+
34+
// goldenConfigs is the freeze corpus for the current content version: one cutover
35+
// config (every measured field set) plus named edge configs the cutover case
36+
// cannot express at once.
37+
//
38+
// # Managing golden vectors (read before editing this map or testdata/golden_v<N>.json)
39+
//
40+
// A golden vector is a frozen (config -> digest) pair that pins a content
41+
// version's projection digest irreversibly. The freeze is append-only and these
42+
// rules are load-bearing because the encoding becomes a one-way door the moment a
43+
// version ships (see the lazy-schema-migration RFC):
44+
//
45+
// - One file per content version: testdata/golden_v1.json pins v1, a future
46+
// testdata/golden_v2.json pins v2, and so on. A new version ADDS its own file
47+
// and never rewrites an existing one; this harness reads the file for
48+
// currentContentVersion. (The future projection generator follows the same
49+
// rule - it emits golden_v<N>.json for the version it adds and leaves older
50+
// files untouched.)
51+
// - NEVER edit an existing entry's digest in testdata/golden_v<N>.json. The
52+
// -update-golden flag only APPENDS; a moved existing digest is a deliberate
53+
// FATAL. If an existing digest moves, your change was NOT output-preserving for
54+
// the fleet - that is a bug to fix, not a value to -update away.
55+
// - cutoverFieldSet (the "maximal" vector) is FROZEN: it is a corpus config, so
56+
// changing what it returns moves its pinned digest. Do not add fields to it.
57+
// - emissionProbeConfig GROWS instead: it sets every measured field so the
58+
// emission probe can assert each measured key is emitted. The two are split
59+
// precisely because one config cannot be both frozen and growing.
60+
//
61+
// Adding an omit-if-zero field "foo" (fingerprint:"v<N>..*", no version bump):
62+
// 1. add the field + tag and its projectV<N> emit line;
63+
// 2. set foo non-zero in emissionProbeConfig (the probe fails until you do - that
64+
// failure is the "you forgot to emit the new key" guard working);
65+
// 3. append a new named vector that sets foo in isolation, then run:
66+
// go test ./internal/fingerprint -run TestGoldenVectors -update-golden
67+
// 4. confirm the run APPENDS ONLY - every existing digest must be byte-identical.
68+
// Any movement of an existing entry is the bug, not the fix.
69+
//
70+
// NEVER retroactively measure a new field at a superseded version (e.g. editing a
71+
// frozen projectV<N> to read foo once v<N+1> exists). That changes the bytes that
72+
// version emitted in production, so every stored lock at that version becomes
73+
// unreproducible on replay (Problem 6) -> mass false-Stale. Omit-if-zero means no
74+
// pre-existing lock ever set foo, so each replays byte-identically without it; a
75+
// lock that later needs foo measured gets it by FORWARD migration, never by editing
76+
// the old version.
77+
//
78+
// Naming convention (semantic, not chronological - the date is already in git, and
79+
// a name must say what broke when a vector moves):
80+
// - "maximal" / "minimal": the frozen baselines, never changed.
81+
// - edge cases: name the property exercised ("defines-empty-value",
82+
// "single-overlay", ...).
83+
// - an additive field: "<toml-key>-set", one isolated vector per field.
84+
func goldenConfigs() map[string]projectconfig.ComponentConfig {
85+
return map[string]projectconfig.ComponentConfig{
86+
"maximal": cutoverFieldSet(),
87+
"minimal": {},
88+
"defines-empty-value": {Build: projectconfig.ComponentBuildConfig{Defines: map[string]string{"k": ""}}},
89+
"single-overlay": {Overlays: []projectconfig.ComponentOverlay{{
90+
Type: projectconfig.ComponentOverlayAddPatch, Filename: "p.patch",
91+
}}},
92+
}
93+
}
94+
95+
// TestGoldenVectors pins the v1 projection encoding irreversibly. The expected
96+
// digests are append-only: a commit that changes an existing entry fails (a
97+
// frozen v1 vector cannot move - a new encoding is a new version, not an edit).
98+
func TestGoldenVectors(t *testing.T) {
99+
computed := make(map[string]string)
100+
for name, cfg := range goldenConfigs() {
101+
computed[name] = projectionDigest(t, cfg)
102+
}
103+
104+
// S2: every vector that sets a measured field must differ from the empty
105+
// projection. A vector that collapses to `minimal` means a measured emit was
106+
// dropped or mis-wired (e.g. a duplicate emit-key the global probe misses, or a
107+
// '!' field that used emit instead of emitAlways and so dropped its zero value).
108+
// A deliberately projected-empty vector (none today) would opt out here.
109+
expectedEmptyProjection := map[string]bool{"minimal": true}
110+
for name, digest := range computed {
111+
if expectedEmptyProjection[name] {
112+
continue
113+
}
114+
115+
assert.NotEqualf(t, computed["minimal"], digest,
116+
"golden vector %q collapsed to the empty projection - a measured emit was dropped or mis-wired", name)
117+
}
118+
119+
frozen := loadGolden(t)
120+
121+
if *updateGolden {
122+
for name, digest := range computed {
123+
if old, ok := frozen[name]; ok && old != digest {
124+
t.Fatalf("append-only violation: vector %q would change\n from %s\n to %s\n"+
125+
"a frozen v1 vector cannot move; introduce a new version instead", name, old, digest)
126+
}
127+
128+
frozen[name] = digest
129+
}
130+
131+
writeGolden(t, frozen)
132+
133+
return
134+
}
135+
136+
for name, digest := range computed {
137+
expected, ok := frozen[name]
138+
require.Truef(t, ok, "golden vector %q is missing; bootstrap with -update-golden", name)
139+
assert.Equalf(t, expected, digest, "v1 projection for %q changed - this is a frozen byte encoding", name)
140+
}
141+
}
142+
143+
func projectionDigest(t *testing.T, cfg projectconfig.ComponentConfig) string {
144+
t.Helper()
145+
146+
digest := sha256.Sum256(mustProject(t, cfg))
147+
148+
return "sha256:" + hex.EncodeToString(digest[:])
149+
}
150+
151+
func loadGolden(t *testing.T) map[string]string {
152+
t.Helper()
153+
154+
data, err := os.ReadFile(goldenPath)
155+
if os.IsNotExist(err) {
156+
return make(map[string]string)
157+
}
158+
159+
require.NoError(t, err)
160+
161+
golden := make(map[string]string)
162+
require.NoError(t, json.Unmarshal(data, &golden))
163+
164+
return golden
165+
}
166+
167+
func writeGolden(t *testing.T, golden map[string]string) {
168+
t.Helper()
169+
170+
golden["_README"] = fmt.Sprintf("Frozen v%d projection digests. APPEND-ONLY: never edit an "+
171+
"existing digest - a new encoding is a new content version, not an edit here. "+
172+
"Management rules and the additive-field workflow live in goldenConfigs's doc "+
173+
"comment in golden_internal_test.go.", currentContentVersion)
174+
175+
// json.Marshal emits map keys sorted, giving a stable diff.
176+
data, err := json.MarshalIndent(golden, "", " ")
177+
require.NoError(t, err)
178+
179+
require.NoError(t, os.WriteFile(goldenPath, append(data, '\n'), 0o644)) //nolint:gosec // golden fixture, not a secret.
180+
}

internal/fingerprint/project.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package fingerprint
5+
6+
import (
7+
"fmt"
8+
"reflect"
9+
"strings"
10+
11+
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
12+
)
13+
14+
// currentContentVersion is the highest content version that exists. The reset
15+
// establishes v1, so projectV1 is the current (and only) projection and the tag
16+
// parser rejects any field that references a future version.
17+
const currentContentVersion = 1
18+
19+
// FingerprintedStructTypes returns every struct type whose fields participate in
20+
// the component fingerprint. It is the single source of truth that both the
21+
// mandatory-tag decision test (`projectconfig.TestAllFingerprintedFieldsHaveDecision`)
22+
// and the emission probe (`measuredLeafEmitKeys`) walk - keeping it in one
23+
// exported place is what stops those two walks from drifting silently (a struct
24+
// dropped from one but not the other would weaken coverage with no failure).
25+
//
26+
// It is hand-maintained until the projection generator (which would derive it
27+
// from projectV1 directly) lands. When you add a new struct type to the
28+
// fingerprinted graph, add it here - and nowhere else.
29+
func FingerprintedStructTypes() []reflect.Type {
30+
return []reflect.Type{
31+
reflect.TypeFor[projectconfig.ComponentConfig](),
32+
reflect.TypeFor[projectconfig.ComponentBuildConfig](),
33+
reflect.TypeFor[projectconfig.CheckConfig](),
34+
reflect.TypeFor[projectconfig.PackageConfig](),
35+
reflect.TypeFor[projectconfig.ComponentOverlay](),
36+
reflect.TypeFor[projectconfig.SpecSource](),
37+
reflect.TypeFor[projectconfig.DistroReference](),
38+
reflect.TypeFor[projectconfig.SourceFileReference](),
39+
reflect.TypeFor[projectconfig.ReleaseConfig](),
40+
reflect.TypeFor[projectconfig.ComponentRenderConfig](),
41+
}
42+
}
43+
44+
// ValidateFieldTag checks one fingerprinted struct field's tag at the current
45+
// content version. It enforces the mandatory-decision rule (an absent tag is an
46+
// error - every fingerprinted field must declare a version-set or exclusion),
47+
// rejects an unparseable / future-referencing tag, and requires a resolvable
48+
// emit-key for a measured field.
49+
//
50+
// It also gates the documented composite-'!' placeholder: a nested struct, map,
51+
// or slice-of-struct tagged with an always-emit range is not implemented at v1,
52+
// so it is rejected here rather than silently guessing an encoding.
53+
func ValidateFieldTag(field reflect.StructField) error {
54+
set, err := parseVersionSet(field.Tag.Get(fingerprintTagName), currentContentVersion)
55+
if err != nil {
56+
return fmt.Errorf("field %#q:\n%w", field.Name, err)
57+
}
58+
59+
if set.excluded {
60+
return nil
61+
}
62+
63+
if _, err := set.resolveEmitKey(tomlKeyOf(field)); err != nil {
64+
return fmt.Errorf("field %#q:\n%w", field.Name, err)
65+
}
66+
67+
if !isScalarLeaf(reflect.New(field.Type).Elem()) {
68+
for _, rng := range set.ranges {
69+
if rng.alwaysEmit {
70+
return fmt.Errorf(
71+
"field %#q: composite-'!' (always-emit nested struct, map, or slice-of-struct) "+
72+
"is not implemented at v1",
73+
field.Name)
74+
}
75+
}
76+
}
77+
78+
return nil
79+
}
80+
81+
// tomlKeyOf returns the field's bare toml key (the part before the first comma),
82+
// or "" when there is no toml tag.
83+
func tomlKeyOf(field reflect.StructField) string {
84+
key, _, _ := strings.Cut(field.Tag.Get("toml"), ",")
85+
86+
return key
87+
}

0 commit comments

Comments
 (0)