Skip to content

Commit 04a204c

Browse files
aledbfclaude
andcommitted
test(harness): compare_nulls — cazar present-null vs absent-key (wave B #4)
normalizeValue dropeaba los null de los mapas JSON, así que un campo que TS emite como null y Go omite (o viceversa) comparaba igual. Nuevo opt-out 'compare_nulls: true' que conserva los null. Aplicado a los casos-envelope resolve-dependencies-success y read-configuration.features-configuration — pasan, o sea TS/Go coinciden en null-vs-absent y ahora se compara de verdad. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 36098ba commit 04a204c

2 files changed

Lines changed: 26 additions & 16 deletions

File tree

‎docs/migration/parity-matrix.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ initial_cases:
447447
class: feature-resolution
448448
current_status: match
449449
compare_hashes: true
450+
compare_nulls: true
450451

451452
- id: read-configuration.compose-path-terminal
452453
lane: semantic
@@ -2314,6 +2315,7 @@ initial_cases:
23142315
class: features-runtime
23152316
current_status: match
23162317
compare_hashes: true
2318+
compare_nulls: true
23172319
notes: "stdout = grafo mermaid + JSON installOrder. El mermaid se genera con roundPriority 0 (default TS) y alias del feature id (fallback a ref.ID cuando falta la annotation); los hashes de nodo se scrubbean por reMermaidNode. installOrder resuelto vía ComputeInstallationOrder."
23182320

23192321
- id: templates.metadata-success

‎internal/cli/parity_matrix_test.go‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ type parityCase struct {
7070
// integrity) — otherwise a wrong Go digest normalizes to <HASH> and passes.
7171
// Leave it false where digests are non-deterministic (tarball push mtime).
7272
CompareHashes bool `yaml:"compare_hashes"`
73+
// CompareNulls keeps JSON null values instead of dropping them, so a field TS
74+
// emits as null but Go omits (or vice versa) is caught instead of comparing
75+
// equal. Set it for result-envelope cases where present-null vs absent-key is
76+
// a real shape divergence.
77+
CompareNulls bool `yaml:"compare_nulls"`
7378
}
7479

7580
type paritySideResult struct {
@@ -331,8 +336,8 @@ func TestParityMatrix(t *testing.T) {
331336
// Non-zero exit path
332337
if tsRes.ExitCode != 0 && goRes.ExitCode != 0 {
333338
if asserts["stdout_normalized"] {
334-
tsNorm := normalizeOutputOpts(tsRes.Stdout, scrubHashes)
335-
goNorm := normalizeOutputOpts(goRes.Stdout, scrubHashes)
339+
tsNorm := normalizeOutputOpts(tsRes.Stdout, scrubHashes, tc.CompareNulls)
340+
goNorm := normalizeOutputOpts(goRes.Stdout, scrubHashes, tc.CompareNulls)
336341
if tsNorm != goNorm {
337342
t.Errorf("stdout differs:\n--- TS\n%s\n--- Go\n%s", tsNorm, goNorm)
338343
}
@@ -349,8 +354,8 @@ func TestParityMatrix(t *testing.T) {
349354

350355
// Success path
351356
if asserts["stdout_normalized"] {
352-
tsNorm := normalizeOutputOpts(tsRes.Stdout, scrubHashes)
353-
goNorm := normalizeOutputOpts(goRes.Stdout, scrubHashes)
357+
tsNorm := normalizeOutputOpts(tsRes.Stdout, scrubHashes, tc.CompareNulls)
358+
goNorm := normalizeOutputOpts(goRes.Stdout, scrubHashes, tc.CompareNulls)
354359
if tsNorm != goNorm {
355360
t.Errorf("stdout differs:\n--- TS\n%s\n--- Go\n%s", tsNorm, goNorm)
356361
}
@@ -593,25 +598,25 @@ func normalizeStringOpts(s string, scrubHashes bool) string {
593598
return s
594599
}
595600

596-
func normalizeOutput(raw string) string { return normalizeOutputOpts(raw, true) }
601+
func normalizeOutput(raw string) string { return normalizeOutputOpts(raw, true, false) }
597602

598-
func normalizeOutputOpts(raw string, scrubHashes bool) string {
603+
func normalizeOutputOpts(raw string, scrubHashes, keepNulls bool) string {
599604
raw = strings.TrimSpace(raw)
600605
if raw == "" {
601606
return ""
602607
}
603608

604609
// Try JSON parse (UseNumber preserves numeric precision)
605610
if parsed, ok := decodeJSONNumber(raw); ok {
606-
normalized := normalizeValueOpts(parsed, scrubHashes)
611+
normalized := normalizeValueOpts(parsed, scrubHashes, keepNulls)
607612
out, _ := json.Marshal(normalized)
608613
return string(out)
609614
}
610615

611616
// TS occasionally prefixes JSON with banner/noise on stderr/stdout; recover the payload.
612617
if candidate := extractJSONCandidate(raw); candidate != "" && candidate != raw {
613618
if parsed, ok := decodeJSONNumber(candidate); ok {
614-
normalized := normalizeValueOpts(parsed, scrubHashes)
619+
normalized := normalizeValueOpts(parsed, scrubHashes, keepNulls)
615620
out, _ := json.Marshal(normalized)
616621
return string(out)
617622
}
@@ -621,48 +626,51 @@ func normalizeOutputOpts(raw string, scrubHashes bool) string {
621626
return normalizeStringOpts(raw, scrubHashes)
622627
}
623628

624-
func normalizeValue(v interface{}) interface{} { return normalizeValueOpts(v, true) }
629+
func normalizeValue(v interface{}) interface{} { return normalizeValueOpts(v, true, false) }
625630

626-
func normalizeValueOpts(v interface{}, scrubHashes bool) interface{} {
631+
func normalizeValueOpts(v interface{}, scrubHashes, keepNulls bool) interface{} {
627632
switch val := v.(type) {
628633
case map[string]interface{}:
629634
result := make(map[string]interface{})
630635
for k, v := range val {
631636
if v == nil {
637+
if keepNulls {
638+
result[k] = nil
639+
}
632640
continue
633641
}
634642
if k == "devcontainer.metadata" {
635643
if s, ok := v.(string); ok {
636-
result[k] = normalizeEmbeddedJSONOpts(s, scrubHashes)
644+
result[k] = normalizeEmbeddedJSONOpts(s, scrubHashes, keepNulls)
637645
continue
638646
}
639647
}
640-
result[k] = normalizeValueOpts(v, scrubHashes)
648+
result[k] = normalizeValueOpts(v, scrubHashes, keepNulls)
641649
}
642650
return sortedMap(result)
643651
case []interface{}:
644652
out := make([]interface{}, len(val))
645653
for i, item := range val {
646-
out[i] = normalizeValueOpts(item, scrubHashes)
654+
out[i] = normalizeValueOpts(item, scrubHashes, keepNulls)
647655
}
648656
return out
649657
case string:
650658
if embedded, ok := parseEmbeddedJSON(val); ok {
651-
return normalizeValueOpts(embedded, scrubHashes)
659+
return normalizeValueOpts(embedded, scrubHashes, keepNulls)
652660
}
653661
return normalizeStringOpts(val, scrubHashes)
654662
default:
655663
return val
656664
}
657665
}
658666

659-
func normalizeEmbeddedJSONOpts(raw string, scrubHashes bool) interface{} {
667+
func normalizeEmbeddedJSONOpts(raw string, scrubHashes, keepNulls bool) interface{} {
660668
raw = strings.TrimSpace(raw)
661669
if raw == "" {
662670
return ""
663671
}
664672
if parsed, ok := parseEmbeddedJSON(raw); ok {
665-
return normalizeValueOpts(parsed, scrubHashes)
673+
return normalizeValueOpts(parsed, scrubHashes, keepNulls)
666674
}
667675
return normalizeStringOpts(raw, scrubHashes)
668676
}

0 commit comments

Comments
 (0)