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
15 changes: 8 additions & 7 deletions .github/instructions/go.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,18 @@ description: "Instructions for working on the azldev Go codebase. IMPORTANT: Alw
}
```

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

Structs in `internal/projectconfig/` are hashed by `hashstructure.Hash()` to detect component changes. Fields **included by default** (safe: false positive > false negative).
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.)

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`.
When adding a field to a fingerprinted struct, ask: **"Does changing this field change the build output?"**
- **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`.
- **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.
- **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).

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.
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`.

Run `mage unit` to verifythe guard test will catch unregistered exclusions or missing tags.
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.

### Cmdline Returns

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ require (
github.com/go-playground/validator/v10 v10.30.3
github.com/google/renameio v1.0.1
github.com/google/uuid v1.6.0
github.com/gowebpki/jcs v1.0.1
github.com/h2non/gock v1.2.0
github.com/invopop/jsonschema v0.14.0
github.com/jedib0t/go-pretty/v6 v6.8.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU
github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU=
github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
Expand Down
302 changes: 302 additions & 0 deletions internal/fingerprint/canonical.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package fingerprint

import (
"fmt"
"reflect"
)

// maxSafeInteger is the largest integer magnitude (2^53) that survives RFC 8785's
// ECMAScript number serialization without precision loss. Note this is 2^53, one
// more than ECMAScript's Number.MAX_SAFE_INTEGER (2^53-1): 2^53 is itself exactly
// representable, and its only non-representable neighbour 2^53+1 is rejected by the
// strict ">" bound in scalarToJSON. An integer of greater magnitude is rejected
// rather than silently coerced - such a value is almost always an identifier or
// hash that belongs in the config as a string.
const maxSafeInteger = 1 << 53

// treeBuilder accumulates the canonical projection of a struct as a JSON-able
// map[string]any. The omit predicates mirror the frozen projection semantics:
//
// - emit: scalar leaf, omit-if-zero (the common "active field" path).
// - emitAlways: scalar leaf emitted even at its zero value (the '!' case, for a
// field whose zero is build-meaningful).
// - emitMap: scalar-valued map measuring key membership - every entry is kept
// (so {"k":""} differs from {}); the whole map is omitted only when empty.
// - emitComposite: nested-struct projection, omitted on projected emptiness (the
// sub-projector produced no measured keys).
// - emitSlice: struct-slice projection, omitted when empty; order is significant.
//
// Map key ordering is irrelevant here: RFC 8785 sorts object keys at
// serialization, so the builder need not sort. The first error is deferred
// (bufio-style) and surfaces from result, keeping the projectors readable.
type treeBuilder struct {
out map[string]any
err error
}

func (b *treeBuilder) set(key string, value any) {
if b.err != nil {
return
}

if b.out == nil {
b.out = map[string]any{}
}

b.out[key] = value
}

// emit adds a scalar-leaf field, omitting it when its resolved value is zero.
func (b *treeBuilder) emit(key string, value any) {
b.emitScalar(key, value, false)
}

// emitAlways adds a scalar-leaf field even when its value is zero (the '!' case).
func (b *treeBuilder) emitAlways(key string, value any) {
b.emitScalar(key, value, true)
}

func (b *treeBuilder) emitScalar(key string, value any, always bool) {
if b.err != nil {
return
}

rval := reflect.ValueOf(value)
if !rval.IsValid() {
b.err = fmt.Errorf("emit %#q: a nil value has no fingerprint encoding", key)

return
}

if !isScalarLeaf(rval) {
b.err = fmt.Errorf(
"emit %#q: kind %s is not an encodable scalar leaf; composites use emitComposite, emitSlice, or emitMap",
key, rval.Kind())

return
}

if !always && isScalarZero(rval) {
return
}

encoded, err := scalarToJSON(rval)
if err != nil {
b.err = fmt.Errorf("encoding field %#q:\n%w", key, err)

return
}

b.set(key, encoded)
}

// emitMap adds a scalar-leaf-valued map, measuring key membership: each value is a
// scalar or a scalar slice (matching scalarToJSON's leaf set). Interface-kind values
// (from a map[string]any) are unwrapped to their concrete value, as emitScalar does.
// Struct-valued maps are a composite and must be projected entry-by-entry through
// emitComposite.
func (b *treeBuilder) emitMap(key string, mapValue any) {
if b.err != nil {
return
}

rval := reflect.ValueOf(mapValue)
if !rval.IsValid() || rval.Kind() != reflect.Map {
b.err = fmt.Errorf("emitMap %#q: value is not a map", key)

return
}

if rval.Type().Key().Kind() != reflect.String {
b.err = fmt.Errorf("emitMap %#q: map key kind %s is not string", key, rval.Type().Key().Kind())

return
}

if rval.Len() == 0 {
return
}

entries := make(map[string]any, rval.Len())

iter := rval.MapRange()
for iter.Next() {
entryVal := iter.Value()
if entryVal.Kind() == reflect.Interface {
entryVal = entryVal.Elem()
}

if !isScalarLeaf(entryVal) {
b.err = fmt.Errorf(
"emitMap %#q entry %#q: kind %s is not an encodable scalar leaf; "+
"struct-valued maps use emitComposite",
key, iter.Key().String(), entryVal.Kind())

return
}

encoded, err := scalarToJSON(entryVal)
if err != nil {
b.err = fmt.Errorf("encoding map %#q entry %#q:\n%w", key, iter.Key().String(), err)

return
}

entries[iter.Key().String()] = encoded
}

b.set(key, entries)
}

// emitComposite adds a nested-struct projection, omitting it when the
// sub-projector produced no measured keys (projected emptiness). A non-nil err
// from the sub-projector is folded into the deferred error (wrapped with key), so
// a projector threads sub-projection errors without a manual check, under the same
// deferred-error discipline as the scalar emitters.
func (b *treeBuilder) emitComposite(key string, sub map[string]any, err error) {
if b.err != nil {
return
}

if err != nil {
b.err = fmt.Errorf("%s:\n%w", key, err)

return
}

if len(sub) == 0 {
return
}

b.set(key, sub)
}

// emitSlice adds a struct-slice projection, omitting it when empty. A JSON array
// preserves order, so reordering elements is a different encoding. A non-nil err
// from the element projector is folded into the deferred error (wrapped with key).
func (b *treeBuilder) emitSlice(key string, elems []any, err error) {
if b.err != nil {
return
}

if err != nil {
b.err = fmt.Errorf("%s:\n%w", key, err)

return
}

if len(elems) == 0 {
return
}

b.set(key, elems)
}

// result returns the accumulated projection map (nil when nothing was emitted)
// or the first deferred error.
func (b *treeBuilder) result() (map[string]any, error) {
if b.err != nil {
return nil, b.err
}

return b.out, nil
}

// scalarToJSON converts a scalar leaf to its JSON-able Go value by underlying
// kind, so a named type is encoded by kind rather than via any MarshalJSON /
// MarshalText it might carry. An integer beyond the JSON-safe range is rejected
// rather than silently coerced by RFC 8785's number model (see maxSafeInteger).
func scalarToJSON(rval reflect.Value) (any, error) {
//exhaustive:ignore // the default branch deliberately rejects every un-pinned kind.
switch rval.Kind() {
case reflect.String:
return rval.String(), nil
case reflect.Bool:
return rval.Bool(), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
number := rval.Int()
if number > maxSafeInteger || number < -maxSafeInteger {
return nil, fmt.Errorf(
"integer %d exceeds the JSON-safe magnitude 2^53; represent it as a string in the config schema", number)
}

return number, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
number := rval.Uint()
if number > maxSafeInteger {
return nil, fmt.Errorf(
"integer %d exceeds the JSON-safe magnitude 2^53; represent it as a string in the config schema", number)
}

return number, nil
case reflect.Slice:
if rval.Type().Elem().Kind() == reflect.Uint8 {
return nil, fmt.Errorf(
"%s is not encodable at v1: a byte slice ([]byte) must be represented as a string in the config schema",
rval.Type())
}

out := make([]any, 0, rval.Len())
for i := range rval.Len() {
elem, err := scalarToJSON(rval.Index(i))
if err != nil {
return nil, fmt.Errorf("slice element %d:\n%w", i, err)
}

out = append(out, elem)
}

return out, nil
default:
return nil, fmt.Errorf(
"kind %s is not encodable: only string, bool, JSON-safe integers, and slices of those are pinned",
rval.Kind())
}
}

// isScalarZero is the omit-if-zero predicate for a scalar leaf. A nil AND a
// non-nil empty scalar slice both count as zero, so emit cannot distinguish them
// (resolution's mergo WithAppendSlice merge yields either for the same intent).
// emitAlways bypasses this, so an explicit empty '!' slice still emits. Non-slice
// scalars fall back to plain IsZero, unchanged.
func isScalarZero(rval reflect.Value) bool {
if rval.Kind() == reflect.Slice {
return rval.Len() == 0
}

return rval.IsZero()
}

// isScalarLeaf reports whether v is a scalar leaf for omit-predicate purposes:
// a scalar kind, or a slice whose element kind is scalar. Composites (struct,
// map, slice-of-struct, pointer, interface, ...) are not scalar leaves.
func isScalarLeaf(rval reflect.Value) bool {
if isScalarKind(rval.Kind()) {
return true
}

if rval.Kind() == reflect.Slice {
return isScalarKind(rval.Type().Elem().Kind())
}

return false
}

// isScalarKind reports whether k is a scalar kind pinned by the v1 encoding:
// string, bool, and the sized signed/unsigned integers. Named
// scalar types (e.g. SpecSourceType) report their underlying kind here, so they
// are measured by their underlying kind rather than failing.
func isScalarKind(k reflect.Kind) bool {
//exhaustive:ignore // the default branch deliberately rejects every other kind.
switch k {
case reflect.String, reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return true
default:
return false
}
}
Loading
Loading