fix: emit report rows in a deterministic order

NewReportGetter rendered its rows by ranging over a map, so two runs over the same items could
write different documents even though every row was identical. The getter now emits the rows in the
order of the joined relative path that keys them, and a test renders the same set of rows eight
times so a map order cannot pass by chance.

The README states what the document actually contains while it is being corrected: an empty files or
errors array is omitted, so a run without rows and without pipeline problems writes `{}`.
This commit is contained in:
Samuel Cui committed 2026-09-19 16:58:19 +08:00
1 parent e025d57d9d
commit 25500fffe5
4 files changed
+56 -13

No files matched your search

+3 -2
View File
@@ -68,8 +68,9 @@ entry point is `NewStream` with `Submit`/`Close`/`Wait`; `Run` and `BatchSource`
- A report row keeps the `af05f05c` shape: `Base` is the directory the source-relative `Path`
segments are resolved against, so the JSON carries `path` as an array. `FullPath` and
`SignatureCacheHit` are additive fields, `NewReportGetter` keys a row by the joined relative
path, and the push engine owns no report row at all — the shell fills the row from its own
compatibility item, which carries the base and the segments its walk knows.
path and emits the rows in that key's order, and the push engine owns no report row at all — the
shell fills the row from its own compatibility item, which carries the base and the segments its
walk knows.
- The shell publishes one terminal report row per item, so a failed item still has a row, with
the item-level failure under the empty target key.
- `cmd/acp` uses the shell and exits non-zero when any item, target or pipeline failure
+11 -8
View File
@@ -466,14 +466,17 @@ acp -from-linear -to-linear example target/
## Report
`-report` writes one `files` row per item plus a top-level `errors` array. Per-item and
per-target failures stay inside the row's `fail_target` map, keyed by the target path, or by
the empty key when ACP could not process the item itself: an unreadable source, an item
abandoned by a stop, or a targetless item whose content read failed. The empty key is the one
slot no requested target can occupy, so every item-level failure has the same shape, and a
failed item still gets a terminal row. The top-level `errors` array carries pipeline problems
only, so a single failure is recorded once. A row names its source the `af05f05c` way: `base`
plus the source-relative `path` array, with `full_path` as the additive whole path.
`-report` writes one `files` row per item plus, when the run recorded a pipeline problem, a
top-level `errors` array; an empty `files` or `errors` array is omitted, so a run with no items and
no problems writes `{}`. Rows are ordered by the joined relative path that keys them, so the same
items always write the same document. Per-item and per-target failures stay inside the row's
`fail_target` map, keyed by the target path, or by the empty key when ACP could not process the item
itself: an unreadable source, an item abandoned by a stop, or a targetless item whose content read
failed. The empty key is the one slot no requested target can occupy, so every item-level failure
has the same shape, and a failed item still gets a terminal row. The top-level `errors` array
carries pipeline problems only, so a single failure is recorded once. A row names its source the
`af05f05c` way: `base` plus the source-relative `path` array, with `full_path` as the additive whole
path.
`-report-indent` writes the same document with a two-space indent (`cmd/acp-rewrite` indents its
accumulated report with a tab). The report is plain JSON:
+16 -3
View File
@@ -3,11 +3,16 @@ package acp
import (
"encoding/json"
"path"
"sort"
"sync"
)
type ReportGetter func() *Report
// NewReportGetter returns the event handler that collects one run's report and the getter that
// renders it. A row is keyed by the joined relative path of its item, and the getter emits the rows
// in that key's order, so the same items always produce the same document; the pipeline errors keep
// their arrival order.
func NewReportGetter() (EventHandler, ReportGetter) {
var lock sync.Mutex
jobs := make(map[string]*Job, 8)
@@ -32,9 +37,17 @@ func NewReportGetter() (EventHandler, ReportGetter) {
lock.Lock()
defer lock.Unlock()
jobsCopyed := make([]*Job, 0, len(jobs))
for _, j := range jobs {
jobsCopyed = append(jobsCopyed, j)
// Emit the rows in the order of the key that identifies them instead of the map's
// iteration order, so two runs over the same items write the same document.
keys := make([]string, 0, len(jobs))
for key := range jobs {
keys = append(keys, key)
}
sort.Strings(keys)
jobsCopyed := make([]*Job, 0, len(keys))
for _, key := range keys {
jobsCopyed = append(jobsCopyed, jobs[key])
}
errorsCopyed := make([]*Error, 0, len(errors))
+26
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"os"
"path"
"strings"
"testing"
"time"
@@ -229,6 +230,31 @@ func TestReportToJSONStringIndentUsesSpaces(t *testing.T) {
}
}
// TestReportGetterOrdersRowsByTheJoinedRelativePath pins the report document's row order: a row is
// keyed by the joined relative path, and the getter emits the rows in that key's order, so the same
// items always render the same document instead of following the map's iteration order.
func TestReportGetterOrdersRowsByTheJoinedRelativePath(t *testing.T) {
handler, getter := NewReportGetter()
for _, segments := range [][]string{{"z.txt"}, {"nested", "b.txt"}, {"a.txt"}, {"nested", "a.txt"}} {
handler(&EventUpdateJob{Job: &Job{Base: "/source", Path: segments}})
}
want := []string{"a.txt", "nested/a.txt", "nested/b.txt", "z.txt"}
// A map has no order, so a single render could match by chance.
for attempt := 0; attempt < 8; attempt++ {
report := getter()
if len(report.Jobs) != len(want) {
t.Fatalf("rows = %d, want %d", len(report.Jobs), len(want))
}
for index, key := range want {
if got := path.Join(report.Jobs[index].Path...); got != key {
t.Fatalf("row %d = %q, want %q (attempt %d)", index, got, key, attempt)
}
}
}
}
// TestRunReportsAFailedItemAsATerminalRow pins the report row of an item ACP could not process:
// consumers resolve an item by its row, and the failure travels under the empty target key.
func TestRunReportsAFailedItemAsATerminalRow(t *testing.T) {