diff --git a/AGENTS.md b/AGENTS.md index 509db75..cf9502d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 901dc49..85cb368 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/report.go b/report.go index 9b5f0cf..45a5e38 100644 --- a/report.go +++ b/report.go @@ -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)) diff --git a/report_test.go b/report_test.go index 6f146b0..837db58 100644 --- a/report_test.go +++ b/report_test.go @@ -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) {