master
go 2,819 lines 81.8 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package main
4
5 import (
6 "bufio"
7 "bytes"
8 "crypto/sha256"
9 "encoding/csv"
10 "encoding/json"
11 "errors"
12 "flag"
13 "fmt"
14 "io"
15 "io/fs"
16 "os"
17 "os/exec"
18 "path"
19 "path/filepath"
20 "regexp"
21 "slices"
22 "sort"
23 "strconv"
24 "strings"
25 "time"
26
27 "github.com/netdata/netdata/go/plugins/pkg/l2topology"
28 "github.com/netdata/netdata/go/plugins/pkg/l2topology/parity"
29 )
30
31 const (
32 defaultEnlinkdRoot = "/tmp/topology-library-repos/enlinkd"
33 defaultFixtureSourceRel = "features/enlinkd/tests/src/test/resources/linkd"
34 defaultFixtureMirrorRel = "testdata/snmp/enlinkd/upstream/linkd"
35 defaultManifestRootRel = "testdata/snmp/enlinkd"
36 defaultEvidenceRel = "testdata/snmp/parity-evidence"
37 defaultScopedTestsRelEn = "features/enlinkd/tests/src/test/java/org/opennms/netmgt/enlinkd"
38 defaultScopedTestsRelNB = "features/enlinkd/tests/src/test/java/org/opennms/netmgt/nb"
39 defaultFixtureInventory = "enlinkd-fixture-inventory.csv"
40 defaultMethodInventory = "enlinkd-test-method-inventory.csv"
41 defaultAssertionInventory = "enlinkd-assertion-inventory.csv"
42 defaultAssertionMapping = "assertion-mapping.csv"
43 defaultSummaryFile = "parity-summary.json"
44 defaultPhase2ReportFile = "phase2-parity-report.json"
45 defaultPhase2GapFile = "phase2-gap-report.md"
46 defaultOfficeReportFile = "office-live-reliability-report.md"
47 defaultOracleDiffJSONFile = "behavior-oracle-diff.json"
48 defaultOracleDiffMDFile = "behavior-oracle-diff.md"
49 )
50
51 var (
52 testAnnotationRE = regexp.MustCompile(`^\s*@Test\b`)
53 packageRE = regexp.MustCompile(`^\s*package\s+([A-Za-z0-9_.]+)\s*;`)
54 classRE = regexp.MustCompile(`\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
55 identifierRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
56 assertionCallRE = regexp.MustCompile(`\b(assert[A-Za-z0-9_]*)\s*\(`)
57 testFileNameITRE = regexp.MustCompile(`IT\.java$`)
58 testFileNameTestR = regexp.MustCompile(`Test\.java$`)
59 )
60
61 type options struct {
62 mode string
63 enlinkdRoot string
64 fixtureSrcRel string
65 fixtureDstPath string
66 manifestRoot string
67 evidencePath string
68 summaryPath string
69 phase2Report string
70 phase2Gap string
71 oracleDiffJSON string
72 oracleDiffMD string
73 }
74
75 type fixtureRow struct {
76 Scenario string
77 File string
78 RelativePath string
79 SHA256 string
80 SizeBytes int64
81 UpstreamPath string
82 }
83
84 type methodRow struct {
85 Class string
86 Method string
87 SourceFile string
88 ProtocolScope string
89 }
90
91 type assertionRow struct {
92 Class string
93 Method string
94 AssertionID string
95 SourceFile string
96 Line int
97 AssertCall string
98 ProtocolScope string
99 }
100
101 type methodRange struct {
102 Name string
103 Start int
104 End int
105 Scope string
106 }
107
108 type assertionCandidate struct {
109 Line int
110 Call string
111 }
112
113 type mappingStats struct {
114 MappedAssertions int
115 TotalAssertions int
116 MappedMethods int
117 TotalMethods int
118 MappedTestFiles int
119 TotalTestFiles int
120 }
121
122 type protocolSummary struct {
123 Protocol string `json:"protocol"`
124 Total int `json:"total"`
125 Passed int `json:"passed"`
126 Failed int `json:"failed"`
127 }
128
129 type scenarioSummary struct {
130 ID string `json:"id"`
131 Manifest string `json:"manifest"`
132 Protocols []string `json:"protocols"`
133 Passed bool `json:"passed"`
134 Failures []string `json:"failures,omitempty"`
135 }
136
137 type goTestSummary struct {
138 Package string `json:"package"`
139 Passed bool `json:"passed"`
140 Error string `json:"error,omitempty"`
141 }
142
143 type paritySummary struct {
144 Version string `json:"version"`
145 FixtureScenarios int `json:"fixture_scenarios"`
146 FixtureFiles int `json:"fixture_files"`
147 TotalScenarios int `json:"total_scenarios"`
148 ScenariosPassed int `json:"scenarios_passed"`
149 ScenariosFailed int `json:"scenarios_failed"`
150 TotalTestsMapped int `json:"total_tests_mapped"`
151 TotalTestsInventory int `json:"total_tests_inventory"`
152 TotalAssertionsMapped int `json:"total_assertions_mapped"`
153 TotalAssertionsTotal int `json:"total_assertions_inventory"`
154 ProtocolCounts []protocolSummary `json:"protocol_counts"`
155 ScenarioResults []scenarioSummary `json:"scenario_results"`
156 GoTests []goTestSummary `json:"go_tests"`
157 Determinism struct {
158 Runs int `json:"runs"`
159 ByteIdentical bool `json:"byte_identical"`
160 } `json:"determinism"`
161 }
162
163 type phase2SuiteSummary struct {
164 FixtureScenarios int `json:"fixture_scenarios"`
165 FixtureFiles int `json:"fixture_files"`
166 TotalScenarios int `json:"total_scenarios"`
167 ScenariosPassed int `json:"scenarios_passed"`
168 ScenariosFailed int `json:"scenarios_failed"`
169 TotalTestsMapped int `json:"total_tests_mapped"`
170 TotalTestsInventory int `json:"total_tests_inventory"`
171 TotalAssertionsMapped int `json:"total_assertions_mapped"`
172 TotalAssertionsTotal int `json:"total_assertions_inventory"`
173 ProtocolCounts []protocolSummary `json:"protocol_counts"`
174 }
175
176 type phase2CheckStatus struct {
177 Name string `json:"name"`
178 Status string `json:"status"`
179 ChecksPassed int `json:"checks_passed"`
180 ChecksTotal int `json:"checks_total"`
181 Commands []string `json:"commands"`
182 Failed []string `json:"failed,omitempty"`
183 Missing []string `json:"missing,omitempty"`
184 Errors []string `json:"errors,omitempty"`
185 }
186
187 type phase2AssertionCoverage struct {
188 Status string `json:"status"`
189 InScopeTotal int `json:"in_scope_total"`
190 InScopePorted int `json:"in_scope_ported"`
191 InScopeNotApplicable int `json:"in_scope_not_applicable_approved"`
192 InScopeUnmapped int `json:"in_scope_unmapped"`
193 OutOfScopePorted int `json:"out_of_scope_ported"`
194 OutOfScopeNotApplicable int `json:"out_of_scope_not_applicable_approved"`
195 }
196
197 type phase2DeferredGap struct {
198 ID string `json:"id"`
199 Description string `json:"description"`
200 Reason string `json:"reason"`
201 Evidence string `json:"evidence"`
202 }
203
204 type phase2Report struct {
205 Version string `json:"version"`
206 GeneratedAtUTC string `json:"generated_at_utc"`
207 Status string `json:"status"`
208 Suite phase2SuiteSummary `json:"suite"`
209 ModuleParity []phase2CheckStatus `json:"module_parity"`
210 ReversePairQuality phase2CheckStatus `json:"reverse_pair_quality"`
211 IdentityMergeQuality phase2CheckStatus `json:"identity_merge_quality"`
212 AssertionCoverage phase2AssertionCoverage `json:"assertion_coverage"`
213 DeferredGaps []phase2DeferredGap `json:"deferred_gaps"`
214 }
215
216 type behaviorOracleReport struct {
217 Version string `json:"version"`
218 GeneratedAtUTC string `json:"generated_at_utc"`
219 Status string `json:"status"`
220 Scope behaviorOracleScope `json:"scope"`
221 Totals behaviorOracleTotals `json:"totals"`
222 Scenarios []behaviorOracleScenarioReport `json:"scenarios"`
223 }
224
225 type behaviorOracleScope struct {
226 Protocols []string `json:"protocols"`
227 }
228
229 type behaviorOracleTotals struct {
230 ScenariosTotal int `json:"scenarios_total"`
231 ScenariosInScope int `json:"scenarios_in_scope"`
232 ScenariosSkipped int `json:"scenarios_skipped"`
233 ScenariosZeroDiff int `json:"scenarios_zero_diff"`
234 ScenariosWithDiffs int `json:"scenarios_with_diffs"`
235 ScenariosWithFailures int `json:"scenarios_with_failures"`
236 }
237
238 type behaviorOracleScenarioReport struct {
239 ID string `json:"id"`
240 Manifest string `json:"manifest"`
241 Protocols []string `json:"protocols"`
242 InScope bool `json:"in_scope"`
243 FixtureInputs []behaviorOracleFixture `json:"fixture_inputs,omitempty"`
244 Expected behaviorOracleSnapshot `json:"expected"`
245 Actual behaviorOracleSnapshot `json:"actual"`
246 Diff behaviorOracleDiff `json:"diff"`
247 Status string `json:"status"`
248 Errors []string `json:"errors,omitempty"`
249 }
250
251 type behaviorOracleFixture struct {
252 DeviceID string `json:"device_id"`
253 Hostname string `json:"hostname,omitempty"`
254 Address string `json:"address,omitempty"`
255 WalkFile string `json:"walk_file"`
256 SHA256 string `json:"sha256"`
257 SizeBytes int64 `json:"size_bytes"`
258 }
259
260 type behaviorOracleSnapshot struct {
261 Devices []parity.GoldenDevice `json:"devices"`
262 Adjacencies []parity.GoldenAdjacency `json:"adjacencies"`
263 Metadata behaviorOracleMetadata `json:"metadata"`
264 }
265
266 type behaviorOracleMetadata struct {
267 Devices int `json:"devices"`
268 DirectionalAdjacencies int `json:"directional_adjacencies"`
269 }
270
271 type behaviorOracleDiff struct {
272 ZeroDiff bool `json:"zero_diff"`
273 MissingDevices []parity.GoldenDevice `json:"missing_devices,omitempty"`
274 UnexpectedDevices []parity.GoldenDevice `json:"unexpected_devices,omitempty"`
275 HostnameMismatches []behaviorOracleDeviceDelta `json:"hostname_mismatches,omitempty"`
276 MissingAdjacencies []parity.GoldenAdjacency `json:"missing_adjacencies,omitempty"`
277 UnexpectedAdjacencies []parity.GoldenAdjacency `json:"unexpected_adjacencies,omitempty"`
278 MetadataMismatches []behaviorOracleCountDelta `json:"metadata_mismatches,omitempty"`
279 }
280
281 type behaviorOracleDeviceDelta struct {
282 DeviceID string `json:"device_id"`
283 Expected string `json:"expected"`
284 Actual string `json:"actual"`
285 }
286
287 type behaviorOracleCountDelta struct {
288 Field string `json:"field"`
289 Expected int `json:"expected"`
290 Actual int `json:"actual"`
291 }
292
293 func main() {
294 opts := parseOptions()
295
296 switch opts.mode {
297 case "sync":
298 if err := runSync(opts); err != nil {
299 fmt.Fprintf(os.Stderr, "topology-parity-evidence sync failed: %v\n", err)
300 os.Exit(1)
301 }
302 case "verify":
303 if err := runVerify(opts); err != nil {
304 fmt.Fprintf(os.Stderr, "topology-parity-evidence verify failed: %v\n", err)
305 os.Exit(1)
306 }
307 case "suite":
308 if err := runSuite(opts); err != nil {
309 fmt.Fprintf(os.Stderr, "topology-parity-evidence suite failed: %v\n", err)
310 os.Exit(1)
311 }
312 case "phase2":
313 if err := runPhase2(opts); err != nil {
314 fmt.Fprintf(os.Stderr, "topology-parity-evidence phase2 failed: %v\n", err)
315 os.Exit(1)
316 }
317 case "oracle-diff":
318 if err := runOracleDiff(opts); err != nil {
319 fmt.Fprintf(os.Stderr, "topology-parity-evidence oracle-diff failed: %v\n", err)
320 os.Exit(1)
321 }
322 default:
323 fmt.Fprintf(os.Stderr, "unsupported mode %q (want sync|verify|suite|phase2|oracle-diff)\n", opts.mode)
324 os.Exit(1)
325 }
326 }
327
328 func parseOptions() options {
329 var opts options
330 flag.StringVar(&opts.mode, "mode", "sync", "sync|verify|suite|phase2|oracle-diff")
331 flag.StringVar(&opts.enlinkdRoot, "enlinkd-root", defaultEnlinkdRoot, "path to enlinkd checkout root")
332 flag.StringVar(&opts.fixtureSrcRel, "fixture-source-rel", defaultFixtureSourceRel, "fixture source path relative to enlinkd root")
333 flag.StringVar(&opts.fixtureDstPath, "fixture-dst", defaultFixtureMirrorRel, "fixture mirror destination path")
334 flag.StringVar(&opts.manifestRoot, "manifest-root", defaultManifestRootRel, "local manifest root path")
335 flag.StringVar(&opts.evidencePath, "evidence-dir", defaultEvidenceRel, "evidence output directory")
336 flag.StringVar(&opts.summaryPath, "summary-file", filepath.Join(defaultEvidenceRel, defaultSummaryFile), "parity summary output file")
337 flag.StringVar(&opts.phase2Report, "phase2-report-file", filepath.Join(defaultEvidenceRel, defaultPhase2ReportFile), "phase2 parity report output file")
338 flag.StringVar(&opts.phase2Gap, "phase2-gap-file", filepath.Join(defaultEvidenceRel, defaultPhase2GapFile), "phase2 gap report output file")
339 flag.StringVar(&opts.oracleDiffJSON, "oracle-diff-json", filepath.Join(defaultEvidenceRel, defaultOracleDiffJSONFile), "behavior oracle diff report (machine-readable JSON)")
340 flag.StringVar(&opts.oracleDiffMD, "oracle-diff-md", filepath.Join(defaultEvidenceRel, defaultOracleDiffMDFile), "behavior oracle diff report (human-readable Markdown)")
341 flag.Parse()
342 return opts
343 }
344
345 func runSync(opts options) error {
346 srcRoot := filepath.Join(opts.enlinkdRoot, opts.fixtureSrcRel)
347 if err := requireDir(srcRoot); err != nil {
348 return fmt.Errorf("fixture source root: %w", err)
349 }
350
351 if err := syncFixtureMirror(srcRoot, opts.fixtureDstPath); err != nil {
352 return fmt.Errorf("sync fixture mirror: %w", err)
353 }
354
355 fixtureRows, err := collectFixtureInventory(opts.fixtureDstPath, opts.fixtureSrcRel)
356 if err != nil {
357 return fmt.Errorf("collect fixture inventory: %w", err)
358 }
359
360 testFiles, err := listScopedTestFiles(opts.enlinkdRoot)
361 if err != nil {
362 return fmt.Errorf("list scoped tests: %w", err)
363 }
364 assertionFiles, err := listScopedJavaFiles(opts.enlinkdRoot)
365 if err != nil {
366 return fmt.Errorf("list scoped java files: %w", err)
367 }
368 methodRows, assertionRows, err := collectTestAndAssertionInventories(opts.enlinkdRoot, testFiles, assertionFiles)
369 if err != nil {
370 return err
371 }
372
373 if err := os.MkdirAll(opts.evidencePath, 0o755); err != nil {
374 return fmt.Errorf("mkdir evidence dir: %w", err)
375 }
376 if err := writeFixtureInventoryCSV(filepath.Join(opts.evidencePath, defaultFixtureInventory), fixtureRows); err != nil {
377 return err
378 }
379 if err := writeMethodInventoryCSV(filepath.Join(opts.evidencePath, defaultMethodInventory), methodRows); err != nil {
380 return err
381 }
382 if err := writeAssertionInventoryCSV(filepath.Join(opts.evidencePath, defaultAssertionInventory), assertionRows); err != nil {
383 return err
384 }
385
386 fmt.Printf("sync complete\n")
387 fmt.Printf("fixture scenarios: %d\n", countDistinctScenarios(fixtureRows))
388 fmt.Printf("fixture files: %d\n", len(fixtureRows))
389 fmt.Printf("test files: %d\n", countDistinctFiles(methodRows))
390 fmt.Printf("test methods: %d\n", len(methodRows))
391 fmt.Printf("assertions: %d\n", len(assertionRows))
392 return nil
393 }
394
395 func runVerify(opts options) error {
396 localRows, err := verifyFixtureInventory(opts)
397 if err != nil {
398 return err
399 }
400
401 fmt.Printf("verify complete\n")
402 fmt.Printf("fixture scenarios: %d\n", countDistinctScenarios(localRows))
403 fmt.Printf("fixture files: %d\n", len(localRows))
404 return nil
405 }
406
407 func verifyFixtureInventory(opts options) ([]fixtureRow, error) {
408 srcRoot := filepath.Join(opts.enlinkdRoot, opts.fixtureSrcRel)
409 if err := requireDir(srcRoot); err != nil {
410 return nil, fmt.Errorf("fixture source root: %w", err)
411 }
412 if err := requireDir(opts.fixtureDstPath); err != nil {
413 return nil, fmt.Errorf("fixture destination root: %w", err)
414 }
415
416 upstreamRows, err := collectFixtureInventory(srcRoot, opts.fixtureSrcRel)
417 if err != nil {
418 return nil, fmt.Errorf("collect upstream inventory: %w", err)
419 }
420 localRows, err := collectFixtureInventory(opts.fixtureDstPath, opts.fixtureSrcRel)
421 if err != nil {
422 return nil, fmt.Errorf("collect local inventory: %w", err)
423 }
424 if err := compareFixtureInventories(upstreamRows, localRows); err != nil {
425 return nil, err
426 }
427
428 inventoryPath := filepath.Join(opts.evidencePath, defaultFixtureInventory)
429 fileRows, err := readFixtureInventoryCSV(inventoryPath)
430 if err != nil {
431 return nil, err
432 }
433 if err := compareFixtureInventories(localRows, fileRows); err != nil {
434 return nil, fmt.Errorf("inventory file mismatch (%s): %w", inventoryPath, err)
435 }
436
437 return localRows, nil
438 }
439
440 func runSuite(opts options) error {
441 summaryA, err := buildSuiteSummary(opts)
442 if err != nil {
443 return err
444 }
445 summaryB, err := buildSuiteSummary(opts)
446 if err != nil {
447 return err
448 }
449
450 baseA, err := marshalSummaryJSON(summaryA)
451 if err != nil {
452 return err
453 }
454 baseB, err := marshalSummaryJSON(summaryB)
455 if err != nil {
456 return err
457 }
458 byteIdentical := bytes.Equal(baseA, baseB)
459
460 summaryA.Determinism.Runs = 2
461 summaryA.Determinism.ByteIdentical = byteIdentical
462
463 outBytes, err := marshalSummaryJSON(summaryA)
464 if err != nil {
465 return err
466 }
467 if err := os.MkdirAll(filepath.Dir(opts.summaryPath), 0o755); err != nil {
468 return fmt.Errorf("create summary directory: %w", err)
469 }
470 if err := os.WriteFile(opts.summaryPath, outBytes, 0o644); err != nil {
471 return fmt.Errorf("write summary %q: %w", opts.summaryPath, err)
472 }
473
474 fmt.Printf("suite complete\n")
475 fmt.Printf("summary file: %s\n", opts.summaryPath)
476 fmt.Printf("total scenarios: %d (passed=%d failed=%d)\n", summaryA.TotalScenarios, summaryA.ScenariosPassed, summaryA.ScenariosFailed)
477 fmt.Printf("mapped tests/assertions: %d/%d tests, %d/%d assertions\n",
478 summaryA.TotalTestsMapped, summaryA.TotalTestsInventory,
479 summaryA.TotalAssertionsMapped, summaryA.TotalAssertionsTotal)
480
481 if !byteIdentical {
482 return fmt.Errorf("determinism check failed: canonical summary JSON differs across repeated runs")
483 }
484 for _, testResult := range summaryA.GoTests {
485 if !testResult.Passed {
486 return fmt.Errorf("go test failed for %s: %s", testResult.Package, testResult.Error)
487 }
488 }
489 if summaryA.ScenariosFailed > 0 {
490 return fmt.Errorf("%d scenario(s) failed parity validation", summaryA.ScenariosFailed)
491 }
492 return nil
493 }
494
495 func runOracleDiff(opts options) error {
496 if _, err := verifyFixtureInventory(opts); err != nil {
497 return err
498 }
499
500 report, err := buildBehaviorOracleDiffReport(opts.manifestRoot)
501 if err != nil {
502 return err
503 }
504
505 payload, err := json.MarshalIndent(report, "", " ")
506 if err != nil {
507 return fmt.Errorf("marshal behavior oracle report: %w", err)
508 }
509 payload = append(payload, '\n')
510
511 if err := os.MkdirAll(filepath.Dir(opts.oracleDiffJSON), 0o755); err != nil {
512 return fmt.Errorf("create oracle diff json directory: %w", err)
513 }
514 if err := os.WriteFile(opts.oracleDiffJSON, payload, 0o644); err != nil {
515 return fmt.Errorf("write oracle diff json %q: %w", opts.oracleDiffJSON, err)
516 }
517
518 markdown := buildBehaviorOracleDiffMarkdown(report)
519 if err := os.MkdirAll(filepath.Dir(opts.oracleDiffMD), 0o755); err != nil {
520 return fmt.Errorf("create oracle diff markdown directory: %w", err)
521 }
522 if err := os.WriteFile(opts.oracleDiffMD, []byte(markdown), 0o644); err != nil {
523 return fmt.Errorf("write oracle diff markdown %q: %w", opts.oracleDiffMD, err)
524 }
525
526 fmt.Printf("oracle diff complete\n")
527 fmt.Printf("json report: %s\n", opts.oracleDiffJSON)
528 fmt.Printf("markdown report: %s\n", opts.oracleDiffMD)
529 fmt.Printf("status: %s\n", report.Status)
530 fmt.Printf("in-scope scenarios: %d (zero-diff=%d diffs=%d failures=%d)\n",
531 report.Totals.ScenariosInScope,
532 report.Totals.ScenariosZeroDiff,
533 report.Totals.ScenariosWithDiffs,
534 report.Totals.ScenariosWithFailures)
535
536 if report.Status != "pass" {
537 return fmt.Errorf("behavior oracle diff contains in-scope mismatches")
538 }
539 return nil
540 }
541
542 func buildBehaviorOracleDiffReport(manifestRoot string) (behaviorOracleReport, error) {
543 pattern := filepath.Join(manifestRoot, "*/manifest.yaml")
544 manifestPaths, err := filepath.Glob(pattern)
545 if err != nil {
546 return behaviorOracleReport{}, fmt.Errorf("glob manifests %q: %w", pattern, err)
547 }
548 sort.Strings(manifestPaths)
549 if len(manifestPaths) == 0 {
550 return behaviorOracleReport{}, fmt.Errorf("no manifests found under %q", manifestRoot)
551 }
552
553 report := behaviorOracleReport{
554 Version: "v1",
555 GeneratedAtUTC: time.Now().UTC().Format(time.RFC3339),
556 Status: "pass",
557 Scope: behaviorOracleScope{
558 Protocols: []string{"lldp", "cdp", "bridge_fdb", "arp_nd"},
559 },
560 Scenarios: make([]behaviorOracleScenarioReport, 0, 64),
561 }
562
563 for _, manifestPath := range manifestPaths {
564 manifest, err := parity.LoadManifest(manifestPath)
565 if err != nil {
566 return behaviorOracleReport{}, err
567 }
568
569 scenarios := append([]parity.ManifestScenario(nil), manifest.Scenarios...)
570 sort.Slice(scenarios, func(i, j int) bool {
571 return scenarios[i].ID < scenarios[j].ID
572 })
573
574 for _, scenario := range scenarios {
575 scenarioReport := evaluateBehaviorOracleScenario(manifestPath, scenario)
576 report.Scenarios = append(report.Scenarios, scenarioReport)
577 }
578 }
579
580 report.Totals.ScenariosTotal = len(report.Scenarios)
581 for _, scenario := range report.Scenarios {
582 if !scenario.InScope {
583 report.Totals.ScenariosSkipped++
584 continue
585 }
586
587 report.Totals.ScenariosInScope++
588 switch scenario.Status {
589 case "zero-diff":
590 report.Totals.ScenariosZeroDiff++
591 case "diff":
592 report.Totals.ScenariosWithDiffs++
593 default:
594 report.Totals.ScenariosWithFailures++
595 }
596 }
597
598 if report.Totals.ScenariosWithDiffs > 0 || report.Totals.ScenariosWithFailures > 0 {
599 report.Status = "fail"
600 }
601
602 return report, nil
603 }
604
605 func evaluateBehaviorOracleScenario(manifestPath string, scenario parity.ManifestScenario) behaviorOracleScenarioReport {
606 out := behaviorOracleScenarioReport{
607 ID: scenario.ID,
608 Manifest: filepath.ToSlash(manifestPath),
609 Protocols: enabledProtocols(scenario.Protocols),
610 InScope: scenarioInScope(scenario),
611 Status: "error",
612 Expected: behaviorOracleSnapshot{
613 Devices: []parity.GoldenDevice{},
614 Adjacencies: []parity.GoldenAdjacency{},
615 },
616 Actual: behaviorOracleSnapshot{
617 Devices: []parity.GoldenDevice{},
618 Adjacencies: []parity.GoldenAdjacency{},
619 },
620 }
621
622 resolved, err := parity.ResolveScenario(manifestPath, scenario)
623 if err != nil {
624 out.Errors = []string{err.Error()}
625 return out
626 }
627
628 fixtures, err := collectBehaviorOracleFixtureInputs(resolved)
629 if err != nil {
630 out.Errors = []string{err.Error()}
631 return out
632 }
633 out.FixtureInputs = fixtures
634
635 if err := parity.ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON); err != nil {
636 out.Errors = append(out.Errors, err.Error())
637 }
638
639 golden, err := parity.LoadGoldenYAML(resolved.GoldenYAML)
640 if err != nil {
641 out.Errors = append(out.Errors, err.Error())
642 return out
643 }
644 out.Expected = expectedBehaviorSnapshot(golden)
645
646 walks, err := parity.LoadScenarioWalks(resolved)
647 if err != nil {
648 out.Errors = append(out.Errors, err.Error())
649 return out
650 }
651
652 result, err := parity.BuildL2ResultFromWalks(walks, parity.BuildOptions{
653 EnableLLDP: scenario.Protocols.LLDP,
654 EnableCDP: scenario.Protocols.CDP,
655 EnableBridge: scenario.Protocols.Bridge,
656 EnableARP: scenario.Protocols.ARPND,
657 })
658 if err != nil {
659 out.Errors = append(out.Errors, err.Error())
660 return out
661 }
662 out.Actual = actualBehaviorSnapshot(result)
663 out.Diff = diffBehaviorSnapshots(out.Expected, out.Actual)
664
665 if !out.InScope {
666 out.Status = "skipped"
667 return out
668 }
669 if len(out.Errors) > 0 {
670 out.Status = "error"
671 return out
672 }
673 if out.Diff.ZeroDiff {
674 out.Status = "zero-diff"
675 } else {
676 out.Status = "diff"
677 }
678 return out
679 }
680
681 func collectBehaviorOracleFixtureInputs(scenario parity.ResolvedScenario) ([]behaviorOracleFixture, error) {
682 out := make([]behaviorOracleFixture, 0, len(scenario.Fixtures))
683 for _, fixture := range scenario.Fixtures {
684 info, err := os.Stat(fixture.WalkFile)
685 if err != nil {
686 return nil, fmt.Errorf("stat walk file %q: %w", fixture.WalkFile, err)
687 }
688 sha256Value, err := sha256File(fixture.WalkFile)
689 if err != nil {
690 return nil, fmt.Errorf("sha256 walk file %q: %w", fixture.WalkFile, err)
691 }
692
693 out = append(out, behaviorOracleFixture{
694 DeviceID: fixture.DeviceID,
695 Hostname: fixture.Hostname,
696 Address: fixture.Address,
697 WalkFile: filepath.ToSlash(fixture.WalkFile),
698 SHA256: sha256Value,
699 SizeBytes: info.Size(),
700 })
701 }
702
703 sort.Slice(out, func(i, j int) bool {
704 if out[i].DeviceID != out[j].DeviceID {
705 return out[i].DeviceID < out[j].DeviceID
706 }
707 return out[i].WalkFile < out[j].WalkFile
708 })
709 return out, nil
710 }
711
712 func expectedBehaviorSnapshot(golden parity.GoldenDocument) behaviorOracleSnapshot {
713 canonical := golden.Canonical()
714 devices := append([]parity.GoldenDevice(nil), canonical.Devices...)
715 adjacencies := append([]parity.GoldenAdjacency(nil), canonical.Adjacencies...)
716 return behaviorOracleSnapshot{
717 Devices: devices,
718 Adjacencies: adjacencies,
719 Metadata: behaviorOracleMetadata{
720 Devices: canonical.Expectations.Devices,
721 DirectionalAdjacencies: canonical.Expectations.DirectionalAdjacencies,
722 },
723 }
724 }
725
726 func actualBehaviorSnapshot(result l2topology.Result) behaviorOracleSnapshot {
727 devices := make([]parity.GoldenDevice, 0, len(result.Devices))
728 for _, dev := range result.Devices {
729 devices = append(devices, parity.GoldenDevice{
730 ID: dev.ID,
731 Hostname: dev.Hostname,
732 })
733 }
734 sort.Slice(devices, func(i, j int) bool {
735 if devices[i].ID != devices[j].ID {
736 return devices[i].ID < devices[j].ID
737 }
738 return devices[i].Hostname < devices[j].Hostname
739 })
740
741 adjacencies := make([]parity.GoldenAdjacency, 0, len(result.Adjacencies))
742 for _, adj := range result.Adjacencies {
743 adjacencies = append(adjacencies, parity.GoldenAdjacency{
744 Protocol: adj.Protocol,
745 SourceDevice: adj.SourceID,
746 SourcePort: adj.SourcePort,
747 TargetDevice: adj.TargetID,
748 TargetPort: adj.TargetPort,
749 })
750 }
751 sort.Slice(adjacencies, func(i, j int) bool {
752 ai := adjacencies[i]
753 aj := adjacencies[j]
754 if ai.Protocol != aj.Protocol {
755 return ai.Protocol < aj.Protocol
756 }
757 if ai.SourceDevice != aj.SourceDevice {
758 return ai.SourceDevice < aj.SourceDevice
759 }
760 if ai.SourcePort != aj.SourcePort {
761 return ai.SourcePort < aj.SourcePort
762 }
763 if ai.TargetDevice != aj.TargetDevice {
764 return ai.TargetDevice < aj.TargetDevice
765 }
766 return ai.TargetPort < aj.TargetPort
767 })
768
769 return behaviorOracleSnapshot{
770 Devices: devices,
771 Adjacencies: adjacencies,
772 Metadata: behaviorOracleMetadata{
773 Devices: len(devices),
774 DirectionalAdjacencies: len(adjacencies),
775 },
776 }
777 }
778
779 func diffBehaviorSnapshots(expected, actual behaviorOracleSnapshot) behaviorOracleDiff {
780 diff := behaviorOracleDiff{
781 ZeroDiff: true,
782 }
783
784 expectedByID := make(map[string]parity.GoldenDevice, len(expected.Devices))
785 for _, dev := range expected.Devices {
786 expectedByID[dev.ID] = dev
787 }
788 actualByID := make(map[string]parity.GoldenDevice, len(actual.Devices))
789 for _, dev := range actual.Devices {
790 actualByID[dev.ID] = dev
791 }
792
793 for _, dev := range expected.Devices {
794 actualDev, ok := actualByID[dev.ID]
795 if !ok {
796 diff.MissingDevices = append(diff.MissingDevices, dev)
797 continue
798 }
799 if dev.Hostname != actualDev.Hostname {
800 diff.HostnameMismatches = append(diff.HostnameMismatches, behaviorOracleDeviceDelta{
801 DeviceID: dev.ID,
802 Expected: dev.Hostname,
803 Actual: actualDev.Hostname,
804 })
805 }
806 }
807 for _, dev := range actual.Devices {
808 if _, ok := expectedByID[dev.ID]; !ok {
809 diff.UnexpectedDevices = append(diff.UnexpectedDevices, dev)
810 }
811 }
812
813 expectedAdjByKey := make(map[string]parity.GoldenAdjacency, len(expected.Adjacencies))
814 for _, adj := range expected.Adjacencies {
815 expectedAdjByKey[goldenAdjacencyKey(adj)] = adj
816 }
817 actualAdjByKey := make(map[string]parity.GoldenAdjacency, len(actual.Adjacencies))
818 for _, adj := range actual.Adjacencies {
819 actualAdjByKey[goldenAdjacencyKey(adj)] = adj
820 }
821
822 for _, adj := range expected.Adjacencies {
823 if _, ok := actualAdjByKey[goldenAdjacencyKey(adj)]; !ok {
824 diff.MissingAdjacencies = append(diff.MissingAdjacencies, adj)
825 }
826 }
827 for _, adj := range actual.Adjacencies {
828 if _, ok := expectedAdjByKey[goldenAdjacencyKey(adj)]; !ok {
829 diff.UnexpectedAdjacencies = append(diff.UnexpectedAdjacencies, adj)
830 }
831 }
832
833 diff.MetadataMismatches = append(diff.MetadataMismatches,
834 buildCountDelta("devices", expected.Metadata.Devices, actual.Metadata.Devices),
835 buildCountDelta("directional_adjacencies", expected.Metadata.DirectionalAdjacencies, actual.Metadata.DirectionalAdjacencies),
836 )
837 filteredCountDeltas := make([]behaviorOracleCountDelta, 0, len(diff.MetadataMismatches))
838 for _, delta := range diff.MetadataMismatches {
839 if delta.Field == "" {
840 continue
841 }
842 filteredCountDeltas = append(filteredCountDeltas, delta)
843 }
844 diff.MetadataMismatches = filteredCountDeltas
845
846 sort.Slice(diff.MissingDevices, func(i, j int) bool { return diff.MissingDevices[i].ID < diff.MissingDevices[j].ID })
847 sort.Slice(diff.UnexpectedDevices, func(i, j int) bool { return diff.UnexpectedDevices[i].ID < diff.UnexpectedDevices[j].ID })
848 sort.Slice(diff.HostnameMismatches, func(i, j int) bool { return diff.HostnameMismatches[i].DeviceID < diff.HostnameMismatches[j].DeviceID })
849 sort.Slice(diff.MissingAdjacencies, func(i, j int) bool {
850 return goldenAdjacencyKey(diff.MissingAdjacencies[i]) < goldenAdjacencyKey(diff.MissingAdjacencies[j])
851 })
852 sort.Slice(diff.UnexpectedAdjacencies, func(i, j int) bool {
853 return goldenAdjacencyKey(diff.UnexpectedAdjacencies[i]) < goldenAdjacencyKey(diff.UnexpectedAdjacencies[j])
854 })
855 sort.Slice(diff.MetadataMismatches, func(i, j int) bool { return diff.MetadataMismatches[i].Field < diff.MetadataMismatches[j].Field })
856
857 if len(diff.MissingDevices) > 0 ||
858 len(diff.UnexpectedDevices) > 0 ||
859 len(diff.HostnameMismatches) > 0 ||
860 len(diff.MissingAdjacencies) > 0 ||
861 len(diff.UnexpectedAdjacencies) > 0 ||
862 len(diff.MetadataMismatches) > 0 {
863 diff.ZeroDiff = false
864 }
865 return diff
866 }
867
868 func buildCountDelta(field string, expected, actual int) behaviorOracleCountDelta {
869 if expected == actual {
870 return behaviorOracleCountDelta{}
871 }
872 return behaviorOracleCountDelta{
873 Field: field,
874 Expected: expected,
875 Actual: actual,
876 }
877 }
878
879 func goldenAdjacencyKey(adj parity.GoldenAdjacency) string {
880 return fmt.Sprintf("%s|%s|%s|%s|%s", adj.Protocol, adj.SourceDevice, adj.SourcePort, adj.TargetDevice, adj.TargetPort)
881 }
882
883 func scenarioInScope(scenario parity.ManifestScenario) bool {
884 for _, protocol := range enabledProtocols(scenario.Protocols) {
885 switch protocol {
886 case "lldp", "cdp", "bridge_fdb", "arp_nd":
887 continue
888 default:
889 return false
890 }
891 }
892 return true
893 }
894
895 func buildBehaviorOracleDiffMarkdown(report behaviorOracleReport) string {
896 var b strings.Builder
897 b.WriteString("# Behavior Oracle Diff Report\n\n")
898 b.WriteString(fmt.Sprintf("- Generated at (UTC): `%s`\n", report.GeneratedAtUTC))
899 b.WriteString(fmt.Sprintf("- Status: `%s`\n", report.Status))
900 b.WriteString(fmt.Sprintf("- In-scope protocols: `%s`\n", strings.Join(report.Scope.Protocols, ", ")))
901 b.WriteString(fmt.Sprintf("- Scenarios: total `%d`, in-scope `%d`, skipped `%d`\n",
902 report.Totals.ScenariosTotal, report.Totals.ScenariosInScope, report.Totals.ScenariosSkipped))
903 b.WriteString(fmt.Sprintf("- Zero-diff `%d`, with diffs `%d`, failures `%d`\n\n",
904 report.Totals.ScenariosZeroDiff, report.Totals.ScenariosWithDiffs, report.Totals.ScenariosWithFailures))
905
906 b.WriteString("## Pass Criteria\n\n")
907 b.WriteString("- No missing or unexpected device IDs.\n")
908 b.WriteString("- No hostname mismatches for matched device IDs.\n")
909 b.WriteString("- No missing or unexpected directed adjacency keys (`protocol|source_device|source_port|target_device|target_port`).\n")
910 b.WriteString("- No metadata mismatches (`devices`, `directional_adjacencies`).\n\n")
911
912 b.WriteString("## Per-Scenario Summary\n\n")
913 for _, scenario := range report.Scenarios {
914 b.WriteString(fmt.Sprintf("- `%s` (%s): status `%s`; missing_devices=%d unexpected_devices=%d hostname_mismatches=%d missing_adjacencies=%d unexpected_adjacencies=%d metadata_mismatches=%d\n",
915 scenario.ID,
916 strings.Join(scenario.Protocols, ","),
917 scenario.Status,
918 len(scenario.Diff.MissingDevices),
919 len(scenario.Diff.UnexpectedDevices),
920 len(scenario.Diff.HostnameMismatches),
921 len(scenario.Diff.MissingAdjacencies),
922 len(scenario.Diff.UnexpectedAdjacencies),
923 len(scenario.Diff.MetadataMismatches)))
924 }
925 b.WriteString("\n")
926
927 b.WriteString("## Command Evidence\n\n")
928 b.WriteString("- `go run ./tools/topology-parity-evidence --mode oracle-diff`\n")
929 return b.String()
930 }
931
932 type testSelection struct {
933 packagePath string
934 tests []string
935 }
936
937 type goTestSelectionResult struct {
938 command string
939 passed []string
940 failed []string
941 missing []string
942 commandError string
943 }
944
945 type goTestEvent struct {
946 Action string `json:"Action"`
947 Test string `json:"Test"`
948 }
949
950 func runPhase2(opts options) error {
951 summary, err := buildSuiteSummary(opts)
952 if err != nil {
953 return err
954 }
955
956 modules := []struct {
957 name string
958 selections []testSelection
959 }{
960 {
961 name: "lldp",
962 selections: []testSelection{{
963 packagePath: "./pkg/l2topology",
964 tests: []string{
965 "TestMatchLLDPLinksEnlinkdPassOrder_Precedence",
966 "TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses/port-description",
967 "TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses/sysname",
968 "TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses/chassis-port-subtype",
969 "TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses/chassis-port-description",
970 "TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses/chassis-only",
971 },
972 }},
973 },
974 {
975 name: "cdp",
976 selections: []testSelection{{
977 packagePath: "./pkg/l2topology",
978 tests: []string{
979 "TestMatchCDPLinksEnlinkdPassOrder_DefaultAndParsedTarget",
980 "TestMatchCDPLinksEnlinkdPassOrder_SkipsSelfTarget",
981 },
982 }},
983 },
984 {
985 name: "bridge_fdb_arp",
986 selections: []testSelection{{
987 packagePath: "./pkg/l2topology",
988 tests: []string{
989 "TestBuildL2ResultFromObservations_FDBAttachments",
990 "TestBuildL2ResultFromObservations_FDBDropsDuplicateMACAcrossPorts",
991 "TestBuildL2ResultFromObservations_FDBSkipsSelfAndNonLearned",
992 "TestBuildL2ResultFromObservations_FDBBridgeDomainFallbackToBridgePort",
993 },
994 }},
995 },
996 {
997 name: "updater",
998 selections: []testSelection{
999 {
1000 packagePath: "./pkg/l2topology",
1001 tests: []string{
1002 "TestBuildL2ResultFromObservations_AnnotatesPairMetadata",
1003 },
1004 },
1005 {
1006 packagePath: "./pkg/l2topology",
1007 tests: []string{
1008 "TestToGraph_MergesPairedAdjacenciesIntoBidirectionalLink",
1009 },
1010 },
1011 },
1012 },
1013 }
1014
1015 moduleStatus := make([]phase2CheckStatus, 0, len(modules))
1016 overallPass := summary.ScenariosFailed == 0
1017 for _, module := range modules {
1018 status, err := runSelectionGroup(module.name, module.selections)
1019 if err != nil {
1020 return err
1021 }
1022 moduleStatus = append(moduleStatus, status)
1023 if status.Status != "pass" {
1024 overallPass = false
1025 }
1026 }
1027
1028 reversePairQuality, err := runSelectionGroup("reverse_pair_quality", []testSelection{{
1029 packagePath: "./plugin/go.d/collector/snmp",
1030 tests: []string{
1031 "TestTopologyCache_LldpSnapshot",
1032 "TestTopologyCache_CdpSnapshot",
1033 "TestTopologyCache_CdpSnapshotHexAddress",
1034 "TestTopologyCache_CdpSnapshotRawAddressWithoutIP",
1035 "TestTopologyCache_SnapshotBidirectionalPairMetadata",
1036 },
1037 }})
1038 if err != nil {
1039 return err
1040 }
1041 if reversePairQuality.Status != "pass" {
1042 overallPass = false
1043 }
1044
1045 identityMergeQuality, err := runSelectionGroup("identity_merge_quality", []testSelection{{
1046 packagePath: "./plugin/go.d/collector/snmp",
1047 tests: []string{
1048 "TestTopologyCache_SnapshotMergesRemoteIdentityAcrossProtocols",
1049 },
1050 }})
1051 if err != nil {
1052 return err
1053 }
1054 if identityMergeQuality.Status != "pass" {
1055 overallPass = false
1056 }
1057
1058 assertionCoverage, err := computePhase2AssertionCoverage(opts.evidencePath)
1059 if err != nil {
1060 return err
1061 }
1062 if assertionCoverage.Status != "pass" {
1063 overallPass = false
1064 }
1065
1066 report := phase2Report{
1067 Version: "v1",
1068 GeneratedAtUTC: time.Now().UTC().Format(time.RFC3339),
1069 Status: "pass",
1070 Suite: phase2SuiteSummary{
1071 FixtureScenarios: summary.FixtureScenarios,
1072 FixtureFiles: summary.FixtureFiles,
1073 TotalScenarios: summary.TotalScenarios,
1074 ScenariosPassed: summary.ScenariosPassed,
1075 ScenariosFailed: summary.ScenariosFailed,
1076 TotalTestsMapped: summary.TotalTestsMapped,
1077 TotalTestsInventory: summary.TotalTestsInventory,
1078 TotalAssertionsMapped: summary.TotalAssertionsMapped,
1079 TotalAssertionsTotal: summary.TotalAssertionsTotal,
1080 ProtocolCounts: append([]protocolSummary(nil), summary.ProtocolCounts...),
1081 },
1082 ModuleParity: moduleStatus,
1083 ReversePairQuality: reversePairQuality,
1084 IdentityMergeQuality: identityMergeQuality,
1085 AssertionCoverage: assertionCoverage,
1086 DeferredGaps: deferredOfficeGaps(opts.evidencePath),
1087 }
1088
1089 if !overallPass {
1090 report.Status = "fail"
1091 }
1092
1093 reportBytes, err := json.MarshalIndent(report, "", " ")
1094 if err != nil {
1095 return fmt.Errorf("marshal phase2 report json: %w", err)
1096 }
1097 reportBytes = append(reportBytes, '\n')
1098 if err := os.MkdirAll(filepath.Dir(opts.phase2Report), 0o755); err != nil {
1099 return fmt.Errorf("create phase2 report directory: %w", err)
1100 }
1101 if err := os.WriteFile(opts.phase2Report, reportBytes, 0o644); err != nil {
1102 return fmt.Errorf("write phase2 report %q: %w", opts.phase2Report, err)
1103 }
1104
1105 gapReport := buildPhase2GapReportMarkdown(report)
1106 if err := os.MkdirAll(filepath.Dir(opts.phase2Gap), 0o755); err != nil {
1107 return fmt.Errorf("create phase2 gap report directory: %w", err)
1108 }
1109 if err := os.WriteFile(opts.phase2Gap, []byte(gapReport), 0o644); err != nil {
1110 return fmt.Errorf("write phase2 gap report %q: %w", opts.phase2Gap, err)
1111 }
1112
1113 fmt.Printf("phase2 report complete\n")
1114 fmt.Printf("report file: %s\n", opts.phase2Report)
1115 fmt.Printf("gap report: %s\n", opts.phase2Gap)
1116 fmt.Printf("status: %s\n", report.Status)
1117 fmt.Printf("in-scope assertion coverage: %d/%d ported (not-applicable=%d unmapped=%d)\n",
1118 assertionCoverage.InScopePorted,
1119 assertionCoverage.InScopeTotal,
1120 assertionCoverage.InScopeNotApplicable,
1121 assertionCoverage.InScopeUnmapped)
1122
1123 if report.Status != "pass" {
1124 return fmt.Errorf("phase2 report contains failing gates")
1125 }
1126 return nil
1127 }
1128
1129 func runSelectionGroup(name string, selections []testSelection) (phase2CheckStatus, error) {
1130 status := phase2CheckStatus{
1131 Name: name,
1132 Status: "pass",
1133 Commands: make([]string, 0, len(selections)),
1134 }
1135
1136 for _, selection := range selections {
1137 result, err := runGoTestSelection(selection)
1138 if err != nil {
1139 return phase2CheckStatus{}, err
1140 }
1141 status.Commands = append(status.Commands, result.command)
1142 status.ChecksTotal += len(selection.tests)
1143 status.ChecksPassed += len(result.passed)
1144 status.Failed = append(status.Failed, result.failed...)
1145 status.Missing = append(status.Missing, result.missing...)
1146 if result.commandError != "" {
1147 status.Errors = append(status.Errors, result.commandError)
1148 }
1149 }
1150
1151 status.Failed = uniqueSortedStrings(status.Failed)
1152 status.Missing = uniqueSortedStrings(status.Missing)
1153 status.Errors = uniqueSortedStrings(status.Errors)
1154
1155 if len(status.Failed) > 0 || len(status.Missing) > 0 || len(status.Errors) > 0 {
1156 status.Status = "fail"
1157 }
1158 return status, nil
1159 }
1160
1161 func runGoTestSelection(selection testSelection) (goTestSelectionResult, error) {
1162 if strings.TrimSpace(selection.packagePath) == "" {
1163 return goTestSelectionResult{}, fmt.Errorf("test selection package path is empty")
1164 }
1165 if len(selection.tests) == 0 {
1166 return goTestSelectionResult{}, fmt.Errorf("test selection for %s has no tests", selection.packagePath)
1167 }
1168
1169 topLevelTests := topLevelTestNames(selection.tests)
1170 regex := buildGoTestNameRegex(topLevelTests)
1171 args := []string{"test", "-json", selection.packagePath, "-run", regex, "-count=1"}
1172 command := "go " + strings.Join(args, " ")
1173
1174 cmd := exec.Command("go", args...)
1175 output, err := cmd.CombinedOutput()
1176
1177 passedSet := make(map[string]struct{}, len(selection.tests))
1178 failedSet := make(map[string]struct{}, len(selection.tests))
1179 scanner := bufio.NewScanner(bytes.NewReader(output))
1180 scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
1181 for scanner.Scan() {
1182 line := scanner.Bytes()
1183 var event goTestEvent
1184 if json.Unmarshal(line, &event) != nil {
1185 continue
1186 }
1187 if strings.TrimSpace(event.Test) == "" {
1188 continue
1189 }
1190 switch event.Action {
1191 case "pass":
1192 passedSet[event.Test] = struct{}{}
1193 case "fail":
1194 failedSet[event.Test] = struct{}{}
1195 }
1196 }
1197 if scanErr := scanner.Err(); scanErr != nil {
1198 return goTestSelectionResult{}, fmt.Errorf("scan go test json output: %w", scanErr)
1199 }
1200
1201 result := goTestSelectionResult{
1202 command: command,
1203 passed: make([]string, 0, len(selection.tests)),
1204 failed: make([]string, 0, len(selection.tests)),
1205 missing: make([]string, 0, len(selection.tests)),
1206 }
1207 for _, testName := range selection.tests {
1208 if _, failed := failedSet[testName]; failed {
1209 result.failed = append(result.failed, testName)
1210 continue
1211 }
1212 if _, passed := passedSet[testName]; passed {
1213 result.passed = append(result.passed, testName)
1214 continue
1215 }
1216 result.missing = append(result.missing, testName)
1217 }
1218
1219 sort.Strings(result.passed)
1220 sort.Strings(result.failed)
1221 sort.Strings(result.missing)
1222
1223 if err != nil {
1224 result.commandError = truncateWhitespace(string(output), 2048)
1225 }
1226 return result, nil
1227 }
1228
1229 func buildGoTestNameRegex(testNames []string) string {
1230 parts := make([]string, 0, len(testNames))
1231 for _, name := range testNames {
1232 name = strings.TrimSpace(name)
1233 if name == "" {
1234 continue
1235 }
1236 parts = append(parts, regexp.QuoteMeta(name))
1237 }
1238 sort.Strings(parts)
1239 return "^(" + strings.Join(parts, "|") + ")$"
1240 }
1241
1242 func topLevelTestNames(testNames []string) []string {
1243 names := make([]string, 0, len(testNames))
1244 for _, name := range testNames {
1245 name = strings.TrimSpace(name)
1246 if name == "" {
1247 continue
1248 }
1249 if idx := strings.IndexByte(name, '/'); idx > 0 {
1250 name = name[:idx]
1251 }
1252 names = append(names, name)
1253 }
1254 return uniqueSortedStrings(names)
1255 }
1256
1257 type assertionScopeRow struct {
1258 AssertionID string
1259 Scope string
1260 }
1261
1262 func computePhase2AssertionCoverage(evidencePath string) (phase2AssertionCoverage, error) {
1263 inventoryPath := filepath.Join(evidencePath, defaultAssertionInventory)
1264 mappingPath := filepath.Join(evidencePath, defaultAssertionMapping)
1265
1266 assertions, err := readAssertionScopeRows(inventoryPath)
1267 if err != nil {
1268 return phase2AssertionCoverage{}, err
1269 }
1270 statusByAssertion, err := readMappingStatusByAssertion(mappingPath)
1271 if err != nil {
1272 return phase2AssertionCoverage{}, err
1273 }
1274
1275 inScopeProtocols := map[string]struct{}{
1276 "lldp": {},
1277 "cdp": {},
1278 "bridge_fdb": {},
1279 "arp_nd": {},
1280 }
1281
1282 coverage := phase2AssertionCoverage{}
1283 for _, assertion := range assertions {
1284 status, ok := statusByAssertion[assertion.AssertionID]
1285 if _, inScope := inScopeProtocols[assertion.Scope]; inScope {
1286 coverage.InScopeTotal++
1287 if !ok {
1288 coverage.InScopeUnmapped++
1289 continue
1290 }
1291 switch status {
1292 case "ported":
1293 coverage.InScopePorted++
1294 case "not-applicable-approved":
1295 coverage.InScopeNotApplicable++
1296 default:
1297 return phase2AssertionCoverage{}, fmt.Errorf("unsupported status %q for in-scope assertion %q", status, assertion.AssertionID)
1298 }
1299 continue
1300 }
1301
1302 if !ok {
1303 continue
1304 }
1305 switch status {
1306 case "ported":
1307 coverage.OutOfScopePorted++
1308 case "not-applicable-approved":
1309 coverage.OutOfScopeNotApplicable++
1310 default:
1311 return phase2AssertionCoverage{}, fmt.Errorf("unsupported status %q for out-of-scope assertion %q", status, assertion.AssertionID)
1312 }
1313 }
1314
1315 coverage.Status = "pass"
1316 if coverage.InScopeNotApplicable > 0 || coverage.InScopeUnmapped > 0 || coverage.InScopePorted != coverage.InScopeTotal {
1317 coverage.Status = "fail"
1318 }
1319 return coverage, nil
1320 }
1321
1322 func readAssertionScopeRows(path string) ([]assertionScopeRow, error) {
1323 f, err := os.Open(path)
1324 if err != nil {
1325 return nil, fmt.Errorf("open assertion inventory %q: %w", path, err)
1326 }
1327 defer f.Close()
1328
1329 r := csv.NewReader(f)
1330 records, err := r.ReadAll()
1331 if err != nil {
1332 return nil, fmt.Errorf("read assertion inventory %q: %w", path, err)
1333 }
1334 if len(records) == 0 {
1335 return nil, fmt.Errorf("assertion inventory %q is empty", path)
1336 }
1337
1338 header := strings.Join(records[0], ",")
1339 expectedHeader := "class,method,assertion_id,source_file,line,assert_call,protocol_scope"
1340 if header != expectedHeader {
1341 return nil, fmt.Errorf("unexpected assertion inventory header in %q: %q", path, header)
1342 }
1343
1344 rows := make([]assertionScopeRow, 0, len(records)-1)
1345 for i := 1; i < len(records); i++ {
1346 rec := records[i]
1347 if len(rec) != 7 {
1348 return nil, fmt.Errorf("assertion inventory %q line %d: expected 7 columns, got %d", path, i+1, len(rec))
1349 }
1350 rows = append(rows, assertionScopeRow{
1351 AssertionID: strings.TrimSpace(rec[2]),
1352 Scope: strings.TrimSpace(rec[6]),
1353 })
1354 }
1355 return rows, nil
1356 }
1357
1358 func readMappingStatusByAssertion(path string) (map[string]string, error) {
1359 f, err := os.Open(path)
1360 if err != nil {
1361 return nil, fmt.Errorf("open mapping csv %q: %w", path, err)
1362 }
1363 defer f.Close()
1364
1365 r := csv.NewReader(f)
1366 records, err := r.ReadAll()
1367 if err != nil {
1368 return nil, fmt.Errorf("read mapping csv %q: %w", path, err)
1369 }
1370 if len(records) == 0 {
1371 return nil, fmt.Errorf("mapping csv %q is empty", path)
1372 }
1373
1374 header := strings.Join(records[0], ",")
1375 expectedHeader := "upstream_class,upstream_method,upstream_assert_id,local_test,local_assert,status"
1376 if header != expectedHeader {
1377 return nil, fmt.Errorf("unexpected mapping header in %q: %q", path, header)
1378 }
1379
1380 out := make(map[string]string, len(records)-1)
1381 for i := 1; i < len(records); i++ {
1382 rec := records[i]
1383 if len(rec) != 6 {
1384 return nil, fmt.Errorf("mapping csv %q line %d: expected 6 columns, got %d", path, i+1, len(rec))
1385 }
1386 assertionID := strings.TrimSpace(rec[2])
1387 status := strings.TrimSpace(rec[5])
1388 if status != "ported" && status != "not-applicable-approved" {
1389 return nil, fmt.Errorf("mapping csv %q line %d: unsupported status %q", path, i+1, status)
1390 }
1391 out[assertionID] = status
1392 }
1393 return out, nil
1394 }
1395
1396 func deferredOfficeGaps(evidenceDir string) []phase2DeferredGap {
1397 officeReportPath := filepath.Join(evidenceDir, defaultOfficeReportFile)
1398 if _, err := os.Stat(officeReportPath); err == nil {
1399 return []phase2DeferredGap{}
1400 }
1401
1402 return []phase2DeferredGap{
1403 {
1404 ID: "gap-live-office-validation",
1405 Description: "Live office `topology:snmp` sanity validation is still pending.",
1406 Reason: "Repository test fixtures do not include the live office runtime environment.",
1407 Evidence: "TODO-topology-library-phase2-direct-port.md Track 3/T4 runtime gate",
1408 },
1409 }
1410 }
1411
1412 func buildPhase2GapReportMarkdown(report phase2Report) string {
1413 var b strings.Builder
1414 b.WriteString("# Topology Library Phase 2 Gap Report\n\n")
1415 b.WriteString(fmt.Sprintf("- Generated at (UTC): `%s`\n", report.GeneratedAtUTC))
1416 b.WriteString(fmt.Sprintf("- Overall status: `%s`\n", report.Status))
1417 b.WriteString(fmt.Sprintf("- Scenario parity: `%d/%d` passed\n", report.Suite.ScenariosPassed, report.Suite.TotalScenarios))
1418 b.WriteString(fmt.Sprintf("- Assertion parity: `%d/%d` mapped\n\n", report.Suite.TotalAssertionsMapped, report.Suite.TotalAssertionsTotal))
1419
1420 b.WriteString("## What Matches Enlinkd (In Scope)\n\n")
1421 for _, module := range report.ModuleParity {
1422 b.WriteString(fmt.Sprintf("- `%s`: `%d/%d` checks passed (status: `%s`).\n",
1423 module.Name, module.ChecksPassed, module.ChecksTotal, module.Status))
1424 }
1425 b.WriteString(fmt.Sprintf("- In-scope assertion coverage: `%d/%d` ported, `%d` not-applicable-approved, `%d` unmapped.\n\n",
1426 report.AssertionCoverage.InScopePorted,
1427 report.AssertionCoverage.InScopeTotal,
1428 report.AssertionCoverage.InScopeNotApplicable,
1429 report.AssertionCoverage.InScopeUnmapped))
1430
1431 b.WriteString("## Runtime Quality Checks\n\n")
1432 b.WriteString(fmt.Sprintf("- Reverse pair quality: `%d/%d` checks passed (status: `%s`).\n",
1433 report.ReversePairQuality.ChecksPassed,
1434 report.ReversePairQuality.ChecksTotal,
1435 report.ReversePairQuality.Status))
1436 b.WriteString(fmt.Sprintf("- Identity merge quality: `%d/%d` checks passed (status: `%s`).\n\n",
1437 report.IdentityMergeQuality.ChecksPassed,
1438 report.IdentityMergeQuality.ChecksTotal,
1439 report.IdentityMergeQuality.Status))
1440
1441 b.WriteString("## Intentionally Deferred Gaps\n\n")
1442 if len(report.DeferredGaps) == 0 {
1443 b.WriteString("- none\n")
1444 } else {
1445 for _, gap := range report.DeferredGaps {
1446 b.WriteString(fmt.Sprintf("- `%s`: %s\n", gap.ID, gap.Description))
1447 b.WriteString(fmt.Sprintf(" - Reason: %s\n", gap.Reason))
1448 b.WriteString(fmt.Sprintf(" - Evidence: %s\n", gap.Evidence))
1449 }
1450 }
1451 b.WriteString("\n")
1452
1453 b.WriteString("## Command Evidence\n\n")
1454 for _, module := range report.ModuleParity {
1455 for _, command := range module.Commands {
1456 b.WriteString(fmt.Sprintf("- `%s`\n", command))
1457 }
1458 }
1459 for _, command := range report.ReversePairQuality.Commands {
1460 b.WriteString(fmt.Sprintf("- `%s`\n", command))
1461 }
1462 for _, command := range report.IdentityMergeQuality.Commands {
1463 b.WriteString(fmt.Sprintf("- `%s`\n", command))
1464 }
1465 return b.String()
1466 }
1467
1468 func uniqueSortedStrings(values []string) []string {
1469 if len(values) == 0 {
1470 return nil
1471 }
1472 set := make(map[string]struct{}, len(values))
1473 for _, value := range values {
1474 value = strings.TrimSpace(value)
1475 if value == "" {
1476 continue
1477 }
1478 set[value] = struct{}{}
1479 }
1480 out := make([]string, 0, len(set))
1481 for value := range set {
1482 out = append(out, value)
1483 }
1484 sort.Strings(out)
1485 return out
1486 }
1487
1488 func buildSuiteSummary(opts options) (paritySummary, error) {
1489 localRows, err := verifyFixtureInventory(opts)
1490 if err != nil {
1491 return paritySummary{}, err
1492 }
1493
1494 mapStats, err := collectMappingStats(opts.evidencePath)
1495 if err != nil {
1496 return paritySummary{}, err
1497 }
1498
1499 scenarioResults, protocolCounts, err := collectScenarioSummaries(opts.manifestRoot)
1500 if err != nil {
1501 return paritySummary{}, err
1502 }
1503
1504 passed := 0
1505 for _, result := range scenarioResults {
1506 if result.Passed {
1507 passed++
1508 }
1509 }
1510
1511 return paritySummary{
1512 Version: "v1",
1513 FixtureScenarios: countDistinctScenarios(localRows),
1514 FixtureFiles: len(localRows),
1515 TotalScenarios: len(scenarioResults),
1516 ScenariosPassed: passed,
1517 ScenariosFailed: len(scenarioResults) - passed,
1518 TotalTestsMapped: mapStats.MappedMethods,
1519 TotalTestsInventory: mapStats.TotalMethods,
1520 TotalAssertionsMapped: mapStats.MappedAssertions,
1521 TotalAssertionsTotal: mapStats.TotalAssertions,
1522 ProtocolCounts: protocolCounts,
1523 ScenarioResults: scenarioResults,
1524 GoTests: runRequiredGoTests(),
1525 }, nil
1526 }
1527
1528 func marshalSummaryJSON(summary paritySummary) ([]byte, error) {
1529 payload, err := json.MarshalIndent(summary, "", " ")
1530 if err != nil {
1531 return nil, fmt.Errorf("marshal summary json: %w", err)
1532 }
1533 return append(payload, '\n'), nil
1534 }
1535
1536 func runRequiredGoTests() []goTestSummary {
1537 type testCmd struct {
1538 packageLabel string
1539 args []string
1540 }
1541
1542 commands := []testCmd{
1543 {
1544 packageLabel: "./pkg/l2topology/parity",
1545 args: []string{"test", "./pkg/l2topology/parity"},
1546 },
1547 {
1548 packageLabel: "./pkg/l2topology",
1549 args: []string{"test", "./pkg/l2topology"},
1550 },
1551 {
1552 packageLabel: "./tools/topology-parity-evidence",
1553 args: []string{"test", "./tools/topology-parity-evidence"},
1554 },
1555 {
1556 packageLabel: "./plugin/go.d/collector/snmp -run ^TestTopology",
1557 args: []string{"test", "./plugin/go.d/collector/snmp", "-run", "^TestTopology"},
1558 },
1559 }
1560
1561 results := make([]goTestSummary, 0, len(commands))
1562 for _, tc := range commands {
1563 cmd := exec.Command("go", tc.args...)
1564 output, err := cmd.CombinedOutput()
1565 if err != nil {
1566 results = append(results, goTestSummary{
1567 Package: tc.packageLabel,
1568 Passed: false,
1569 Error: truncateWhitespace(string(output), 2048),
1570 })
1571 continue
1572 }
1573 results = append(results, goTestSummary{
1574 Package: tc.packageLabel,
1575 Passed: true,
1576 })
1577 }
1578 return results
1579 }
1580
1581 func collectScenarioSummaries(manifestRoot string) ([]scenarioSummary, []protocolSummary, error) {
1582 pattern := filepath.Join(manifestRoot, "*/manifest.yaml")
1583 manifestPaths, err := filepath.Glob(pattern)
1584 if err != nil {
1585 return nil, nil, fmt.Errorf("glob manifests %q: %w", pattern, err)
1586 }
1587 sort.Strings(manifestPaths)
1588 if len(manifestPaths) == 0 {
1589 return nil, nil, fmt.Errorf("no manifests found under %q", manifestRoot)
1590 }
1591
1592 results := make([]scenarioSummary, 0, 64)
1593 protocolCounts := map[string]protocolSummary{
1594 "lldp": {Protocol: "lldp"},
1595 "cdp": {Protocol: "cdp"},
1596 "bridge_fdb": {Protocol: "bridge_fdb"},
1597 "arp_nd": {Protocol: "arp_nd"},
1598 }
1599
1600 for _, manifestPath := range manifestPaths {
1601 manifest, err := parity.LoadManifest(manifestPath)
1602 if err != nil {
1603 return nil, nil, err
1604 }
1605
1606 scenarios := append([]parity.ManifestScenario(nil), manifest.Scenarios...)
1607 sort.Slice(scenarios, func(i, j int) bool {
1608 return scenarios[i].ID < scenarios[j].ID
1609 })
1610
1611 for _, scenario := range scenarios {
1612 result := evaluateScenario(manifestPath, scenario)
1613 results = append(results, result)
1614
1615 for _, protocol := range result.Protocols {
1616 count := protocolCounts[protocol]
1617 count.Total++
1618 if result.Passed {
1619 count.Passed++
1620 } else {
1621 count.Failed++
1622 }
1623 protocolCounts[protocol] = count
1624 }
1625 }
1626 }
1627
1628 orderedProtocols := []string{"lldp", "cdp", "bridge_fdb", "arp_nd"}
1629 summary := make([]protocolSummary, 0, len(orderedProtocols))
1630 for _, protocol := range orderedProtocols {
1631 summary = append(summary, protocolCounts[protocol])
1632 }
1633 return results, summary, nil
1634 }
1635
1636 func evaluateScenario(manifestPath string, scenario parity.ManifestScenario) scenarioSummary {
1637 out := scenarioSummary{
1638 ID: scenario.ID,
1639 Manifest: filepath.ToSlash(manifestPath),
1640 Protocols: enabledProtocols(scenario.Protocols),
1641 }
1642
1643 failures := make([]string, 0, 4)
1644
1645 resolved, err := parity.ResolveScenario(manifestPath, scenario)
1646 if err != nil {
1647 out.Failures = []string{err.Error()}
1648 return out
1649 }
1650
1651 if err := parity.ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON); err != nil {
1652 failures = append(failures, err.Error())
1653 }
1654
1655 golden, err := parity.LoadGoldenYAML(resolved.GoldenYAML)
1656 if err != nil {
1657 failures = append(failures, err.Error())
1658 out.Failures = failures
1659 return out
1660 }
1661
1662 walks, err := parity.LoadScenarioWalks(resolved)
1663 if err != nil {
1664 failures = append(failures, err.Error())
1665 out.Failures = failures
1666 return out
1667 }
1668
1669 result, err := parity.BuildL2ResultFromWalks(walks, parity.BuildOptions{
1670 EnableLLDP: scenario.Protocols.LLDP,
1671 EnableCDP: scenario.Protocols.CDP,
1672 EnableBridge: scenario.Protocols.Bridge,
1673 EnableARP: scenario.Protocols.ARPND,
1674 })
1675 if err != nil {
1676 failures = append(failures, err.Error())
1677 out.Failures = failures
1678 return out
1679 }
1680
1681 if len(result.Devices) != golden.Expectations.Devices {
1682 failures = append(failures, fmt.Sprintf("devices mismatch: expected %d got %d", golden.Expectations.Devices, len(result.Devices)))
1683 }
1684 if len(result.Adjacencies) != golden.Expectations.DirectionalAdjacencies {
1685 failures = append(failures, fmt.Sprintf("directional adjacencies mismatch: expected %d got %d", golden.Expectations.DirectionalAdjacencies, len(result.Adjacencies)))
1686 }
1687
1688 expectedAdjacencies := goldenAdjacencyKeySet(golden.Adjacencies)
1689 actualAdjacencies := resultAdjacencyKeySet(result.Adjacencies)
1690 if !stringSetEqual(expectedAdjacencies, actualAdjacencies) {
1691 failures = append(failures, fmt.Sprintf("adjacency set mismatch: expected %d keys got %d", len(expectedAdjacencies), len(actualAdjacencies)))
1692 }
1693
1694 out.Passed = len(failures) == 0
1695 out.Failures = failures
1696 return out
1697 }
1698
1699 func enabledProtocols(protocols parity.ManifestProtocols) []string {
1700 out := make([]string, 0, 4)
1701 if protocols.LLDP {
1702 out = append(out, "lldp")
1703 }
1704 if protocols.CDP {
1705 out = append(out, "cdp")
1706 }
1707 if protocols.Bridge {
1708 out = append(out, "bridge_fdb")
1709 }
1710 if protocols.ARPND {
1711 out = append(out, "arp_nd")
1712 }
1713 return out
1714 }
1715
1716 func resultAdjacencyKeySet(adjacencies []l2topology.Adjacency) map[string]struct{} {
1717 out := make(map[string]struct{}, len(adjacencies))
1718 for _, adj := range adjacencies {
1719 out[fmt.Sprintf("%s|%s|%s|%s|%s", adj.Protocol, adj.SourceID, adj.SourcePort, adj.TargetID, adj.TargetPort)] = struct{}{}
1720 }
1721 return out
1722 }
1723
1724 func goldenAdjacencyKeySet(adjacencies []parity.GoldenAdjacency) map[string]struct{} {
1725 out := make(map[string]struct{}, len(adjacencies))
1726 for _, adj := range adjacencies {
1727 out[fmt.Sprintf("%s|%s|%s|%s|%s", adj.Protocol, adj.SourceDevice, adj.SourcePort, adj.TargetDevice, adj.TargetPort)] = struct{}{}
1728 }
1729 return out
1730 }
1731
1732 func stringSetEqual(a, b map[string]struct{}) bool {
1733 if len(a) != len(b) {
1734 return false
1735 }
1736 for key := range a {
1737 if _, ok := b[key]; !ok {
1738 return false
1739 }
1740 }
1741 return true
1742 }
1743
1744 func collectMappingStats(evidencePath string) (mappingStats, error) {
1745 mappingFile := filepath.Join(evidencePath, defaultAssertionMapping)
1746 methodInventoryFile := filepath.Join(evidencePath, defaultMethodInventory)
1747 assertionInventoryFile := filepath.Join(evidencePath, defaultAssertionInventory)
1748
1749 mappedAssertions, mappedMethods, err := readMappingCoverage(mappingFile)
1750 if err != nil {
1751 return mappingStats{}, err
1752 }
1753 assertionMethods, err := readAssertionMethodSet(assertionInventoryFile)
1754 if err != nil {
1755 return mappingStats{}, err
1756 }
1757 methodCoverage, err := readMethodCoverage(methodInventoryFile, mappedMethods, assertionMethods)
1758 if err != nil {
1759 return mappingStats{}, err
1760 }
1761 totalAssertions, err := readAssertionInventoryCount(assertionInventoryFile)
1762 if err != nil {
1763 return mappingStats{}, err
1764 }
1765
1766 return mappingStats{
1767 MappedAssertions: mappedAssertions,
1768 TotalAssertions: totalAssertions,
1769 MappedMethods: methodCoverage.mappedMethods,
1770 TotalMethods: methodCoverage.totalMethods,
1771 MappedTestFiles: len(methodCoverage.mappedFiles),
1772 TotalTestFiles: methodCoverage.totalFiles,
1773 }, nil
1774 }
1775
1776 type methodCoverageInfo struct {
1777 totalMethods int
1778 mappedMethods int
1779 totalFiles int
1780 mappedFiles map[string]struct{}
1781 }
1782
1783 func readMappingCoverage(path string) (int, map[string]struct{}, error) {
1784 f, err := os.Open(path)
1785 if err != nil {
1786 return 0, nil, fmt.Errorf("open mapping csv %q: %w", path, err)
1787 }
1788 defer f.Close()
1789
1790 r := csv.NewReader(f)
1791 records, err := r.ReadAll()
1792 if err != nil {
1793 return 0, nil, fmt.Errorf("read mapping csv %q: %w", path, err)
1794 }
1795 if len(records) == 0 {
1796 return 0, nil, fmt.Errorf("mapping csv %q is empty", path)
1797 }
1798
1799 header := strings.Join(records[0], ",")
1800 expectedHeader := "upstream_class,upstream_method,upstream_assert_id,local_test,local_assert,status"
1801 if header != expectedHeader {
1802 return 0, nil, fmt.Errorf("unexpected mapping header in %q: %q", path, header)
1803 }
1804
1805 methods := make(map[string]struct{}, len(records))
1806 for i := 1; i < len(records); i++ {
1807 rec := records[i]
1808 if len(rec) != 6 {
1809 return 0, nil, fmt.Errorf("mapping csv %q line %d: expected 6 columns, got %d", path, i+1, len(rec))
1810 }
1811 status := strings.TrimSpace(rec[5])
1812 if status != "ported" && status != "not-applicable-approved" {
1813 return 0, nil, fmt.Errorf("mapping csv %q line %d: unsupported status %q", path, i+1, status)
1814 }
1815 methods[rec[0]+"#"+rec[1]] = struct{}{}
1816 }
1817 return len(records) - 1, methods, nil
1818 }
1819
1820 func readMethodCoverage(path string, mappedMethods map[string]struct{}, assertionMethods map[string]struct{}) (methodCoverageInfo, error) {
1821 f, err := os.Open(path)
1822 if err != nil {
1823 return methodCoverageInfo{}, fmt.Errorf("open method inventory %q: %w", path, err)
1824 }
1825 defer f.Close()
1826
1827 r := csv.NewReader(f)
1828 records, err := r.ReadAll()
1829 if err != nil {
1830 return methodCoverageInfo{}, fmt.Errorf("read method inventory %q: %w", path, err)
1831 }
1832 if len(records) == 0 {
1833 return methodCoverageInfo{}, fmt.Errorf("method inventory %q is empty", path)
1834 }
1835
1836 header := strings.Join(records[0], ",")
1837 expectedHeader := "class,method,source_file,protocol_scope"
1838 if header != expectedHeader {
1839 return methodCoverageInfo{}, fmt.Errorf("unexpected method inventory header in %q: %q", path, header)
1840 }
1841
1842 allFiles := make(map[string]struct{}, len(records))
1843 mappedFiles := make(map[string]struct{}, len(records))
1844 mappedMethodCount := 0
1845 for i := 1; i < len(records); i++ {
1846 rec := records[i]
1847 if len(rec) != 4 {
1848 return methodCoverageInfo{}, fmt.Errorf("method inventory %q line %d: expected 4 columns, got %d", path, i+1, len(rec))
1849 }
1850 methodKey := rec[0] + "#" + rec[1]
1851 allFiles[rec[2]] = struct{}{}
1852 _, methodHasAssertions := assertionMethods[methodKey]
1853 _, methodMappedByAssertion := mappedMethods[methodKey]
1854 if methodMappedByAssertion || !methodHasAssertions {
1855 mappedMethodCount++
1856 mappedFiles[rec[2]] = struct{}{}
1857 }
1858 }
1859
1860 return methodCoverageInfo{
1861 totalMethods: len(records) - 1,
1862 mappedMethods: mappedMethodCount,
1863 totalFiles: len(allFiles),
1864 mappedFiles: mappedFiles,
1865 }, nil
1866 }
1867
1868 func readAssertionMethodSet(path string) (map[string]struct{}, error) {
1869 f, err := os.Open(path)
1870 if err != nil {
1871 return nil, fmt.Errorf("open assertion inventory %q: %w", path, err)
1872 }
1873 defer f.Close()
1874
1875 r := csv.NewReader(f)
1876 records, err := r.ReadAll()
1877 if err != nil {
1878 return nil, fmt.Errorf("read assertion inventory %q: %w", path, err)
1879 }
1880 if len(records) == 0 {
1881 return nil, fmt.Errorf("assertion inventory %q is empty", path)
1882 }
1883 header := strings.Join(records[0], ",")
1884 expectedHeader := "class,method,assertion_id,source_file,line,assert_call,protocol_scope"
1885 if header != expectedHeader {
1886 return nil, fmt.Errorf("unexpected assertion inventory header in %q: %q", path, header)
1887 }
1888
1889 methods := make(map[string]struct{}, len(records))
1890 for i := 1; i < len(records); i++ {
1891 rec := records[i]
1892 if len(rec) != 7 {
1893 return nil, fmt.Errorf("assertion inventory %q line %d: expected 7 columns, got %d", path, i+1, len(rec))
1894 }
1895 methods[rec[0]+"#"+rec[1]] = struct{}{}
1896 }
1897 return methods, nil
1898 }
1899
1900 func readAssertionInventoryCount(path string) (int, error) {
1901 f, err := os.Open(path)
1902 if err != nil {
1903 return 0, fmt.Errorf("open assertion inventory %q: %w", path, err)
1904 }
1905 defer f.Close()
1906
1907 r := csv.NewReader(f)
1908 records, err := r.ReadAll()
1909 if err != nil {
1910 return 0, fmt.Errorf("read assertion inventory %q: %w", path, err)
1911 }
1912 if len(records) == 0 {
1913 return 0, fmt.Errorf("assertion inventory %q is empty", path)
1914 }
1915 header := strings.Join(records[0], ",")
1916 expectedHeader := "class,method,assertion_id,source_file,line,assert_call,protocol_scope"
1917 if header != expectedHeader {
1918 return 0, fmt.Errorf("unexpected assertion inventory header in %q: %q", path, header)
1919 }
1920 return len(records) - 1, nil
1921 }
1922
1923 func truncateWhitespace(s string, maxLen int) string {
1924 s = strings.TrimSpace(s)
1925 if len(s) <= maxLen {
1926 return s
1927 }
1928 return strings.TrimSpace(s[:maxLen]) + "...(truncated)"
1929 }
1930
1931 func requireDir(p string) error {
1932 st, err := os.Stat(p)
1933 if err != nil {
1934 return err
1935 }
1936 if !st.IsDir() {
1937 return fmt.Errorf("%q is not a directory", p)
1938 }
1939 return nil
1940 }
1941
1942 func syncFixtureMirror(srcRoot, dstRoot string) error {
1943 if err := os.MkdirAll(dstRoot, 0o755); err != nil {
1944 return fmt.Errorf("mkdir destination root: %w", err)
1945 }
1946
1947 sourceFiles := make(map[string]struct{}, 256)
1948 sourceDirs := make(map[string]struct{}, 64)
1949 sourceDirs["."] = struct{}{}
1950
1951 if err := filepath.WalkDir(srcRoot, func(srcPath string, d fs.DirEntry, walkErr error) error {
1952 if walkErr != nil {
1953 return walkErr
1954 }
1955 rel, err := filepath.Rel(srcRoot, srcPath)
1956 if err != nil {
1957 return err
1958 }
1959 if rel == "." {
1960 return nil
1961 }
1962 rel = filepath.Clean(rel)
1963 dstPath := filepath.Join(dstRoot, rel)
1964
1965 if d.IsDir() {
1966 sourceDirs[rel] = struct{}{}
1967 return os.MkdirAll(dstPath, 0o755)
1968 }
1969
1970 info, err := d.Info()
1971 if err != nil {
1972 return err
1973 }
1974 if !info.Mode().IsRegular() {
1975 return nil
1976 }
1977 sourceFiles[rel] = struct{}{}
1978 if err := copyFile(srcPath, dstPath, info.Mode()); err != nil {
1979 return err
1980 }
1981 return nil
1982 }); err != nil {
1983 return err
1984 }
1985
1986 if err := pruneMirror(dstRoot, sourceFiles, sourceDirs); err != nil {
1987 return err
1988 }
1989 return nil
1990 }
1991
1992 func copyFile(srcPath, dstPath string, mode fs.FileMode) error {
1993 src, err := os.Open(srcPath)
1994 if err != nil {
1995 return fmt.Errorf("open source %q: %w", srcPath, err)
1996 }
1997 defer src.Close()
1998
1999 if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
2000 return fmt.Errorf("mkdir parent for %q: %w", dstPath, err)
2001 }
2002
2003 dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode.Perm())
2004 if err != nil {
2005 return fmt.Errorf("open destination %q: %w", dstPath, err)
2006 }
2007 defer dst.Close()
2008
2009 if _, err := io.Copy(dst, src); err != nil {
2010 return fmt.Errorf("copy %q -> %q: %w", srcPath, dstPath, err)
2011 }
2012 return nil
2013 }
2014
2015 func pruneMirror(dstRoot string, sourceFiles, sourceDirs map[string]struct{}) error {
2016 var allPaths []string
2017 if err := filepath.WalkDir(dstRoot, func(dstPath string, d fs.DirEntry, walkErr error) error {
2018 if walkErr != nil {
2019 return walkErr
2020 }
2021 rel, err := filepath.Rel(dstRoot, dstPath)
2022 if err != nil {
2023 return err
2024 }
2025 if rel == "." {
2026 return nil
2027 }
2028 allPaths = append(allPaths, filepath.Clean(rel))
2029 return nil
2030 }); err != nil {
2031 return err
2032 }
2033
2034 // Remove files first, then directories deepest-first.
2035 sort.Slice(allPaths, func(i, j int) bool {
2036 di := strings.Count(allPaths[i], string(filepath.Separator))
2037 dj := strings.Count(allPaths[j], string(filepath.Separator))
2038 if di != dj {
2039 return di > dj
2040 }
2041 return allPaths[i] > allPaths[j]
2042 })
2043
2044 for _, rel := range allPaths {
2045 dstPath := filepath.Join(dstRoot, rel)
2046 info, err := os.Lstat(dstPath)
2047 if err != nil {
2048 if errors.Is(err, os.ErrNotExist) {
2049 continue
2050 }
2051 return err
2052 }
2053
2054 if info.IsDir() {
2055 if _, ok := sourceDirs[rel]; ok {
2056 continue
2057 }
2058 if err := os.Remove(dstPath); err != nil && !errors.Is(err, os.ErrNotExist) {
2059 return fmt.Errorf("remove stale dir %q: %w", dstPath, err)
2060 }
2061 continue
2062 }
2063
2064 if _, ok := sourceFiles[rel]; ok {
2065 continue
2066 }
2067 if err := os.Remove(dstPath); err != nil && !errors.Is(err, os.ErrNotExist) {
2068 return fmt.Errorf("remove stale file %q: %w", dstPath, err)
2069 }
2070 }
2071 return nil
2072 }
2073
2074 func collectFixtureInventory(root, upstreamRelPrefix string) ([]fixtureRow, error) {
2075 rows := make([]fixtureRow, 0, 256)
2076 err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
2077 if walkErr != nil {
2078 return walkErr
2079 }
2080 if d.IsDir() {
2081 return nil
2082 }
2083 info, err := d.Info()
2084 if err != nil {
2085 return err
2086 }
2087 if !info.Mode().IsRegular() {
2088 return nil
2089 }
2090
2091 rel, err := filepath.Rel(root, p)
2092 if err != nil {
2093 return err
2094 }
2095 rel = filepath.ToSlash(filepath.Clean(rel))
2096 parts := strings.Split(rel, "/")
2097 if len(parts) < 2 {
2098 return fmt.Errorf("unexpected fixture relative path %q", rel)
2099 }
2100
2101 hashValue, err := sha256File(p)
2102 if err != nil {
2103 return err
2104 }
2105
2106 rows = append(rows, fixtureRow{
2107 Scenario: parts[0],
2108 File: path.Base(rel),
2109 RelativePath: rel,
2110 SHA256: hashValue,
2111 SizeBytes: info.Size(),
2112 UpstreamPath: path.Join(filepath.ToSlash(upstreamRelPrefix), rel),
2113 })
2114 return nil
2115 })
2116 if err != nil {
2117 return nil, err
2118 }
2119
2120 sort.Slice(rows, func(i, j int) bool {
2121 if rows[i].RelativePath != rows[j].RelativePath {
2122 return rows[i].RelativePath < rows[j].RelativePath
2123 }
2124 return rows[i].SHA256 < rows[j].SHA256
2125 })
2126 return rows, nil
2127 }
2128
2129 func sha256File(p string) (string, error) {
2130 f, err := os.Open(p)
2131 if err != nil {
2132 return "", err
2133 }
2134 defer f.Close()
2135
2136 h := sha256.New()
2137 if _, err := io.Copy(h, f); err != nil {
2138 return "", err
2139 }
2140 return fmt.Sprintf("%x", h.Sum(nil)), nil
2141 }
2142
2143 func listScopedTestFiles(enlinkdRoot string) ([]string, error) {
2144 scopedRoots := []string{
2145 filepath.Join(enlinkdRoot, defaultScopedTestsRelEn),
2146 filepath.Join(enlinkdRoot, defaultScopedTestsRelNB),
2147 }
2148
2149 files := make([]string, 0, 32)
2150 for _, root := range scopedRoots {
2151 if _, err := os.Stat(root); errors.Is(err, os.ErrNotExist) {
2152 continue
2153 }
2154 err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
2155 if walkErr != nil {
2156 return walkErr
2157 }
2158 if d.IsDir() {
2159 return nil
2160 }
2161 name := d.Name()
2162 if !(testFileNameITRE.MatchString(name) || testFileNameTestR.MatchString(name)) {
2163 return nil
2164 }
2165 rel, err := filepath.Rel(enlinkdRoot, p)
2166 if err != nil {
2167 return err
2168 }
2169 files = append(files, filepath.ToSlash(rel))
2170 return nil
2171 })
2172 if err != nil {
2173 return nil, err
2174 }
2175 }
2176
2177 sort.Strings(files)
2178 return files, nil
2179 }
2180
2181 func collectTestAndAssertionInventories(enlinkdRoot string, methodFiles, assertionFiles []string) ([]methodRow, []assertionRow, error) {
2182 methods := make([]methodRow, 0, 256)
2183 assertions := make([]assertionRow, 0, 5000)
2184
2185 for _, rel := range methodFiles {
2186 abs := filepath.Join(enlinkdRoot, filepath.FromSlash(rel))
2187 fileMethods, _, err := parseJavaTestFile(abs, rel)
2188 if err != nil {
2189 return nil, nil, fmt.Errorf("parse %q: %w", rel, err)
2190 }
2191 methods = append(methods, fileMethods...)
2192 }
2193
2194 for _, rel := range assertionFiles {
2195 abs := filepath.Join(enlinkdRoot, filepath.FromSlash(rel))
2196 _, fileAssertions, err := parseJavaTestFile(abs, rel)
2197 if err != nil {
2198 return nil, nil, fmt.Errorf("parse assertions in %q: %w", rel, err)
2199 }
2200 assertions = append(assertions, fileAssertions...)
2201 }
2202
2203 sort.Slice(methods, func(i, j int) bool {
2204 if methods[i].Class != methods[j].Class {
2205 return methods[i].Class < methods[j].Class
2206 }
2207 if methods[i].Method != methods[j].Method {
2208 return methods[i].Method < methods[j].Method
2209 }
2210 return methods[i].SourceFile < methods[j].SourceFile
2211 })
2212
2213 sort.Slice(assertions, func(i, j int) bool {
2214 if assertions[i].Class != assertions[j].Class {
2215 return assertions[i].Class < assertions[j].Class
2216 }
2217 if assertions[i].Method != assertions[j].Method {
2218 return assertions[i].Method < assertions[j].Method
2219 }
2220 if assertions[i].Line != assertions[j].Line {
2221 return assertions[i].Line < assertions[j].Line
2222 }
2223 return assertions[i].AssertionID < assertions[j].AssertionID
2224 })
2225
2226 return methods, assertions, nil
2227 }
2228
2229 func listScopedJavaFiles(enlinkdRoot string) ([]string, error) {
2230 scopedRoots := []string{
2231 filepath.Join(enlinkdRoot, defaultScopedTestsRelEn),
2232 filepath.Join(enlinkdRoot, defaultScopedTestsRelNB),
2233 }
2234
2235 files := make([]string, 0, 64)
2236 for _, root := range scopedRoots {
2237 if _, err := os.Stat(root); errors.Is(err, os.ErrNotExist) {
2238 continue
2239 }
2240 err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
2241 if walkErr != nil {
2242 return walkErr
2243 }
2244 if d.IsDir() {
2245 return nil
2246 }
2247 if filepath.Ext(d.Name()) != ".java" {
2248 return nil
2249 }
2250 rel, err := filepath.Rel(enlinkdRoot, p)
2251 if err != nil {
2252 return err
2253 }
2254 files = append(files, filepath.ToSlash(rel))
2255 return nil
2256 })
2257 if err != nil {
2258 return nil, err
2259 }
2260 }
2261 sort.Strings(files)
2262 return files, nil
2263 }
2264
2265 func parseJavaTestFile(absPath, relPath string) ([]methodRow, []assertionRow, error) {
2266 data, err := os.ReadFile(absPath)
2267 if err != nil {
2268 return nil, nil, err
2269 }
2270
2271 lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
2272 if len(lines) == 0 {
2273 return nil, nil, nil
2274 }
2275
2276 packageName := ""
2277 className := ""
2278 methods := make([]methodRow, 0, 16)
2279 ranges := make([]methodRange, 0, 16)
2280 candidates := make([]assertionCandidate, 0, 128)
2281
2282 inBlockComment := false
2283 pendingTest := false
2284 gatheringSignature := false
2285 signatureStartLine := 0
2286 signatureBuilder := strings.Builder{}
2287 inMethod := false
2288 methodName := ""
2289 methodStartLine := 0
2290 methodScope := ""
2291 braceDepth := 0
2292 var methodLines []string
2293 var methodLineNumbers []int
2294
2295 for i, rawLine := range lines {
2296 lineNo := i + 1
2297 cleanLine, nextInBlockComment := stripJavaLine(rawLine, inBlockComment)
2298 inBlockComment = nextInBlockComment
2299 trimmed := strings.TrimSpace(cleanLine)
2300
2301 if matches := assertionCallRE.FindAllStringSubmatchIndex(cleanLine, -1); len(matches) > 0 {
2302 for _, m := range matches {
2303 if len(m) < 4 {
2304 continue
2305 }
2306 candidates = append(candidates, assertionCandidate{
2307 Line: lineNo,
2308 Call: cleanLine[m[2]:m[3]],
2309 })
2310 }
2311 }
2312
2313 if packageName == "" {
2314 if m := packageRE.FindStringSubmatch(trimmed); len(m) == 2 {
2315 packageName = m[1]
2316 }
2317 }
2318 if className == "" {
2319 if m := classRE.FindStringSubmatch(trimmed); len(m) == 2 {
2320 className = m[1]
2321 }
2322 }
2323
2324 if inMethod {
2325 methodLines = append(methodLines, rawLine)
2326 methodLineNumbers = append(methodLineNumbers, lineNo)
2327 braceDepth += countBraces(cleanLine)
2328
2329 if braceDepth <= 0 {
2330 fqcn := buildClassName(packageName, className)
2331 methods = append(methods, methodRow{
2332 Class: fqcn,
2333 Method: methodName,
2334 SourceFile: relPath,
2335 ProtocolScope: methodScope,
2336 })
2337 ranges = append(ranges, methodRange{
2338 Name: methodName,
2339 Start: methodStartLine,
2340 End: lineNo,
2341 Scope: methodScope,
2342 })
2343
2344 inMethod = false
2345 methodName = ""
2346 methodStartLine = 0
2347 methodScope = ""
2348 braceDepth = 0
2349 methodLines = nil
2350 methodLineNumbers = nil
2351 }
2352 continue
2353 }
2354
2355 if testAnnotationRE.MatchString(trimmed) {
2356 pendingTest = true
2357 gatheringSignature = false
2358 signatureBuilder.Reset()
2359 signatureStartLine = 0
2360 continue
2361 }
2362
2363 if !pendingTest {
2364 continue
2365 }
2366
2367 if trimmed == "" {
2368 continue
2369 }
2370 if strings.HasPrefix(trimmed, "@") {
2371 // Additional annotations between @Test and method signature.
2372 continue
2373 }
2374
2375 if !gatheringSignature {
2376 if !looksLikeMethodDeclarationStart(trimmed) {
2377 // Skip annotation trailers like "})" that can appear after @Test annotations.
2378 continue
2379 }
2380 gatheringSignature = true
2381 signatureStartLine = lineNo
2382 }
2383 if signatureBuilder.Len() > 0 {
2384 signatureBuilder.WriteByte(' ')
2385 }
2386 signatureBuilder.WriteString(trimmed)
2387
2388 if !strings.Contains(cleanLine, "{") {
2389 continue
2390 }
2391
2392 name := extractMethodName(signatureBuilder.String())
2393 if name == "" {
2394 return nil, nil, fmt.Errorf("unable to parse test method name in %s near line %d", relPath, signatureStartLine)
2395 }
2396
2397 methodName = name
2398 methodStartLine = signatureStartLine
2399 methodScope = detectProtocolScope(methodName, relPath, signatureBuilder.String())
2400 braceDepth = countBraces(signatureBuilder.String())
2401 methodLines = []string{rawLine}
2402 methodLineNumbers = []int{lineNo}
2403 inMethod = true
2404 pendingTest = false
2405 gatheringSignature = false
2406 signatureBuilder.Reset()
2407 signatureStartLine = 0
2408
2409 if braceDepth <= 0 {
2410 // Single-line method body.
2411 fqcn := buildClassName(packageName, className)
2412 methods = append(methods, methodRow{
2413 Class: fqcn,
2414 Method: methodName,
2415 SourceFile: relPath,
2416 ProtocolScope: methodScope,
2417 })
2418 ranges = append(ranges, methodRange{
2419 Name: methodName,
2420 Start: methodStartLine,
2421 End: lineNo,
2422 Scope: methodScope,
2423 })
2424 inMethod = false
2425 methodName = ""
2426 methodStartLine = 0
2427 methodScope = ""
2428 braceDepth = 0
2429 methodLines = nil
2430 methodLineNumbers = nil
2431 }
2432
2433 _ = methodStartLine
2434 }
2435
2436 if inMethod {
2437 return nil, nil, fmt.Errorf("unterminated method %q in %s near line %d", methodName, relPath, methodStartLine)
2438 }
2439 fqcn := buildClassName(packageName, className)
2440 assertions := collectAssertionsForFile(fqcn, relPath, candidates, ranges)
2441 return methods, assertions, nil
2442 }
2443
2444 func looksLikeMethodDeclarationStart(trimmed string) bool {
2445 if strings.HasPrefix(trimmed, "public ") || strings.HasPrefix(trimmed, "protected ") || strings.HasPrefix(trimmed, "private ") {
2446 return true
2447 }
2448 // Some files may use package-private visibility for tests.
2449 return strings.Contains(trimmed, "(") && !strings.HasPrefix(trimmed, "}") && !strings.HasPrefix(trimmed, ")")
2450 }
2451
2452 func stripJavaLine(line string, inBlockComment bool) (string, bool) {
2453 var out strings.Builder
2454 escaped := false
2455 inString := false
2456 inChar := false
2457
2458 for i := 0; i < len(line); i++ {
2459 ch := line[i]
2460
2461 if inBlockComment {
2462 if ch == '*' && i+1 < len(line) && line[i+1] == '/' {
2463 inBlockComment = false
2464 i++
2465 }
2466 continue
2467 }
2468
2469 if inString {
2470 if escaped {
2471 escaped = false
2472 continue
2473 }
2474 if ch == '\\' {
2475 escaped = true
2476 continue
2477 }
2478 if ch == '"' {
2479 inString = false
2480 }
2481 continue
2482 }
2483
2484 if inChar {
2485 if escaped {
2486 escaped = false
2487 continue
2488 }
2489 if ch == '\\' {
2490 escaped = true
2491 continue
2492 }
2493 if ch == '\'' {
2494 inChar = false
2495 }
2496 continue
2497 }
2498
2499 if ch == '/' && i+1 < len(line) {
2500 next := line[i+1]
2501 if next == '/' {
2502 break
2503 }
2504 if next == '*' {
2505 inBlockComment = true
2506 i++
2507 continue
2508 }
2509 }
2510
2511 if ch == '"' {
2512 inString = true
2513 continue
2514 }
2515 if ch == '\'' {
2516 inChar = true
2517 continue
2518 }
2519 out.WriteByte(ch)
2520 }
2521
2522 return out.String(), inBlockComment
2523 }
2524
2525 func countBraces(s string) int {
2526 delta := 0
2527 for i := 0; i < len(s); i++ {
2528 switch s[i] {
2529 case '{':
2530 delta++
2531 case '}':
2532 delta--
2533 }
2534 }
2535 return delta
2536 }
2537
2538 func extractMethodName(signature string) string {
2539 idx := strings.Index(signature, "(")
2540 if idx <= 0 {
2541 return ""
2542 }
2543 before := strings.TrimSpace(signature[:idx])
2544 fields := strings.Fields(before)
2545 if len(fields) == 0 {
2546 return ""
2547 }
2548 name := fields[len(fields)-1]
2549 if !identifierRE.MatchString(name) {
2550 return ""
2551 }
2552 switch name {
2553 case "if", "for", "while", "switch", "catch", "new", "return", "try":
2554 return ""
2555 }
2556 return name
2557 }
2558
2559 func buildClassName(packageName, className string) string {
2560 if packageName == "" {
2561 return className
2562 }
2563 if className == "" {
2564 return packageName
2565 }
2566 return packageName + "." + className
2567 }
2568
2569 func detectProtocolScope(methodName, sourceFile, material string) string {
2570 s := strings.ToLower(methodName + " " + sourceFile + " " + material)
2571
2572 scopes := make([]string, 0, 4)
2573 add := func(scope string) {
2574 if slices.Contains(scopes, scope) {
2575 return
2576 }
2577 scopes = append(scopes, scope)
2578 }
2579
2580 if hasAny(s, "lldp", "chassis", "portid", "remport", "lldpre") {
2581 add("lldp")
2582 }
2583 if hasAny(s, "cdp", "cisco") {
2584 add("cdp")
2585 }
2586 if hasAny(s, "bridge", "fdb", "dot1d", "sharedsegment", "broadcastdomain", "stp", "vlan", "bridgemac") {
2587 add("bridge_fdb")
2588 }
2589 if hasAny(s, "arp", "ipnettomedia", "neighbor", "neighbour", "ndp", "iproute") {
2590 add("arp_nd")
2591 }
2592
2593 if len(scopes) == 0 {
2594 return "other"
2595 }
2596 sort.Strings(scopes)
2597 return strings.Join(scopes, "|")
2598 }
2599
2600 func hasAny(s string, tokens ...string) bool {
2601 for _, token := range tokens {
2602 if strings.Contains(s, token) {
2603 return true
2604 }
2605 }
2606 return false
2607 }
2608
2609 func collectAssertionsForFile(className, sourceFile string, candidates []assertionCandidate, ranges []methodRange) []assertionRow {
2610 rows := make([]assertionRow, 0, len(candidates))
2611 counters := make(map[string]int, len(ranges))
2612 for _, c := range candidates {
2613 methodName := ""
2614 scope := ""
2615 for _, r := range ranges {
2616 if c.Line >= r.Start && c.Line <= r.End {
2617 methodName = r.Name
2618 scope = r.Scope
2619 break
2620 }
2621 }
2622
2623 // Keep assertion inventory scoped to discovered @Test methods only.
2624 // Assertions in helpers/non-test code are not directly mappable to
2625 // method inventory and create false parity gaps.
2626 if methodName == "" {
2627 continue
2628 }
2629
2630 counters[methodName]++
2631 rows = append(rows, assertionRow{
2632 Class: className,
2633 Method: methodName,
2634 AssertionID: fmt.Sprintf("%s#%s#A%04d", className, methodName, counters[methodName]),
2635 SourceFile: sourceFile,
2636 Line: c.Line,
2637 AssertCall: c.Call,
2638 ProtocolScope: scope,
2639 })
2640 }
2641 return rows
2642 }
2643
2644 func writeFixtureInventoryCSV(outPath string, rows []fixtureRow) error {
2645 f, err := os.Create(outPath)
2646 if err != nil {
2647 return fmt.Errorf("create %q: %w", outPath, err)
2648 }
2649 defer f.Close()
2650
2651 w := csv.NewWriter(f)
2652 if err := w.Write([]string{"scenario", "file", "relative_path", "sha256", "size_bytes", "upstream_path"}); err != nil {
2653 return err
2654 }
2655 for _, row := range rows {
2656 record := []string{
2657 row.Scenario,
2658 row.File,
2659 row.RelativePath,
2660 row.SHA256,
2661 strconv.FormatInt(row.SizeBytes, 10),
2662 row.UpstreamPath,
2663 }
2664 if err := w.Write(record); err != nil {
2665 return err
2666 }
2667 }
2668 w.Flush()
2669 if err := w.Error(); err != nil {
2670 return fmt.Errorf("write %q: %w", outPath, err)
2671 }
2672 return nil
2673 }
2674
2675 func writeMethodInventoryCSV(outPath string, rows []methodRow) error {
2676 f, err := os.Create(outPath)
2677 if err != nil {
2678 return fmt.Errorf("create %q: %w", outPath, err)
2679 }
2680 defer f.Close()
2681
2682 w := csv.NewWriter(f)
2683 if err := w.Write([]string{"class", "method", "source_file", "protocol_scope"}); err != nil {
2684 return err
2685 }
2686 for _, row := range rows {
2687 record := []string{row.Class, row.Method, row.SourceFile, row.ProtocolScope}
2688 if err := w.Write(record); err != nil {
2689 return err
2690 }
2691 }
2692 w.Flush()
2693 if err := w.Error(); err != nil {
2694 return fmt.Errorf("write %q: %w", outPath, err)
2695 }
2696 return nil
2697 }
2698
2699 func writeAssertionInventoryCSV(outPath string, rows []assertionRow) error {
2700 f, err := os.Create(outPath)
2701 if err != nil {
2702 return fmt.Errorf("create %q: %w", outPath, err)
2703 }
2704 defer f.Close()
2705
2706 w := csv.NewWriter(f)
2707 if err := w.Write([]string{"class", "method", "assertion_id", "source_file", "line", "assert_call", "protocol_scope"}); err != nil {
2708 return err
2709 }
2710 for _, row := range rows {
2711 record := []string{
2712 row.Class,
2713 row.Method,
2714 row.AssertionID,
2715 row.SourceFile,
2716 strconv.Itoa(row.Line),
2717 row.AssertCall,
2718 row.ProtocolScope,
2719 }
2720 if err := w.Write(record); err != nil {
2721 return err
2722 }
2723 }
2724 w.Flush()
2725 if err := w.Error(); err != nil {
2726 return fmt.Errorf("write %q: %w", outPath, err)
2727 }
2728 return nil
2729 }
2730
2731 func readFixtureInventoryCSV(inPath string) ([]fixtureRow, error) {
2732 f, err := os.Open(inPath)
2733 if err != nil {
2734 return nil, fmt.Errorf("open %q: %w", inPath, err)
2735 }
2736 defer f.Close()
2737
2738 r := csv.NewReader(f)
2739 records, err := r.ReadAll()
2740 if err != nil {
2741 return nil, fmt.Errorf("read %q: %w", inPath, err)
2742 }
2743 if len(records) == 0 {
2744 return nil, fmt.Errorf("%q is empty", inPath)
2745 }
2746 header := strings.Join(records[0], ",")
2747 expectedHeader := "scenario,file,relative_path,sha256,size_bytes,upstream_path"
2748 if header != expectedHeader {
2749 return nil, fmt.Errorf("unexpected header in %q: %q", inPath, header)
2750 }
2751
2752 rows := make([]fixtureRow, 0, len(records)-1)
2753 for i := 1; i < len(records); i++ {
2754 rec := records[i]
2755 if len(rec) != 6 {
2756 return nil, fmt.Errorf("%q line %d: expected 6 columns, got %d", inPath, i+1, len(rec))
2757 }
2758 size, err := strconv.ParseInt(rec[4], 10, 64)
2759 if err != nil {
2760 return nil, fmt.Errorf("%q line %d: invalid size_bytes %q: %w", inPath, i+1, rec[4], err)
2761 }
2762 rows = append(rows, fixtureRow{
2763 Scenario: rec[0],
2764 File: rec[1],
2765 RelativePath: rec[2],
2766 SHA256: rec[3],
2767 SizeBytes: size,
2768 UpstreamPath: rec[5],
2769 })
2770 }
2771
2772 sort.Slice(rows, func(i, j int) bool {
2773 if rows[i].RelativePath != rows[j].RelativePath {
2774 return rows[i].RelativePath < rows[j].RelativePath
2775 }
2776 return rows[i].SHA256 < rows[j].SHA256
2777 })
2778 return rows, nil
2779 }
2780
2781 func compareFixtureInventories(expected, actual []fixtureRow) error {
2782 if len(expected) != len(actual) {
2783 return fmt.Errorf("row count mismatch: expected %d, got %d", len(expected), len(actual))
2784 }
2785
2786 for i := range expected {
2787 e := expected[i]
2788 a := actual[i]
2789 if e.RelativePath != a.RelativePath {
2790 return fmt.Errorf("relative_path mismatch at row %d: expected %q, got %q", i+1, e.RelativePath, a.RelativePath)
2791 }
2792 if e.SHA256 != a.SHA256 {
2793 return fmt.Errorf("sha256 mismatch for %q: expected %s, got %s", e.RelativePath, e.SHA256, a.SHA256)
2794 }
2795 if e.SizeBytes != a.SizeBytes {
2796 return fmt.Errorf("size mismatch for %q: expected %d, got %d", e.RelativePath, e.SizeBytes, a.SizeBytes)
2797 }
2798 if e.UpstreamPath != a.UpstreamPath {
2799 return fmt.Errorf("upstream_path mismatch for %q: expected %q, got %q", e.RelativePath, e.UpstreamPath, a.UpstreamPath)
2800 }
2801 }
2802 return nil
2803 }
2804
2805 func countDistinctScenarios(rows []fixtureRow) int {
2806 set := make(map[string]struct{}, len(rows))
2807 for _, row := range rows {
2808 set[row.Scenario] = struct{}{}
2809 }
2810 return len(set)
2811 }
2812
2813 func countDistinctFiles(rows []methodRow) int {
2814 set := make(map[string]struct{}, len(rows))
2815 for _, row := range rows {
2816 set[row.SourceFile] = struct{}{}
2817 }
2818 return len(set)
2819 }