Skip to content

Commit 36098ba

Browse files
aledbfclaude
andcommitted
test(harness): compare_hashes — verificar digests deterministas (wave B #1)
El scrub global sha256/hex ocultaba digests que SÍ son deterministas y comparables (mismo feature publicado → mismo manifest digest en TS y Go): un digest Go equivocado normalizaba a <HASH> y pasaba. Nuevo opt-out por-caso 'compare_hashes: true' que deja sha256/hex sin scrubbear (mermaid/paths/timestamps siguen scrubbeados). Aplicado a features.resolve-dependencies-success, read-configuration.features-configuration, upgrade.write-lockfile-to-disk-success — los 3 pasan, o sea los digests coinciden y ahora se comparan de verdad. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4a75b6e commit 36098ba

3 files changed

Lines changed: 61 additions & 37 deletions

File tree

‎docs/migration/parity-matrix.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ initial_cases:
392392
asserts: [exit_code]
393393
class: upgrade-runtime
394394
current_status: match
395+
compare_hashes: true
395396
notes: "upgrade SIN --dry-run persiste el lockfile en disco (.devcontainer-lock.json junto al .devcontainer.json, getLockfilePath) y no emite JSON a stdout; el verify_cmd cat-ea el lockfile producido. Cada lado trabaja sobre una copia tmp aislada del fixture image-with-features. Ambos resuelven los features contra ghcr.io a los mismos digests (sha256 scrubbeado por normalizacion de todos modos)."
396397

397398
- id: read-configuration.missing-required
@@ -445,6 +446,7 @@ initial_cases:
445446
asserts: [exit_code, stdout_normalized]
446447
class: feature-resolution
447448
current_status: match
449+
compare_hashes: true
448450

449451
- id: read-configuration.compose-path-terminal
450452
lane: semantic
@@ -2311,6 +2313,7 @@ initial_cases:
23112313
asserts: [exit_code, stdout_normalized]
23122314
class: features-runtime
23132315
current_status: match
2316+
compare_hashes: true
23142317
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."
23152318

23162319
- id: templates.metadata-success

‎internal/cli/metadata_interop_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ func TestMetadataInteropTSToGo(t *testing.T) {
239239
repoRoot := findRepoRoot(t)
240240
oracle := filepath.Join(repoRoot, "reference", "dist", "spec-node", "devContainersSpecCLI.js")
241241
if _, err := os.Stat(oracle); err != nil {
242-
t.Skip("TS reference oracle not compiled; TS->Go interop validated in RW-018")
242+
t.Skip("TS reference oracle not compiled; skipping TS->Go interop")
243243
}
244244
if !isDockerAvailable() {
245245
t.Skip("docker required")
@@ -249,5 +249,5 @@ func TestMetadataInteropTSToGo(t *testing.T) {
249249
// The assertion still holds: read via the Go reader and compare the merge as
250250
// normalized JSON. This guard keeps the worktree hermetic until the
251251
// bidirectional build wiring is added.
252-
t.Skip("TS->Go interop build wiring lands in RW-018")
252+
t.Skip("TS->Go interop build wiring not implemented yet")
253253
}

‎internal/cli/parity_matrix_test.go‎

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ type parityCase struct {
6363
// Serial forces a case to run sequentially (no t.Parallel). Used for the few
6464
// runtime cases that can't be isolated for parallel execution.
6565
Serial bool `yaml:"serial"`
66+
// CompareHashes keeps sha256/hex digests UNSCRUBBED so they are compared
67+
// exactly between TS and Go. Set it for cases whose digests are deterministic
68+
// and meaningful (resolve-dependencies installOrder resource@digest,
69+
// read-configuration featuresConfiguration manifest/computed digests, lockfile
70+
// integrity) — otherwise a wrong Go digest normalizes to <HASH> and passes.
71+
// Leave it false where digests are non-deterministic (tarball push mtime).
72+
CompareHashes bool `yaml:"compare_hashes"`
6673
}
6774

6875
type paritySideResult struct {
@@ -319,11 +326,13 @@ func TestParityMatrix(t *testing.T) {
319326
t.Errorf("exit code: TS=%d Go=%d", tsRes.ExitCode, goRes.ExitCode)
320327
}
321328

329+
scrubHashes := !tc.CompareHashes
330+
322331
// Non-zero exit path
323332
if tsRes.ExitCode != 0 && goRes.ExitCode != 0 {
324333
if asserts["stdout_normalized"] {
325-
tsNorm := normalizeOutput(tsRes.Stdout)
326-
goNorm := normalizeOutput(goRes.Stdout)
334+
tsNorm := normalizeOutputOpts(tsRes.Stdout, scrubHashes)
335+
goNorm := normalizeOutputOpts(goRes.Stdout, scrubHashes)
327336
if tsNorm != goNorm {
328337
t.Errorf("stdout differs:\n--- TS\n%s\n--- Go\n%s", tsNorm, goNorm)
329338
}
@@ -340,16 +349,16 @@ func TestParityMatrix(t *testing.T) {
340349

341350
// Success path
342351
if asserts["stdout_normalized"] {
343-
tsNorm := normalizeOutput(tsRes.Stdout)
344-
goNorm := normalizeOutput(goRes.Stdout)
352+
tsNorm := normalizeOutputOpts(tsRes.Stdout, scrubHashes)
353+
goNorm := normalizeOutputOpts(goRes.Stdout, scrubHashes)
345354
if tsNorm != goNorm {
346355
t.Errorf("stdout differs:\n--- TS\n%s\n--- Go\n%s", tsNorm, goNorm)
347356
}
348357
}
349358

350359
if asserts["stderr_normalized"] {
351-
tsNorm := normalizeText(tsRes.Stderr)
352-
goNorm := normalizeText(goRes.Stderr)
360+
tsNorm := normalizeTextOpts(tsRes.Stderr, scrubHashes)
361+
goNorm := normalizeTextOpts(goRes.Stderr, scrubHashes)
353362
if tsNorm != goNorm {
354363
t.Errorf("stderr differs:\n--- TS\n%s\n--- Go\n%s", tsNorm, goNorm)
355364
}
@@ -368,13 +377,13 @@ func TestParityMatrix(t *testing.T) {
368377
)
369378
return
370379
}
371-
tsVerifyOut := normalizeText(tsRes.VerifyStdout)
372-
goVerifyOut := normalizeText(goRes.VerifyStdout)
380+
tsVerifyOut := normalizeTextOpts(tsRes.VerifyStdout, scrubHashes)
381+
goVerifyOut := normalizeTextOpts(goRes.VerifyStdout, scrubHashes)
373382
if tsVerifyOut != goVerifyOut {
374383
t.Errorf("verify stdout differs:\n--- TS\n%s\n--- Go\n%s", tsVerifyOut, goVerifyOut)
375384
}
376-
tsVerifyErr := normalizeText(tsRes.VerifyStderr)
377-
goVerifyErr := normalizeText(goRes.VerifyStderr)
385+
tsVerifyErr := normalizeTextOpts(tsRes.VerifyStderr, scrubHashes)
386+
goVerifyErr := normalizeTextOpts(goRes.VerifyStderr, scrubHashes)
378387
if tsVerifyErr != goVerifyErr {
379388
t.Errorf("verify stderr differs:\n--- TS\n%s\n--- Go\n%s", tsVerifyErr, goVerifyErr)
380389
}
@@ -558,53 +567,63 @@ var (
558567
}
559568
)
560569

561-
func normalizeString(s string) string {
570+
func normalizeString(s string) string { return normalizeStringOpts(s, true) }
571+
572+
func normalizeStringOpts(s string, scrubHashes bool) string {
562573
s = reHostPath.ReplaceAllString(s, "<HOST_PATH>")
563574
s = reHomePath.ReplaceAllString(s, "<HOST_PATH>")
564575
s = reTmpPath.ReplaceAllString(s, "<TMP_PATH>")
565576
s = reVarFolders.ReplaceAllString(s, "<TMP_PATH>")
566-
s = reSHA256.ReplaceAllString(s, "sha256:<HASH>")
567-
// Only scrub hex-looking runs that actually contain a hex letter — a pure
568-
// decimal run (epoch-ms, large uid) is a real value and must stay comparable.
569-
s = reHexID.ReplaceAllStringFunc(s, func(m string) string {
570-
if strings.ContainsAny(m, "abcdef") {
571-
return "<ID>"
572-
}
573-
return m
574-
})
577+
if scrubHashes {
578+
s = reSHA256.ReplaceAllString(s, "sha256:<HASH>")
579+
// Only scrub hex-looking runs that actually contain a hex letter — a pure
580+
// decimal run (epoch-ms, large uid) is a real value and must stay comparable.
581+
s = reHexID.ReplaceAllStringFunc(s, func(m string) string {
582+
if strings.ContainsAny(m, "abcdef") {
583+
return "<ID>"
584+
}
585+
return m
586+
})
587+
}
588+
// Mermaid node hashes are internally-consistent but not byte-identical across
589+
// TS/Go, so they are always scrubbed (independent of scrubHashes).
575590
s = reMermaidNode.ReplaceAllString(s, "<NODE>[")
576591
// Normalize parity side identifiers in image/container names
577592
s = reParitySideSuffix.ReplaceAllString(s, "-<SIDE>$2")
578593
return s
579594
}
580595

581-
func normalizeOutput(raw string) string {
596+
func normalizeOutput(raw string) string { return normalizeOutputOpts(raw, true) }
597+
598+
func normalizeOutputOpts(raw string, scrubHashes bool) string {
582599
raw = strings.TrimSpace(raw)
583600
if raw == "" {
584601
return ""
585602
}
586603

587604
// Try JSON parse (UseNumber preserves numeric precision)
588605
if parsed, ok := decodeJSONNumber(raw); ok {
589-
normalized := normalizeValue(parsed)
606+
normalized := normalizeValueOpts(parsed, scrubHashes)
590607
out, _ := json.Marshal(normalized)
591608
return string(out)
592609
}
593610

594611
// TS occasionally prefixes JSON with banner/noise on stderr/stdout; recover the payload.
595612
if candidate := extractJSONCandidate(raw); candidate != "" && candidate != raw {
596613
if parsed, ok := decodeJSONNumber(candidate); ok {
597-
normalized := normalizeValue(parsed)
614+
normalized := normalizeValueOpts(parsed, scrubHashes)
598615
out, _ := json.Marshal(normalized)
599616
return string(out)
600617
}
601618
}
602619

603620
// Fallback: normalize as text
604-
return normalizeString(raw)
621+
return normalizeStringOpts(raw, scrubHashes)
605622
}
606623

607-
func normalizeValue(v interface{}) interface{} {
624+
func normalizeValue(v interface{}) interface{} { return normalizeValueOpts(v, true) }
625+
626+
func normalizeValueOpts(v interface{}, scrubHashes bool) interface{} {
608627
switch val := v.(type) {
609628
case map[string]interface{}:
610629
result := make(map[string]interface{})
@@ -614,38 +633,38 @@ func normalizeValue(v interface{}) interface{} {
614633
}
615634
if k == "devcontainer.metadata" {
616635
if s, ok := v.(string); ok {
617-
result[k] = normalizeEmbeddedJSON(s)
636+
result[k] = normalizeEmbeddedJSONOpts(s, scrubHashes)
618637
continue
619638
}
620639
}
621-
result[k] = normalizeValue(v)
640+
result[k] = normalizeValueOpts(v, scrubHashes)
622641
}
623642
return sortedMap(result)
624643
case []interface{}:
625644
out := make([]interface{}, len(val))
626645
for i, item := range val {
627-
out[i] = normalizeValue(item)
646+
out[i] = normalizeValueOpts(item, scrubHashes)
628647
}
629648
return out
630649
case string:
631650
if embedded, ok := parseEmbeddedJSON(val); ok {
632-
return normalizeValue(embedded)
651+
return normalizeValueOpts(embedded, scrubHashes)
633652
}
634-
return normalizeString(val)
653+
return normalizeStringOpts(val, scrubHashes)
635654
default:
636655
return val
637656
}
638657
}
639658

640-
func normalizeEmbeddedJSON(raw string) interface{} {
659+
func normalizeEmbeddedJSONOpts(raw string, scrubHashes bool) interface{} {
641660
raw = strings.TrimSpace(raw)
642661
if raw == "" {
643662
return ""
644663
}
645664
if parsed, ok := parseEmbeddedJSON(raw); ok {
646-
return normalizeValue(parsed)
665+
return normalizeValueOpts(parsed, scrubHashes)
647666
}
648-
return normalizeString(raw)
667+
return normalizeStringOpts(raw, scrubHashes)
649668
}
650669

651670
func parseEmbeddedJSON(raw string) (interface{}, bool) {
@@ -678,7 +697,9 @@ func sortedMap(m map[string]interface{}) map[string]interface{} {
678697
return m
679698
}
680699

681-
func normalizeText(raw string) string {
700+
func normalizeText(raw string) string { return normalizeTextOpts(raw, true) }
701+
702+
func normalizeTextOpts(raw string, scrubHashes bool) string {
682703
var lines []string
683704
for _, line := range strings.Split(raw, "\n") {
684705
line = strings.TrimRight(line, "\r")
@@ -700,7 +721,7 @@ func normalizeText(raw string) string {
700721
line = reTimestampTS.ReplaceAllString(line, "[X ms]")
701722

702723
// Normalize paths and IDs
703-
line = normalizeString(line)
724+
line = normalizeStringOpts(line, scrubHashes)
704725

705726
// Normalize dockerfile size
706727
line = reDockerfileB.ReplaceAllString(line, "transferring dockerfile: <N>B")

0 commit comments

Comments
 (0)