From b7212f0cb6d42764caf0cf761015a1c4dc53a3db Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 16 May 2026 10:15:32 -0700 Subject: [PATCH 01/26] wrappers: standardize nested Person on personFromGenerated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every *FromGenerated function that built a nested *Person value (Creator, Completer, Approver, Booster, Participants, top-level Person) previously inlined a 6-field shallow copy that silently dropped Bio, Location, Title, PersonableType, AttachableSGID, Client, Employee, TimeZone, CanPing, CanAccessHillCharts, CanAccessTimesheet, CanManageProjects, CanManagePeople, Company, CreatedAt, and UpdatedAt on every nested context. Replace each shallow copy with a personFromGenerated() call. Pure refactor; zero field additions on the wrapper struct side. The change is forward-compat additive — fields that were dropped before are now populated when present in the generated payload; no removals. This makes every subsequent additive Person field (e.g. Tagline) flow through every nested context for free, instead of requiring per-context re-adds. --- go/pkg/basecamp/boosts.go | 10 +--- go/pkg/basecamp/campfires.go | 20 ++------ go/pkg/basecamp/cards.go | 60 +++++------------------ go/pkg/basecamp/checkins.go | 30 +++--------- go/pkg/basecamp/client_approvals.go | 30 +++--------- go/pkg/basecamp/client_correspondences.go | 10 +--- go/pkg/basecamp/client_replies.go | 10 +--- go/pkg/basecamp/comments.go | 10 +--- go/pkg/basecamp/events.go | 10 +--- go/pkg/basecamp/forwards.go | 30 +++--------- go/pkg/basecamp/message_boards.go | 10 +--- go/pkg/basecamp/messages.go | 10 +--- go/pkg/basecamp/recordings.go | 10 +--- go/pkg/basecamp/reports.go | 10 +--- go/pkg/basecamp/schedules.go | 32 ++---------- go/pkg/basecamp/search.go | 10 +--- go/pkg/basecamp/timeline.go | 20 ++------ go/pkg/basecamp/timesheet.go | 20 ++------ go/pkg/basecamp/todolist_groups.go | 10 +--- go/pkg/basecamp/todolists.go | 10 +--- go/pkg/basecamp/todos.go | 10 +--- go/pkg/basecamp/todosets.go | 10 +--- go/pkg/basecamp/vaults.go | 30 +++--------- 23 files changed, 81 insertions(+), 331 deletions(-) diff --git a/go/pkg/basecamp/boosts.go b/go/pkg/basecamp/boosts.go index 91b995be2..8b8a7f58d 100644 --- a/go/pkg/basecamp/boosts.go +++ b/go/pkg/basecamp/boosts.go @@ -364,14 +364,8 @@ func boostFromGenerated(gb generated.Boost) Boost { b.ID = gb.Id if gb.Booster.Id != 0 || gb.Booster.Name != "" { - b.Booster = &Person{ - ID: int64(gb.Booster.Id), - Name: gb.Booster.Name, - EmailAddress: gb.Booster.EmailAddress, - AvatarURL: gb.Booster.AvatarUrl, - Admin: gb.Booster.Admin, - Owner: gb.Booster.Owner, - } + booster := personFromGenerated(gb.Booster) + b.Booster = &booster } if gb.Recording.Id != 0 || gb.Recording.Title != "" { diff --git a/go/pkg/basecamp/campfires.go b/go/pkg/basecamp/campfires.go index 4feaf440b..d249d1a47 100644 --- a/go/pkg/basecamp/campfires.go +++ b/go/pkg/basecamp/campfires.go @@ -926,14 +926,8 @@ func campfireFromGenerated(gc generated.Campfire) Campfire { } if gc.Creator.Id != 0 || gc.Creator.Name != "" { - c.Creator = &Person{ - ID: int64(gc.Creator.Id), - Name: gc.Creator.Name, - EmailAddress: gc.Creator.EmailAddress, - AvatarURL: gc.Creator.AvatarUrl, - Admin: gc.Creator.Admin, - Owner: gc.Creator.Owner, - } + creator := personFromGenerated(gc.Creator) + c.Creator = &creator } return c @@ -990,14 +984,8 @@ func campfireLineFromGenerated(gl generated.CampfireLine) CampfireLine { } if gl.Creator.Id != 0 || gl.Creator.Name != "" { - l.Creator = &Person{ - ID: int64(gl.Creator.Id), - Name: gl.Creator.Name, - EmailAddress: gl.Creator.EmailAddress, - AvatarURL: gl.Creator.AvatarUrl, - Admin: gl.Creator.Admin, - Owner: gl.Creator.Owner, - } + creator := personFromGenerated(gl.Creator) + l.Creator = &creator } return l diff --git a/go/pkg/basecamp/cards.go b/go/pkg/basecamp/cards.go index 729675d7a..db4d4c096 100644 --- a/go/pkg/basecamp/cards.go +++ b/go/pkg/basecamp/cards.go @@ -1207,14 +1207,8 @@ func cardTableFromGenerated(gc generated.CardTable) CardTable { } if gc.Creator.Id != 0 || gc.Creator.Name != "" { - ct.Creator = &Person{ - ID: int64(gc.Creator.Id), - Name: gc.Creator.Name, - EmailAddress: gc.Creator.EmailAddress, - AvatarURL: gc.Creator.AvatarUrl, - Admin: gc.Creator.Admin, - Owner: gc.Creator.Owner, - } + creator := personFromGenerated(gc.Creator) + ct.Creator = &creator } if len(gc.Subscribers) > 0 { @@ -1278,14 +1272,8 @@ func cardColumnFromGenerated(gc generated.CardColumn) CardColumn { } if gc.Creator.Id != 0 || gc.Creator.Name != "" { - cc.Creator = &Person{ - ID: int64(gc.Creator.Id), - Name: gc.Creator.Name, - EmailAddress: gc.Creator.EmailAddress, - AvatarURL: gc.Creator.AvatarUrl, - Admin: gc.Creator.Admin, - Owner: gc.Creator.Owner, - } + creator := personFromGenerated(gc.Creator) + cc.Creator = &creator } if gc.OnHold.Id != 0 { @@ -1368,25 +1356,13 @@ func cardFromGenerated(gc generated.Card) Card { } if gc.Creator.Id != 0 || gc.Creator.Name != "" { - c.Creator = &Person{ - ID: int64(gc.Creator.Id), - Name: gc.Creator.Name, - EmailAddress: gc.Creator.EmailAddress, - AvatarURL: gc.Creator.AvatarUrl, - Admin: gc.Creator.Admin, - Owner: gc.Creator.Owner, - } + creator := personFromGenerated(gc.Creator) + c.Creator = &creator } if gc.Completer.Id != 0 || gc.Completer.Name != "" { - c.Completer = &Person{ - ID: int64(gc.Completer.Id), - Name: gc.Completer.Name, - EmailAddress: gc.Completer.EmailAddress, - AvatarURL: gc.Completer.AvatarUrl, - Admin: gc.Completer.Admin, - Owner: gc.Completer.Owner, - } + completer := personFromGenerated(gc.Completer) + c.Completer = &completer } if len(gc.Assignees) > 0 { @@ -1463,25 +1439,13 @@ func cardStepFromGenerated(gs generated.CardStep) CardStep { } if gs.Creator.Id != 0 || gs.Creator.Name != "" { - s.Creator = &Person{ - ID: int64(gs.Creator.Id), - Name: gs.Creator.Name, - EmailAddress: gs.Creator.EmailAddress, - AvatarURL: gs.Creator.AvatarUrl, - Admin: gs.Creator.Admin, - Owner: gs.Creator.Owner, - } + creator := personFromGenerated(gs.Creator) + s.Creator = &creator } if gs.Completer.Id != 0 || gs.Completer.Name != "" { - s.Completer = &Person{ - ID: int64(gs.Completer.Id), - Name: gs.Completer.Name, - EmailAddress: gs.Completer.EmailAddress, - AvatarURL: gs.Completer.AvatarUrl, - Admin: gs.Completer.Admin, - Owner: gs.Completer.Owner, - } + completer := personFromGenerated(gs.Completer) + s.Completer = &completer } if len(gs.Assignees) > 0 { diff --git a/go/pkg/basecamp/checkins.go b/go/pkg/basecamp/checkins.go index 6676283b6..4dcf6bbce 100644 --- a/go/pkg/basecamp/checkins.go +++ b/go/pkg/basecamp/checkins.go @@ -745,14 +745,8 @@ func questionnaireFromGenerated(gq generated.Questionnaire) Questionnaire { } if gq.Creator.Id != 0 || gq.Creator.Name != "" { - q.Creator = &Person{ - ID: int64(gq.Creator.Id), - Name: gq.Creator.Name, - EmailAddress: gq.Creator.EmailAddress, - AvatarURL: gq.Creator.AvatarUrl, - Admin: gq.Creator.Admin, - Owner: gq.Creator.Owner, - } + creator := personFromGenerated(gq.Creator) + q.Creator = &creator } return q @@ -829,14 +823,8 @@ func questionFromGenerated(gq generated.Question) Question { } if gq.Creator.Id != 0 || gq.Creator.Name != "" { - q.Creator = &Person{ - ID: int64(gq.Creator.Id), - Name: gq.Creator.Name, - EmailAddress: gq.Creator.EmailAddress, - AvatarURL: gq.Creator.AvatarUrl, - Admin: gq.Creator.Admin, - Owner: gq.Creator.Owner, - } + creator := personFromGenerated(gq.Creator) + q.Creator = &creator } return q @@ -889,14 +877,8 @@ func questionAnswerFromGenerated(ga generated.QuestionAnswer) QuestionAnswer { } if ga.Creator.Id != 0 || ga.Creator.Name != "" { - a.Creator = &Person{ - ID: int64(ga.Creator.Id), - Name: ga.Creator.Name, - EmailAddress: ga.Creator.EmailAddress, - AvatarURL: ga.Creator.AvatarUrl, - Admin: ga.Creator.Admin, - Owner: ga.Creator.Owner, - } + creator := personFromGenerated(ga.Creator) + a.Creator = &creator } return a diff --git a/go/pkg/basecamp/client_approvals.go b/go/pkg/basecamp/client_approvals.go index 454bb55be..e9b4a2e6f 100644 --- a/go/pkg/basecamp/client_approvals.go +++ b/go/pkg/basecamp/client_approvals.go @@ -256,25 +256,13 @@ func clientApprovalFromGenerated(ga generated.ClientApproval) ClientApproval { } if ga.Creator.Id != 0 || ga.Creator.Name != "" { - a.Creator = &Person{ - ID: int64(ga.Creator.Id), - Name: ga.Creator.Name, - EmailAddress: ga.Creator.EmailAddress, - AvatarURL: ga.Creator.AvatarUrl, - Admin: ga.Creator.Admin, - Owner: ga.Creator.Owner, - } + creator := personFromGenerated(ga.Creator) + a.Creator = &creator } if ga.Approver.Id != 0 || ga.Approver.Name != "" { - a.Approver = &Person{ - ID: int64(ga.Approver.Id), - Name: ga.Approver.Name, - EmailAddress: ga.Approver.EmailAddress, - AvatarURL: ga.Approver.AvatarUrl, - Admin: ga.Approver.Admin, - Owner: ga.Approver.Owner, - } + approver := personFromGenerated(ga.Approver) + a.Approver = &approver } // Convert responses @@ -314,14 +302,8 @@ func clientApprovalFromGenerated(ga generated.ClientApproval) ClientApproval { } } if gr.Creator.Id != 0 || gr.Creator.Name != "" { - resp.Creator = &Person{ - ID: int64(gr.Creator.Id), - Name: gr.Creator.Name, - EmailAddress: gr.Creator.EmailAddress, - AvatarURL: gr.Creator.AvatarUrl, - Admin: gr.Creator.Admin, - Owner: gr.Creator.Owner, - } + respCreator := personFromGenerated(gr.Creator) + resp.Creator = &respCreator } a.Responses = append(a.Responses, resp) } diff --git a/go/pkg/basecamp/client_correspondences.go b/go/pkg/basecamp/client_correspondences.go index d59d12f19..7391e8a29 100644 --- a/go/pkg/basecamp/client_correspondences.go +++ b/go/pkg/basecamp/client_correspondences.go @@ -227,14 +227,8 @@ func clientCorrespondenceFromGenerated(gc generated.ClientCorrespondence) Client } if gc.Creator.Id != 0 || gc.Creator.Name != "" { - c.Creator = &Person{ - ID: int64(gc.Creator.Id), - Name: gc.Creator.Name, - EmailAddress: gc.Creator.EmailAddress, - AvatarURL: gc.Creator.AvatarUrl, - Admin: gc.Creator.Admin, - Owner: gc.Creator.Owner, - } + creator := personFromGenerated(gc.Creator) + c.Creator = &creator } return c diff --git a/go/pkg/basecamp/client_replies.go b/go/pkg/basecamp/client_replies.go index 2dfec2c8f..698fa200c 100644 --- a/go/pkg/basecamp/client_replies.go +++ b/go/pkg/basecamp/client_replies.go @@ -207,14 +207,8 @@ func clientReplyFromGenerated(gr generated.ClientReply) ClientReply { } if gr.Creator.Id != 0 || gr.Creator.Name != "" { - r.Creator = &Person{ - ID: int64(gr.Creator.Id), - Name: gr.Creator.Name, - EmailAddress: gr.Creator.EmailAddress, - AvatarURL: gr.Creator.AvatarUrl, - Admin: gr.Creator.Admin, - Owner: gr.Creator.Owner, - } + creator := personFromGenerated(gr.Creator) + r.Creator = &creator } return r diff --git a/go/pkg/basecamp/comments.go b/go/pkg/basecamp/comments.go index e47b6c915..82c6e3043 100644 --- a/go/pkg/basecamp/comments.go +++ b/go/pkg/basecamp/comments.go @@ -331,14 +331,8 @@ func commentFromGenerated(gc generated.Comment) Comment { } if gc.Creator.Id != 0 || gc.Creator.Name != "" { - c.Creator = &Person{ - ID: int64(gc.Creator.Id), - Name: gc.Creator.Name, - EmailAddress: gc.Creator.EmailAddress, - AvatarURL: gc.Creator.AvatarUrl, - Admin: gc.Creator.Admin, - Owner: gc.Creator.Owner, - } + creator := personFromGenerated(gc.Creator) + c.Creator = &creator } return c diff --git a/go/pkg/basecamp/events.go b/go/pkg/basecamp/events.go index 86ea62c8b..884202508 100644 --- a/go/pkg/basecamp/events.go +++ b/go/pkg/basecamp/events.go @@ -168,14 +168,8 @@ func eventFromGenerated(ge generated.Event) Event { } if ge.Creator.Id != 0 || ge.Creator.Name != "" { - e.Creator = &Person{ - ID: int64(ge.Creator.Id), - Name: ge.Creator.Name, - EmailAddress: ge.Creator.EmailAddress, - AvatarURL: ge.Creator.AvatarUrl, - Admin: ge.Creator.Admin, - Owner: ge.Creator.Owner, - } + creator := personFromGenerated(ge.Creator) + e.Creator = &creator } return e diff --git a/go/pkg/basecamp/forwards.go b/go/pkg/basecamp/forwards.go index edbd9ac6a..56ebbc478 100644 --- a/go/pkg/basecamp/forwards.go +++ b/go/pkg/basecamp/forwards.go @@ -445,14 +445,8 @@ func inboxFromGenerated(gi generated.Inbox) Inbox { } if gi.Creator.Id != 0 || gi.Creator.Name != "" { - i.Creator = &Person{ - ID: int64(gi.Creator.Id), - Name: gi.Creator.Name, - EmailAddress: gi.Creator.EmailAddress, - AvatarURL: gi.Creator.AvatarUrl, - Admin: gi.Creator.Admin, - Owner: gi.Creator.Owner, - } + creator := personFromGenerated(gi.Creator) + i.Creator = &creator } return i @@ -495,14 +489,8 @@ func forwardFromGenerated(gf generated.Forward) Forward { } if gf.Creator.Id != 0 || gf.Creator.Name != "" { - f.Creator = &Person{ - ID: int64(gf.Creator.Id), - Name: gf.Creator.Name, - EmailAddress: gf.Creator.EmailAddress, - AvatarURL: gf.Creator.AvatarUrl, - Admin: gf.Creator.Admin, - Owner: gf.Creator.Owner, - } + creator := personFromGenerated(gf.Creator) + f.Creator = &creator } return f @@ -543,14 +531,8 @@ func forwardReplyFromGenerated(gr generated.ForwardReply) ForwardReply { } if gr.Creator.Id != 0 || gr.Creator.Name != "" { - r.Creator = &Person{ - ID: int64(gr.Creator.Id), - Name: gr.Creator.Name, - EmailAddress: gr.Creator.EmailAddress, - AvatarURL: gr.Creator.AvatarUrl, - Admin: gr.Creator.Admin, - Owner: gr.Creator.Owner, - } + creator := personFromGenerated(gr.Creator) + r.Creator = &creator } return r diff --git a/go/pkg/basecamp/message_boards.go b/go/pkg/basecamp/message_boards.go index 86e86f68a..157e61676 100644 --- a/go/pkg/basecamp/message_boards.go +++ b/go/pkg/basecamp/message_boards.go @@ -93,14 +93,8 @@ func messageBoardFromGenerated(gb generated.MessageBoard) MessageBoard { } if gb.Creator.Id != 0 || gb.Creator.Name != "" { - mb.Creator = &Person{ - ID: int64(gb.Creator.Id), - Name: gb.Creator.Name, - EmailAddress: gb.Creator.EmailAddress, - AvatarURL: gb.Creator.AvatarUrl, - Admin: gb.Creator.Admin, - Owner: gb.Creator.Owner, - } + creator := personFromGenerated(gb.Creator) + mb.Creator = &creator } return mb diff --git a/go/pkg/basecamp/messages.go b/go/pkg/basecamp/messages.go index 33d5fa892..58c72d59c 100644 --- a/go/pkg/basecamp/messages.go +++ b/go/pkg/basecamp/messages.go @@ -475,14 +475,8 @@ func messageFromGenerated(gm generated.Message) Message { } if gm.Creator.Id != 0 || gm.Creator.Name != "" { - m.Creator = &Person{ - ID: int64(gm.Creator.Id), - Name: gm.Creator.Name, - EmailAddress: gm.Creator.EmailAddress, - AvatarURL: gm.Creator.AvatarUrl, - Admin: gm.Creator.Admin, - Owner: gm.Creator.Owner, - } + creator := personFromGenerated(gm.Creator) + m.Creator = &creator } if gm.Category.Id != 0 || gm.Category.Name != "" { diff --git a/go/pkg/basecamp/recordings.go b/go/pkg/basecamp/recordings.go index a47e876b5..956d0ed61 100644 --- a/go/pkg/basecamp/recordings.go +++ b/go/pkg/basecamp/recordings.go @@ -396,14 +396,8 @@ func recordingFromGenerated(gr generated.Recording) Recording { } if gr.Creator.Id != 0 || gr.Creator.Name != "" { - r.Creator = &Person{ - ID: int64(gr.Creator.Id), - Name: gr.Creator.Name, - EmailAddress: gr.Creator.EmailAddress, - AvatarURL: gr.Creator.AvatarUrl, - Admin: gr.Creator.Admin, - Owner: gr.Creator.Owner, - } + creator := personFromGenerated(gr.Creator) + r.Creator = &creator } return r diff --git a/go/pkg/basecamp/reports.go b/go/pkg/basecamp/reports.go index 214271dec..cf586b2e8 100644 --- a/go/pkg/basecamp/reports.go +++ b/go/pkg/basecamp/reports.go @@ -102,14 +102,8 @@ func (s *ReportsService) AssignedTodos(ctx context.Context, personID int64, opts } if resp.JSON200.Person.Id != 0 || resp.JSON200.Person.Name != "" { - result.Person = &Person{ - ID: int64(resp.JSON200.Person.Id), - Name: resp.JSON200.Person.Name, - EmailAddress: resp.JSON200.Person.EmailAddress, - AvatarURL: resp.JSON200.Person.AvatarUrl, - Admin: resp.JSON200.Person.Admin, - Owner: resp.JSON200.Person.Owner, - } + p := personFromGenerated(resp.JSON200.Person) + result.Person = &p } result.Todos = make([]Todo, 0, len(resp.JSON200.Todos)) diff --git a/go/pkg/basecamp/schedules.go b/go/pkg/basecamp/schedules.go index 647223388..d34625b67 100644 --- a/go/pkg/basecamp/schedules.go +++ b/go/pkg/basecamp/schedules.go @@ -579,14 +579,8 @@ func scheduleFromGenerated(gs generated.Schedule) Schedule { } if gs.Creator.Id != 0 || gs.Creator.Name != "" { - s.Creator = &Person{ - ID: int64(gs.Creator.Id), - Name: gs.Creator.Name, - EmailAddress: gs.Creator.EmailAddress, - AvatarURL: gs.Creator.AvatarUrl, - Admin: gs.Creator.Admin, - Owner: gs.Creator.Owner, - } + creator := personFromGenerated(gs.Creator) + s.Creator = &creator } return s @@ -638,31 +632,15 @@ func scheduleEntryFromGenerated(ge generated.ScheduleEntry) ScheduleEntry { } if ge.Creator.Id != 0 || ge.Creator.Name != "" { - e.Creator = &Person{ - ID: int64(ge.Creator.Id), - Name: ge.Creator.Name, - EmailAddress: ge.Creator.EmailAddress, - AvatarURL: ge.Creator.AvatarUrl, - Admin: ge.Creator.Admin, - Owner: ge.Creator.Owner, - } + creator := personFromGenerated(ge.Creator) + e.Creator = &creator } // Convert participants if len(ge.Participants) > 0 { e.Participants = make([]Person, 0, len(ge.Participants)) for _, gp := range ge.Participants { - p := Person{ - Name: gp.Name, - EmailAddress: gp.EmailAddress, - AvatarURL: gp.AvatarUrl, - Admin: gp.Admin, - Owner: gp.Owner, - } - if gp.Id != 0 { - p.ID = int64(gp.Id) - } - e.Participants = append(e.Participants, p) + e.Participants = append(e.Participants, personFromGenerated(gp)) } } diff --git a/go/pkg/basecamp/search.go b/go/pkg/basecamp/search.go index 43b989886..4ed2d6aa6 100644 --- a/go/pkg/basecamp/search.go +++ b/go/pkg/basecamp/search.go @@ -245,14 +245,8 @@ func searchResultFromGenerated(gsr generated.SearchResult) SearchResult { } if gsr.Creator.Id != 0 || gsr.Creator.Name != "" { - sr.Creator = &Person{ - ID: int64(gsr.Creator.Id), - Name: gsr.Creator.Name, - EmailAddress: gsr.Creator.EmailAddress, - AvatarURL: gsr.Creator.AvatarUrl, - Admin: gsr.Creator.Admin, - Owner: gsr.Creator.Owner, - } + creator := personFromGenerated(gsr.Creator) + sr.Creator = &creator } return sr diff --git a/go/pkg/basecamp/timeline.go b/go/pkg/basecamp/timeline.go index 1ae500151..e79d39673 100644 --- a/go/pkg/basecamp/timeline.go +++ b/go/pkg/basecamp/timeline.go @@ -256,14 +256,8 @@ func (s *TimelineService) PersonProgress(ctx context.Context, personID int64, op // Extract person from first page var person *Person if resp.JSON200.Person.Id != 0 || resp.JSON200.Person.Name != "" { - person = &Person{ - ID: int64(resp.JSON200.Person.Id), - Name: resp.JSON200.Person.Name, - EmailAddress: resp.JSON200.Person.EmailAddress, - AvatarURL: resp.JSON200.Person.AvatarUrl, - Admin: resp.JSON200.Person.Admin, - Owner: resp.JSON200.Person.Owner, - } + p := personFromGenerated(resp.JSON200.Person) + person = &p } // Parse events from first page @@ -393,14 +387,8 @@ func timelineEventFromGenerated(ge generated.TimelineEvent) TimelineEvent { e.CreatedAt = ge.CreatedAt if ge.Creator.Id != 0 || ge.Creator.Name != "" { - e.Creator = &Person{ - ID: int64(ge.Creator.Id), - Name: ge.Creator.Name, - EmailAddress: ge.Creator.EmailAddress, - AvatarURL: ge.Creator.AvatarUrl, - Admin: ge.Creator.Admin, - Owner: ge.Creator.Owner, - } + creator := personFromGenerated(ge.Creator) + e.Creator = &creator } if ge.Bucket.Id != 0 || ge.Bucket.Name != "" { diff --git a/go/pkg/basecamp/timesheet.go b/go/pkg/basecamp/timesheet.go index 6514420c5..741be9a8f 100644 --- a/go/pkg/basecamp/timesheet.go +++ b/go/pkg/basecamp/timesheet.go @@ -467,25 +467,13 @@ func timesheetEntryFromGenerated(ge generated.TimesheetEntry) TimesheetEntry { } if ge.Creator.Id != 0 || ge.Creator.Name != "" { - e.Creator = &Person{ - ID: int64(ge.Creator.Id), - Name: ge.Creator.Name, - EmailAddress: ge.Creator.EmailAddress, - AvatarURL: ge.Creator.AvatarUrl, - Admin: ge.Creator.Admin, - Owner: ge.Creator.Owner, - } + creator := personFromGenerated(ge.Creator) + e.Creator = &creator } if ge.Person.Id != 0 || ge.Person.Name != "" { - e.Person = &Person{ - ID: int64(ge.Person.Id), - Name: ge.Person.Name, - EmailAddress: ge.Person.EmailAddress, - AvatarURL: ge.Person.AvatarUrl, - Admin: ge.Person.Admin, - Owner: ge.Person.Owner, - } + person := personFromGenerated(ge.Person) + e.Person = &person } if ge.Parent.Id != 0 || ge.Parent.Title != "" { diff --git a/go/pkg/basecamp/todolist_groups.go b/go/pkg/basecamp/todolist_groups.go index 47ec6655f..0f10da1cc 100644 --- a/go/pkg/basecamp/todolist_groups.go +++ b/go/pkg/basecamp/todolist_groups.go @@ -389,14 +389,8 @@ func todolistGroupFromGenerated(gg generated.TodolistGroup) TodolistGroup { } if gg.Creator.Id != 0 || gg.Creator.Name != "" { - g.Creator = &Person{ - ID: int64(gg.Creator.Id), - Name: gg.Creator.Name, - EmailAddress: gg.Creator.EmailAddress, - AvatarURL: gg.Creator.AvatarUrl, - Admin: gg.Creator.Admin, - Owner: gg.Creator.Owner, - } + creator := personFromGenerated(gg.Creator) + g.Creator = &creator } return g diff --git a/go/pkg/basecamp/todolists.go b/go/pkg/basecamp/todolists.go index 346c00cbe..a2d476719 100644 --- a/go/pkg/basecamp/todolists.go +++ b/go/pkg/basecamp/todolists.go @@ -380,14 +380,8 @@ func todolistFromGenerated(gtl generated.Todolist) Todolist { } if gtl.Creator.Id != 0 || gtl.Creator.Name != "" { - tl.Creator = &Person{ - ID: int64(gtl.Creator.Id), - Name: gtl.Creator.Name, - EmailAddress: gtl.Creator.EmailAddress, - AvatarURL: gtl.Creator.AvatarUrl, - Admin: gtl.Creator.Admin, - Owner: gtl.Creator.Owner, - } + creator := personFromGenerated(gtl.Creator) + tl.Creator = &creator } return tl diff --git a/go/pkg/basecamp/todos.go b/go/pkg/basecamp/todos.go index e28066bfe..a10367348 100644 --- a/go/pkg/basecamp/todos.go +++ b/go/pkg/basecamp/todos.go @@ -802,14 +802,8 @@ func todoFromGenerated(gt generated.Todo) Todo { } if gt.Creator.Id != 0 || gt.Creator.Name != "" { - t.Creator = &Person{ - ID: int64(gt.Creator.Id), - Name: gt.Creator.Name, - EmailAddress: gt.Creator.EmailAddress, - AvatarURL: gt.Creator.AvatarUrl, - Admin: gt.Creator.Admin, - Owner: gt.Creator.Owner, - } + creator := personFromGenerated(gt.Creator) + t.Creator = &creator } // Convert assignees diff --git a/go/pkg/basecamp/todosets.go b/go/pkg/basecamp/todosets.go index 116ed8410..edb8f70f3 100644 --- a/go/pkg/basecamp/todosets.go +++ b/go/pkg/basecamp/todosets.go @@ -115,14 +115,8 @@ func todosetFromGenerated(gts generated.Todoset) Todoset { } if gts.Creator.Id != 0 || gts.Creator.Name != "" { - ts.Creator = &Person{ - ID: int64(gts.Creator.Id), - Name: gts.Creator.Name, - EmailAddress: gts.Creator.EmailAddress, - AvatarURL: gts.Creator.AvatarUrl, - Admin: gts.Creator.Admin, - Owner: gts.Creator.Owner, - } + creator := personFromGenerated(gts.Creator) + ts.Creator = &creator } return ts diff --git a/go/pkg/basecamp/vaults.go b/go/pkg/basecamp/vaults.go index fbed51dba..e53bf4faf 100644 --- a/go/pkg/basecamp/vaults.go +++ b/go/pkg/basecamp/vaults.go @@ -1093,14 +1093,8 @@ func vaultFromGenerated(gv generated.Vault) Vault { } if gv.Creator.Id != 0 || gv.Creator.Name != "" { - v.Creator = &Person{ - ID: int64(gv.Creator.Id), - Name: gv.Creator.Name, - EmailAddress: gv.Creator.EmailAddress, - AvatarURL: gv.Creator.AvatarUrl, - Admin: gv.Creator.Admin, - Owner: gv.Creator.Owner, - } + creator := personFromGenerated(gv.Creator) + v.Creator = &creator } return v @@ -1150,14 +1144,8 @@ func documentFromGenerated(gd generated.Document) Document { } if gd.Creator.Id != 0 || gd.Creator.Name != "" { - d.Creator = &Person{ - ID: int64(gd.Creator.Id), - Name: gd.Creator.Name, - EmailAddress: gd.Creator.EmailAddress, - AvatarURL: gd.Creator.AvatarUrl, - Admin: gd.Creator.Admin, - Owner: gd.Creator.Owner, - } + creator := personFromGenerated(gd.Creator) + d.Creator = &creator } return d @@ -1213,14 +1201,8 @@ func uploadFromGenerated(gu generated.Upload) Upload { } if gu.Creator.Id != 0 || gu.Creator.Name != "" { - u.Creator = &Person{ - ID: int64(gu.Creator.Id), - Name: gu.Creator.Name, - EmailAddress: gu.Creator.EmailAddress, - AvatarURL: gu.Creator.AvatarUrl, - Admin: gu.Creator.Admin, - Owner: gu.Creator.Owner, - } + creator := personFromGenerated(gu.Creator) + u.Creator = &creator } return u From 3ab4dd89c17d5b12b914c672fc8b2a0224129be6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 16 May 2026 10:20:25 -0700 Subject: [PATCH 02/26] wrappers: cross-cutting Recording-shaped fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the pervasive Category B fields (BookmarkURL, BoostsCount, BoostsURL, CommentsCount, CommentsURL, SubscriptionURL, InheritsStatus, VisibleToClients, Title) — and Content where applicable — to the wrappers that already share the Recording shape but were missing one or more. Affected wrappers: - Recording: + Content, CommentsCount, CommentsURL, SubscriptionURL - Comment: + VisibleToClients, Title, InheritsStatus, BookmarkURL, BoostsCount, BoostsURL - Message: + VisibleToClients, Title, InheritsStatus, BookmarkURL, BoostsURL, CommentsCount, CommentsURL, SubscriptionURL - MessageBoard: + VisibleToClients, InheritsStatus, BookmarkURL - Forward: + VisibleToClients, Title, InheritsStatus, BookmarkURL, SubscriptionURL - ForwardReply: + VisibleToClients, Title, InheritsStatus, BookmarkURL, BoostsCount, BoostsURL - Inbox: + VisibleToClients, InheritsStatus, BookmarkURL - CampfireLine: + BookmarkURL, BoostsURL - ScheduleEntry: + BoostsCount, BoostsURL - QuestionAnswer: + BoostsCount, BoostsURL - Todolist: + BoostsCount, BoostsURL - TimesheetEntry: + Status, VisibleToClients, Title, InheritsStatus, Type, URL, AppURL, BookmarkURL Forward-compat additive — fields default to zero/empty when absent. Wrapper JSON serialization uses omitempty on newly-added fields to match the upstream serialization behavior. --- go/pkg/basecamp/campfires.go | 8 +- go/pkg/basecamp/checkins.go | 4 + go/pkg/basecamp/comments.go | 48 ++++++---- go/pkg/basecamp/forwards.go | 142 ++++++++++++++++++------------ go/pkg/basecamp/message_boards.go | 48 +++++----- go/pkg/basecamp/messages.go | 62 ++++++++----- go/pkg/basecamp/recordings.go | 8 ++ go/pkg/basecamp/schedules.go | 4 + go/pkg/basecamp/timesheet.go | 48 ++++++---- go/pkg/basecamp/todolists.go | 4 + 10 files changed, 239 insertions(+), 137 deletions(-) diff --git a/go/pkg/basecamp/campfires.go b/go/pkg/basecamp/campfires.go index d249d1a47..463166433 100644 --- a/go/pkg/basecamp/campfires.go +++ b/go/pkg/basecamp/campfires.go @@ -90,12 +90,14 @@ type CampfireLine struct { Type string `json:"type"` URL string `json:"url"` AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url,omitempty"` + BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` Content string `json:"content,omitempty"` Attachments []CampfireLineAttachment `json:"attachments,omitempty"` Parent *Parent `json:"parent,omitempty"` Bucket *Bucket `json:"bucket,omitempty"` Creator *Person `json:"creator,omitempty"` - BoostsCount int `json:"boosts_count,omitempty"` } // CampfireLineAttachment represents a file attached to an upload line. @@ -943,10 +945,12 @@ func campfireLineFromGenerated(gl generated.CampfireLine) CampfireLine { Type: gl.Type, URL: gl.Url, AppURL: gl.AppUrl, + BookmarkURL: gl.BookmarkUrl, + BoostsCount: int(gl.BoostsCount), + BoostsURL: gl.BoostsUrl, Content: gl.Content, CreatedAt: gl.CreatedAt, UpdatedAt: gl.UpdatedAt, - BoostsCount: int(gl.BoostsCount), } l.ID = gl.Id diff --git a/go/pkg/basecamp/checkins.go b/go/pkg/basecamp/checkins.go index 4dcf6bbce..4c4f0a3a3 100644 --- a/go/pkg/basecamp/checkins.go +++ b/go/pkg/basecamp/checkins.go @@ -107,6 +107,8 @@ type QuestionAnswer struct { URL string `json:"url"` AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` + BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` SubscriptionURL string `json:"subscription_url"` CommentsCount int `json:"comments_count"` CommentsURL string `json:"comments_url"` @@ -843,6 +845,8 @@ func questionAnswerFromGenerated(ga generated.QuestionAnswer) QuestionAnswer { URL: ga.Url, AppURL: ga.AppUrl, BookmarkURL: ga.BookmarkUrl, + BoostsCount: int(ga.BoostsCount), + BoostsURL: ga.BoostsUrl, SubscriptionURL: ga.SubscriptionUrl, CommentsCount: int(ga.CommentsCount), CommentsURL: ga.CommentsUrl, diff --git a/go/pkg/basecamp/comments.go b/go/pkg/basecamp/comments.go index 82c6e3043..ad303b187 100644 --- a/go/pkg/basecamp/comments.go +++ b/go/pkg/basecamp/comments.go @@ -14,17 +14,23 @@ const DefaultCommentLimit = 100 // Comment represents a Basecamp comment on a recording. type Comment struct { - ID int64 `json:"id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Content string `json:"content"` - Type string `json:"type"` - URL string `json:"url"` - AppURL string `json:"app_url"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` + ID int64 `json:"id"` + Status string `json:"status"` + VisibleToClients bool `json:"visible_to_clients,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title,omitempty"` + InheritsStatus bool `json:"inherits_status,omitempty"` + Content string `json:"content"` + Type string `json:"type"` + URL string `json:"url"` + AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url,omitempty"` + BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` } // CreateCommentRequest specifies the parameters for creating a comment. @@ -298,13 +304,19 @@ func (s *CommentsService) Trash(ctx context.Context, commentID int64) (err error // commentFromGenerated converts a generated Comment to our clean Comment type. func commentFromGenerated(gc generated.Comment) Comment { c := Comment{ - Status: gc.Status, - Content: gc.Content, - Type: gc.Type, - URL: gc.Url, - AppURL: gc.AppUrl, - CreatedAt: gc.CreatedAt, - UpdatedAt: gc.UpdatedAt, + Status: gc.Status, + VisibleToClients: gc.VisibleToClients, + Title: gc.Title, + InheritsStatus: gc.InheritsStatus, + Content: gc.Content, + Type: gc.Type, + URL: gc.Url, + AppURL: gc.AppUrl, + BookmarkURL: gc.BookmarkUrl, + BoostsCount: int(gc.BoostsCount), + BoostsURL: gc.BoostsUrl, + CreatedAt: gc.CreatedAt, + UpdatedAt: gc.UpdatedAt, } if gc.Id != 0 { diff --git a/go/pkg/basecamp/forwards.go b/go/pkg/basecamp/forwards.go index 56ebbc478..b1cffcd63 100644 --- a/go/pkg/basecamp/forwards.go +++ b/go/pkg/basecamp/forwards.go @@ -57,48 +57,62 @@ type ForwardReplyListResult struct { // Inbox represents a Basecamp email inbox (forwards tool). type Inbox struct { - ID int64 `json:"id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Title string `json:"title"` - Type string `json:"type"` - URL string `json:"url"` - AppURL string `json:"app_url"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` + ID int64 `json:"id"` + Status string `json:"status"` + VisibleToClients bool `json:"visible_to_clients,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title"` + InheritsStatus bool `json:"inherits_status,omitempty"` + Type string `json:"type"` + URL string `json:"url"` + AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` } // Forward represents a forwarded email in Basecamp. type Forward struct { - ID int64 `json:"id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Subject string `json:"subject"` - Content string `json:"content"` - From string `json:"from"` - Type string `json:"type"` - URL string `json:"url"` - AppURL string `json:"app_url"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` + ID int64 `json:"id"` + Status string `json:"status"` + VisibleToClients bool `json:"visible_to_clients,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title,omitempty"` + InheritsStatus bool `json:"inherits_status,omitempty"` + Subject string `json:"subject"` + Content string `json:"content"` + From string `json:"from"` + Type string `json:"type"` + URL string `json:"url"` + AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url,omitempty"` + SubscriptionURL string `json:"subscription_url,omitempty"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` } // ForwardReply represents a reply to a forwarded email. type ForwardReply struct { - ID int64 `json:"id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Content string `json:"content"` - Type string `json:"type"` - URL string `json:"url"` - AppURL string `json:"app_url"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` + ID int64 `json:"id"` + Status string `json:"status"` + VisibleToClients bool `json:"visible_to_clients,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title,omitempty"` + InheritsStatus bool `json:"inherits_status,omitempty"` + Content string `json:"content"` + Type string `json:"type"` + URL string `json:"url"` + AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url,omitempty"` + BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` } // CreateForwardReplyRequest specifies the parameters for creating a reply to a forward. @@ -423,13 +437,16 @@ func (s *ForwardsService) CreateReply(ctx context.Context, forwardID int64, req // inboxFromGenerated converts a generated Inbox to our clean type. func inboxFromGenerated(gi generated.Inbox) Inbox { i := Inbox{ - Status: gi.Status, - CreatedAt: gi.CreatedAt, - UpdatedAt: gi.UpdatedAt, - Title: gi.Title, - Type: gi.Type, - URL: gi.Url, - AppURL: gi.AppUrl, + Status: gi.Status, + VisibleToClients: gi.VisibleToClients, + CreatedAt: gi.CreatedAt, + UpdatedAt: gi.UpdatedAt, + Title: gi.Title, + InheritsStatus: gi.InheritsStatus, + Type: gi.Type, + URL: gi.Url, + AppURL: gi.AppUrl, + BookmarkURL: gi.BookmarkUrl, } if gi.Id != 0 { @@ -455,15 +472,20 @@ func inboxFromGenerated(gi generated.Inbox) Inbox { // forwardFromGenerated converts a generated Forward to our clean type. func forwardFromGenerated(gf generated.Forward) Forward { f := Forward{ - Status: gf.Status, - CreatedAt: gf.CreatedAt, - UpdatedAt: gf.UpdatedAt, - Subject: gf.Subject, - Content: gf.Content, - From: gf.From, - Type: gf.Type, - URL: gf.Url, - AppURL: gf.AppUrl, + Status: gf.Status, + VisibleToClients: gf.VisibleToClients, + CreatedAt: gf.CreatedAt, + UpdatedAt: gf.UpdatedAt, + Title: gf.Title, + InheritsStatus: gf.InheritsStatus, + Subject: gf.Subject, + Content: gf.Content, + From: gf.From, + Type: gf.Type, + URL: gf.Url, + AppURL: gf.AppUrl, + BookmarkURL: gf.BookmarkUrl, + SubscriptionURL: gf.SubscriptionUrl, } if gf.Id != 0 { @@ -499,13 +521,19 @@ func forwardFromGenerated(gf generated.Forward) Forward { // forwardReplyFromGenerated converts a generated ForwardReply to our clean type. func forwardReplyFromGenerated(gr generated.ForwardReply) ForwardReply { r := ForwardReply{ - Status: gr.Status, - CreatedAt: gr.CreatedAt, - UpdatedAt: gr.UpdatedAt, - Content: gr.Content, - Type: gr.Type, - URL: gr.Url, - AppURL: gr.AppUrl, + Status: gr.Status, + VisibleToClients: gr.VisibleToClients, + CreatedAt: gr.CreatedAt, + UpdatedAt: gr.UpdatedAt, + Title: gr.Title, + InheritsStatus: gr.InheritsStatus, + Content: gr.Content, + Type: gr.Type, + URL: gr.Url, + AppURL: gr.AppUrl, + BookmarkURL: gr.BookmarkUrl, + BoostsCount: int(gr.BoostsCount), + BoostsURL: gr.BoostsUrl, } if gr.Id != 0 { diff --git a/go/pkg/basecamp/message_boards.go b/go/pkg/basecamp/message_boards.go index 157e61676..d7ce85677 100644 --- a/go/pkg/basecamp/message_boards.go +++ b/go/pkg/basecamp/message_boards.go @@ -10,18 +10,21 @@ import ( // MessageBoard represents a Basecamp message board in a project. type MessageBoard struct { - ID int64 `json:"id"` - Status string `json:"status"` - Title string `json:"title"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Type string `json:"type"` - URL string `json:"url"` - AppURL string `json:"app_url"` - MessagesCount int `json:"messages_count"` - MessagesURL string `json:"messages_url"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` + ID int64 `json:"id"` + Status string `json:"status"` + VisibleToClients bool `json:"visible_to_clients,omitempty"` + Title string `json:"title"` + InheritsStatus bool `json:"inherits_status,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Type string `json:"type"` + URL string `json:"url"` + AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url,omitempty"` + MessagesCount int `json:"messages_count"` + MessagesURL string `json:"messages_url"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` } // MessageBoardsService handles message board operations. @@ -69,15 +72,18 @@ func (s *MessageBoardsService) Get(ctx context.Context, boardID int64) (result * // messageBoardFromGenerated converts a generated MessageBoard to our clean MessageBoard type. func messageBoardFromGenerated(gb generated.MessageBoard) MessageBoard { mb := MessageBoard{ - Status: gb.Status, - Title: gb.Title, - Type: gb.Type, - URL: gb.Url, - AppURL: gb.AppUrl, - MessagesCount: int(gb.MessagesCount), - MessagesURL: gb.MessagesUrl, - CreatedAt: gb.CreatedAt, - UpdatedAt: gb.UpdatedAt, + Status: gb.Status, + VisibleToClients: gb.VisibleToClients, + Title: gb.Title, + InheritsStatus: gb.InheritsStatus, + Type: gb.Type, + URL: gb.Url, + AppURL: gb.AppUrl, + BookmarkURL: gb.BookmarkUrl, + MessagesCount: int(gb.MessagesCount), + MessagesURL: gb.MessagesUrl, + CreatedAt: gb.CreatedAt, + UpdatedAt: gb.UpdatedAt, } if gb.Id != 0 { diff --git a/go/pkg/basecamp/messages.go b/go/pkg/basecamp/messages.go index 58c72d59c..8a515ef84 100644 --- a/go/pkg/basecamp/messages.go +++ b/go/pkg/basecamp/messages.go @@ -14,20 +14,28 @@ const DefaultMessageLimit = 100 // Message represents a Basecamp message on a message board. type Message struct { - ID int64 `json:"id"` - Status string `json:"status"` - Subject string `json:"subject"` - Content string `json:"content"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Type string `json:"type"` - URL string `json:"url"` - AppURL string `json:"app_url"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` - Category *MessageType `json:"category,omitempty"` - BoostsCount int `json:"boosts_count,omitempty"` + ID int64 `json:"id"` + Status string `json:"status"` + VisibleToClients bool `json:"visible_to_clients,omitempty"` + Subject string `json:"subject"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title,omitempty"` + InheritsStatus bool `json:"inherits_status,omitempty"` + Type string `json:"type"` + URL string `json:"url"` + AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url,omitempty"` + BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` + CommentsCount int `json:"comments_count,omitempty"` + CommentsURL string `json:"comments_url,omitempty"` + SubscriptionURL string `json:"subscription_url,omitempty"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` + Category *MessageType `json:"category,omitempty"` } // CreateMessageRequest specifies the parameters for creating a message. @@ -440,15 +448,23 @@ func (s *MessagesService) Unarchive(ctx context.Context, messageID int64) (err e // messageFromGenerated converts a generated Message to our clean Message type. func messageFromGenerated(gm generated.Message) Message { m := Message{ - Status: gm.Status, - Subject: gm.Subject, - Content: gm.Content, - Type: gm.Type, - URL: gm.Url, - AppURL: gm.AppUrl, - CreatedAt: gm.CreatedAt, - UpdatedAt: gm.UpdatedAt, - BoostsCount: int(gm.BoostsCount), + Status: gm.Status, + VisibleToClients: gm.VisibleToClients, + Subject: gm.Subject, + Content: gm.Content, + Title: gm.Title, + InheritsStatus: gm.InheritsStatus, + Type: gm.Type, + URL: gm.Url, + AppURL: gm.AppUrl, + BookmarkURL: gm.BookmarkUrl, + BoostsCount: int(gm.BoostsCount), + BoostsURL: gm.BoostsUrl, + CommentsCount: int(gm.CommentsCount), + CommentsURL: gm.CommentsUrl, + SubscriptionURL: gm.SubscriptionUrl, + CreatedAt: gm.CreatedAt, + UpdatedAt: gm.UpdatedAt, } if gm.Id != 0 { diff --git a/go/pkg/basecamp/recordings.go b/go/pkg/basecamp/recordings.go index 956d0ed61..ca012ac2a 100644 --- a/go/pkg/basecamp/recordings.go +++ b/go/pkg/basecamp/recordings.go @@ -43,6 +43,10 @@ type Recording struct { URL string `json:"url"` AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` + Content string `json:"content,omitempty"` + CommentsCount int `json:"comments_count,omitempty"` + CommentsURL string `json:"comments_url,omitempty"` + SubscriptionURL string `json:"subscription_url,omitempty"` Parent *Parent `json:"parent,omitempty"` Bucket *Bucket `json:"bucket,omitempty"` Creator *Person `json:"creator,omitempty"` @@ -371,6 +375,10 @@ func recordingFromGenerated(gr generated.Recording) Recording { URL: gr.Url, AppURL: gr.AppUrl, BookmarkURL: gr.BookmarkUrl, + Content: gr.Content, + CommentsCount: int(gr.CommentsCount), + CommentsURL: gr.CommentsUrl, + SubscriptionURL: gr.SubscriptionUrl, } if gr.Id != 0 { diff --git a/go/pkg/basecamp/schedules.go b/go/pkg/basecamp/schedules.go index d34625b67..9df803032 100644 --- a/go/pkg/basecamp/schedules.go +++ b/go/pkg/basecamp/schedules.go @@ -61,6 +61,8 @@ type ScheduleEntry struct { URL string `json:"url"` AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` + BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` SubscriptionURL string `json:"subscription_url"` CommentsURL string `json:"comments_url"` CommentsCount int `json:"comments_count"` @@ -600,6 +602,8 @@ func scheduleEntryFromGenerated(ge generated.ScheduleEntry) ScheduleEntry { URL: ge.Url, AppURL: ge.AppUrl, BookmarkURL: ge.BookmarkUrl, + BoostsCount: int(ge.BoostsCount), + BoostsURL: ge.BoostsUrl, SubscriptionURL: ge.SubscriptionUrl, CommentsURL: ge.CommentsUrl, CommentsCount: int(ge.CommentsCount), diff --git a/go/pkg/basecamp/timesheet.go b/go/pkg/basecamp/timesheet.go index 741be9a8f..743f81bb6 100644 --- a/go/pkg/basecamp/timesheet.go +++ b/go/pkg/basecamp/timesheet.go @@ -11,17 +11,25 @@ import ( // TimesheetEntry represents a single time entry in a Basecamp timesheet report. type TimesheetEntry struct { - ID int64 `json:"id"` - Date string `json:"date"` - Hours string `json:"hours"` - Description string `json:"description,omitempty"` - Creator *Person `json:"creator,omitempty"` - Person *Person `json:"person,omitempty"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - BillableStatus string `json:"billable_status,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Status string `json:"status,omitempty"` + VisibleToClients bool `json:"visible_to_clients,omitempty"` + Title string `json:"title,omitempty"` + InheritsStatus bool `json:"inherits_status,omitempty"` + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` + AppURL string `json:"app_url,omitempty"` + BookmarkURL string `json:"bookmark_url,omitempty"` + Date string `json:"date"` + Hours string `json:"hours"` + Description string `json:"description,omitempty"` + Creator *Person `json:"creator,omitempty"` + Person *Person `json:"person,omitempty"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + BillableStatus string `json:"billable_status,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // CreateTimesheetEntryRequest specifies the parameters for creating a timesheet entry. @@ -455,11 +463,19 @@ func (s *TimesheetService) Trash(ctx context.Context, entryID int64) (err error) // timesheetEntryFromGenerated converts a generated TimesheetEntry to our clean type. func timesheetEntryFromGenerated(ge generated.TimesheetEntry) TimesheetEntry { e := TimesheetEntry{ - Date: ge.Date, - Hours: ge.Hours, - Description: ge.Description, - CreatedAt: ge.CreatedAt, - UpdatedAt: ge.UpdatedAt, + Status: ge.Status, + VisibleToClients: ge.VisibleToClients, + Title: ge.Title, + InheritsStatus: ge.InheritsStatus, + Type: ge.Type, + URL: ge.Url, + AppURL: ge.AppUrl, + BookmarkURL: ge.BookmarkUrl, + Date: ge.Date, + Hours: ge.Hours, + Description: ge.Description, + CreatedAt: ge.CreatedAt, + UpdatedAt: ge.UpdatedAt, } if ge.Id != 0 { diff --git a/go/pkg/basecamp/todolists.go b/go/pkg/basecamp/todolists.go index a2d476719..474fa04d5 100644 --- a/go/pkg/basecamp/todolists.go +++ b/go/pkg/basecamp/todolists.go @@ -25,6 +25,8 @@ type Todolist struct { URL string `json:"url"` AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` + BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` SubscriptionURL string `json:"subscription_url"` CommentsCount int `json:"comments_count"` CommentsURL string `json:"comments_url"` @@ -341,6 +343,8 @@ func todolistFromGenerated(gtl generated.Todolist) Todolist { URL: gtl.Url, AppURL: gtl.AppUrl, BookmarkURL: gtl.BookmarkUrl, + BoostsCount: int(gtl.BoostsCount), + BoostsURL: gtl.BoostsUrl, SubscriptionURL: gtl.SubscriptionUrl, CommentsCount: int(gtl.CommentsCount), CommentsURL: gtl.CommentsUrl, From 50a239feca035c73ea273c15119aaf00850f6638 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 16 May 2026 10:22:36 -0700 Subject: [PATCH 03/26] wrappers: BC5 forward-compat fields Surface the BC5 forward-compat fields filed in go/BRIEF-bc5-forward-compat-wrappers.md through the hand-written wrapper layer. PR 1 (#293) added these on the generated client; this commit closes the wrapper gap so basecamp-cli Phase 1 can read them. - Todo: + Steps []CardStep (BC5 jbuilder reuses the steps/step partial, matching the existing CardStep shape; reuse rather than introduce a new type) - Todoset: + TodosCount, CompletedLooseTodosCount, TodosURL, AppTodosURL - Person: + Tagline (alias of Bio; surfaced as a separate wrapper field per the spec note so consumers can distinguish presence) - Notification: + BubbleUpURL, BubbleUpAt (time.Time, following the existing ReadAt/UnreadAt zero-value pattern) - NotificationsResult: + BubbleUps, ScheduledBubbleUps These wrappers all decode through the existing FromGenerated paths (Todo, Todoset, Person) or direct JSON unmarshal (Notification, NotificationsResult) with struct tags; no new conversion pattern is needed. Refs: go/BRIEF-bc5-forward-compat-wrappers.md --- go/pkg/basecamp/my_notifications.go | 26 ++++++--- go/pkg/basecamp/people.go | 1 + go/pkg/basecamp/todos.go | 16 ++++++ go/pkg/basecamp/todosets.go | 82 ++++++++++++++++------------- 4 files changed, 83 insertions(+), 42 deletions(-) diff --git a/go/pkg/basecamp/my_notifications.go b/go/pkg/basecamp/my_notifications.go index 38a1e6121..322d9cd5e 100644 --- a/go/pkg/basecamp/my_notifications.go +++ b/go/pkg/basecamp/my_notifications.go @@ -26,12 +26,19 @@ type Notification struct { AppURL string `json:"app_url,omitempty"` BookmarkURL string `json:"bookmark_url,omitempty"` MemoryURL string `json:"memory_url,omitempty"` - UnreadURL string `json:"unread_url,omitempty"` - SubscriptionURL string `json:"subscription_url,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ReadAt time.Time `json:"read_at,omitempty"` - UnreadAt time.Time `json:"unread_at,omitempty"` + // BubbleUpURL is the BC5-added URL for the Bubble Up record covering this + // notification. Eligibility-gated — only present on items the current user + // can bubble up. + BubbleUpURL string `json:"bubble_up_url,omitempty"` + // BubbleUpAt is the BC5-added scheduled resurfacing time when this item is + // queued as a scheduled Bubble Up. Zero when there is no scheduled time. + BubbleUpAt time.Time `json:"bubble_up_at,omitempty"` + UnreadURL string `json:"unread_url,omitempty"` + SubscriptionURL string `json:"subscription_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ReadAt time.Time `json:"read_at,omitempty"` + UnreadAt time.Time `json:"unread_at,omitempty"` } // NotificationsResult contains the notifications grouped by status. @@ -39,6 +46,13 @@ type NotificationsResult struct { Unreads []Notification `json:"unreads,omitempty"` Reads []Notification `json:"reads,omitempty"` Memories []Notification `json:"memories,omitempty"` + // BubbleUps is the BC5-added list of Bubble Up notifications. BC3 also + // populates Memories as an alias for BC4-API consumer compat; new + // integrations should prefer BubbleUps. + BubbleUps []Notification `json:"bubble_ups,omitempty"` + // ScheduledBubbleUps is the BC5-added list of scheduled Bubble Up + // notifications (resurface time in the future). + ScheduledBubbleUps []Notification `json:"scheduled_bubble_ups,omitempty"` } // MyNotificationsService handles notification operations for the current user. diff --git a/go/pkg/basecamp/people.go b/go/pkg/basecamp/people.go index a02b8bb3a..10a80c288 100644 --- a/go/pkg/basecamp/people.go +++ b/go/pkg/basecamp/people.go @@ -738,6 +738,7 @@ func personFromGenerated(gp generated.Person) Person { PersonableType: gp.PersonableType, Title: gp.Title, Bio: gp.Bio, + Tagline: gp.Tagline, // BC5 forward-compat field Location: gp.Location, Admin: gp.Admin, Owner: gp.Owner, diff --git a/go/pkg/basecamp/todos.go b/go/pkg/basecamp/todos.go index a10367348..802c744cc 100644 --- a/go/pkg/basecamp/todos.go +++ b/go/pkg/basecamp/todos.go @@ -46,6 +46,10 @@ type Todo struct { CompletionSubscribers []Person `json:"completion_subscribers"` CommentsCount int `json:"comments_count,omitempty"` Position int `json:"position"` + // Steps are the BC5-added subtasks embedded in a Todo response. + // The shared `steps/step` jbuilder partial emits the same shape as + // `CardStep`, so this is `[]CardStep` rather than a separate type. + Steps []CardStep `json:"steps,omitempty"` } // Person represents a Basecamp user or system actor. @@ -63,6 +67,10 @@ type Person struct { PersonableType string `json:"personable_type,omitempty"` Title string `json:"title,omitempty"` Bio string `json:"bio,omitempty"` + // Tagline is the BC5-added alias of Bio. BC3 emits both keys with identical + // content; older BC4 responses may omit `tagline`. Prefer `Bio` for + // cross-version reads. Surfaced as a separate wrapper field per spec note. + Tagline string `json:"tagline,omitempty"` Location string `json:"location,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` @@ -824,5 +832,13 @@ func todoFromGenerated(gt generated.Todo) Todo { } } + // BC5: convert embedded steps + if len(gt.Steps) > 0 { + t.Steps = make([]CardStep, 0, len(gt.Steps)) + for _, gs := range gt.Steps { + t.Steps = append(t.Steps, cardStepFromGenerated(gs)) + } + } + return t } diff --git a/go/pkg/basecamp/todosets.go b/go/pkg/basecamp/todosets.go index edb8f70f3..c11ff9173 100644 --- a/go/pkg/basecamp/todosets.go +++ b/go/pkg/basecamp/todosets.go @@ -11,26 +11,31 @@ import ( // Todoset represents a Basecamp todoset (container for todolists in a project). // Each project has exactly one todoset in its dock. type Todoset struct { - ID int64 `json:"id"` - Status string `json:"status"` - VisibleToClients bool `json:"visible_to_clients"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Title string `json:"title"` - InheritsStatus bool `json:"inherits_status"` - Type string `json:"type"` - URL string `json:"url"` - AppURL string `json:"app_url"` - BookmarkURL string `json:"bookmark_url"` - Position *int `json:"position,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` - Name string `json:"name"` - TodolistsCount int `json:"todolists_count"` - TodolistsURL string `json:"todolists_url"` - CompletedRatio string `json:"completed_ratio"` - Completed bool `json:"completed"` - AppTodolistsURL string `json:"app_todolists_url"` + ID int64 `json:"id"` + Status string `json:"status"` + VisibleToClients bool `json:"visible_to_clients"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title"` + InheritsStatus bool `json:"inherits_status"` + Type string `json:"type"` + URL string `json:"url"` + AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url"` + Position *int `json:"position,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` + Name string `json:"name"` + TodolistsCount int `json:"todolists_count"` + TodolistsURL string `json:"todolists_url"` + CompletedRatio string `json:"completed_ratio"` + Completed bool `json:"completed"` + AppTodolistsURL string `json:"app_todolists_url"` + // BC5 additions: total + completed counts and the top-level todos URLs. + TodosCount int `json:"todos_count,omitempty"` + CompletedLooseTodosCount int `json:"completed_loose_todos_count,omitempty"` + TodosURL string `json:"todos_url,omitempty"` + AppTodosURL string `json:"app_todos_url,omitempty"` } // TodosetsService handles todoset operations. @@ -78,22 +83,27 @@ func (s *TodosetsService) Get(ctx context.Context, todosetID int64) (result *Tod // todosetFromGenerated converts a generated Todoset to our clean Todoset type. func todosetFromGenerated(gts generated.Todoset) Todoset { ts := Todoset{ - Status: gts.Status, - VisibleToClients: gts.VisibleToClients, - Title: gts.Title, - InheritsStatus: gts.InheritsStatus, - Type: gts.Type, - URL: gts.Url, - AppURL: gts.AppUrl, - BookmarkURL: gts.BookmarkUrl, - Name: gts.Name, - TodolistsCount: int(gts.TodolistsCount), - TodolistsURL: gts.TodolistsUrl, - CompletedRatio: gts.CompletedRatio, - Completed: gts.Completed, - AppTodolistsURL: gts.AppTodolistsUrl, - CreatedAt: gts.CreatedAt, - UpdatedAt: gts.UpdatedAt, + Status: gts.Status, + VisibleToClients: gts.VisibleToClients, + Title: gts.Title, + InheritsStatus: gts.InheritsStatus, + Type: gts.Type, + URL: gts.Url, + AppURL: gts.AppUrl, + BookmarkURL: gts.BookmarkUrl, + Name: gts.Name, + TodolistsCount: int(gts.TodolistsCount), + TodolistsURL: gts.TodolistsUrl, + CompletedRatio: gts.CompletedRatio, + Completed: gts.Completed, + AppTodolistsURL: gts.AppTodolistsUrl, + // BC5 forward-compat fields. + TodosCount: int(gts.TodosCount), + CompletedLooseTodosCount: int(gts.CompletedLooseTodosCount), + TodosURL: gts.TodosUrl, + AppTodosURL: gts.AppTodosUrl, + CreatedAt: gts.CreatedAt, + UpdatedAt: gts.UpdatedAt, } if gts.Id != 0 { From 7bca13621c2891f9baf40d7c9e73cc59c9e6dd26 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 16 May 2026 10:27:27 -0700 Subject: [PATCH 04/26] wrappers: one-off structural fields Close the remaining Category B one-off structural gaps identified in the field-level wrapper audit (predates BC5, surfaced during the BC5 readiness sweep). - Campfire: + Topic, Position, BookmarkURL, SubscriptionURL - MessageBoard: + AppMessagesURL, Position - Template: + URL, AppURL, Dock []DockItem (reuses the existing DockItem type from projects.go; conversion shared via dockItemFromGenerated helper) - Person: + CanAccessHillCharts, CanAccessTimesheet; also wire CanPing into personFromGenerated (struct field existed; propagation did not) - Notification: + Creator, Participants, PreviewableAttachments (with new companion PreviewableAttachment type mirroring generated.PreviewableAttachment) - Gauge: + Creator, Bucket - GaugeNeedle: + Creator, Bucket, Parent - Inbox: + Position, ForwardsCount, ForwardsURL - Forward: + RepliesCount, RepliesURL The Notification sentinel-creator test fixture was updated to include `personable_type: "LocalPerson"` (the actual BC3 wire shape for system actors) so normalizeJSON correctly coerces the string "basecamp" id to 0 and preserves it as system_label; the test now asserts the wrapper exposes the normalized creator end-to-end rather than silently dropping it. --- go/pkg/basecamp/campfires.go | 8 ++++ go/pkg/basecamp/forwards.go | 10 ++++ go/pkg/basecamp/gauges.go | 5 ++ go/pkg/basecamp/message_boards.go | 4 ++ go/pkg/basecamp/my_notifications.go | 60 +++++++++++++++--------- go/pkg/basecamp/my_notifications_test.go | 26 ++++++++-- go/pkg/basecamp/people.go | 35 +++++++------- go/pkg/basecamp/projects.go | 36 ++++++++------ go/pkg/basecamp/templates.go | 24 +++++++--- go/pkg/basecamp/todos.go | 22 +++++---- 10 files changed, 156 insertions(+), 74 deletions(-) diff --git a/go/pkg/basecamp/campfires.go b/go/pkg/basecamp/campfires.go index 463166433..fe77f4bb7 100644 --- a/go/pkg/basecamp/campfires.go +++ b/go/pkg/basecamp/campfires.go @@ -67,10 +67,14 @@ type Campfire struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Title string `json:"title"` + Topic string `json:"topic,omitempty"` + Position int `json:"position,omitempty"` InheritsStatus bool `json:"inherits_status"` Type string `json:"type"` URL string `json:"url"` AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url,omitempty"` + SubscriptionURL string `json:"subscription_url,omitempty"` LinesURL string `json:"lines_url"` FilesURL string `json:"files_url,omitempty"` Bucket *Bucket `json:"bucket,omitempty"` @@ -907,10 +911,14 @@ func campfireFromGenerated(gc generated.Campfire) Campfire { Status: gc.Status, VisibleToClients: gc.VisibleToClients, Title: gc.Title, + Topic: gc.Topic, + Position: int(gc.Position), InheritsStatus: gc.InheritsStatus, Type: gc.Type, URL: gc.Url, AppURL: gc.AppUrl, + BookmarkURL: gc.BookmarkUrl, + SubscriptionURL: gc.SubscriptionUrl, LinesURL: gc.LinesUrl, FilesURL: gc.FilesUrl, CreatedAt: gc.CreatedAt, diff --git a/go/pkg/basecamp/forwards.go b/go/pkg/basecamp/forwards.go index b1cffcd63..a73803833 100644 --- a/go/pkg/basecamp/forwards.go +++ b/go/pkg/basecamp/forwards.go @@ -64,10 +64,13 @@ type Inbox struct { UpdatedAt time.Time `json:"updated_at"` Title string `json:"title"` InheritsStatus bool `json:"inherits_status,omitempty"` + Position int `json:"position,omitempty"` Type string `json:"type"` URL string `json:"url"` AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url,omitempty"` + ForwardsCount int `json:"forwards_count,omitempty"` + ForwardsURL string `json:"forwards_url,omitempty"` Bucket *Bucket `json:"bucket,omitempty"` Creator *Person `json:"creator,omitempty"` } @@ -89,6 +92,8 @@ type Forward struct { AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url,omitempty"` SubscriptionURL string `json:"subscription_url,omitempty"` + RepliesCount int `json:"replies_count,omitempty"` + RepliesURL string `json:"replies_url,omitempty"` Parent *Parent `json:"parent,omitempty"` Bucket *Bucket `json:"bucket,omitempty"` Creator *Person `json:"creator,omitempty"` @@ -443,10 +448,13 @@ func inboxFromGenerated(gi generated.Inbox) Inbox { UpdatedAt: gi.UpdatedAt, Title: gi.Title, InheritsStatus: gi.InheritsStatus, + Position: int(gi.Position), Type: gi.Type, URL: gi.Url, AppURL: gi.AppUrl, BookmarkURL: gi.BookmarkUrl, + ForwardsCount: int(gi.ForwardsCount), + ForwardsURL: gi.ForwardsUrl, } if gi.Id != 0 { @@ -486,6 +494,8 @@ func forwardFromGenerated(gf generated.Forward) Forward { AppURL: gf.AppUrl, BookmarkURL: gf.BookmarkUrl, SubscriptionURL: gf.SubscriptionUrl, + RepliesCount: int(gf.RepliesCount), + RepliesURL: gf.RepliesUrl, } if gf.Id != 0 { diff --git a/go/pkg/basecamp/gauges.go b/go/pkg/basecamp/gauges.go index 89c0920b6..05f04fabb 100644 --- a/go/pkg/basecamp/gauges.go +++ b/go/pkg/basecamp/gauges.go @@ -25,6 +25,8 @@ type Gauge struct { URL string `json:"url,omitempty"` AppURL string `json:"app_url,omitempty"` BookmarkURL string `json:"bookmark_url,omitempty"` + Creator *Person `json:"creator,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -48,6 +50,9 @@ type GaugeNeedle struct { CommentsURL string `json:"comments_url,omitempty"` BoostsURL string `json:"boosts_url,omitempty"` SubscriptionURL string `json:"subscription_url,omitempty"` + Creator *Person `json:"creator,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Parent *Parent `json:"parent,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/go/pkg/basecamp/message_boards.go b/go/pkg/basecamp/message_boards.go index d7ce85677..fcd80f58a 100644 --- a/go/pkg/basecamp/message_boards.go +++ b/go/pkg/basecamp/message_boards.go @@ -15,6 +15,7 @@ type MessageBoard struct { VisibleToClients bool `json:"visible_to_clients,omitempty"` Title string `json:"title"` InheritsStatus bool `json:"inherits_status,omitempty"` + Position int `json:"position,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Type string `json:"type"` @@ -23,6 +24,7 @@ type MessageBoard struct { BookmarkURL string `json:"bookmark_url,omitempty"` MessagesCount int `json:"messages_count"` MessagesURL string `json:"messages_url"` + AppMessagesURL string `json:"app_messages_url,omitempty"` Bucket *Bucket `json:"bucket,omitempty"` Creator *Person `json:"creator,omitempty"` } @@ -82,6 +84,8 @@ func messageBoardFromGenerated(gb generated.MessageBoard) MessageBoard { BookmarkURL: gb.BookmarkUrl, MessagesCount: int(gb.MessagesCount), MessagesURL: gb.MessagesUrl, + AppMessagesURL: gb.AppMessagesUrl, + Position: int(gb.Position), CreatedAt: gb.CreatedAt, UpdatedAt: gb.UpdatedAt, } diff --git a/go/pkg/basecamp/my_notifications.go b/go/pkg/basecamp/my_notifications.go index 322d9cd5e..c67cc31a1 100644 --- a/go/pkg/basecamp/my_notifications.go +++ b/go/pkg/basecamp/my_notifications.go @@ -11,34 +11,50 @@ import ( // Notification represents a single notification item. type Notification struct { - ID int64 `json:"id"` - Type string `json:"type,omitempty"` - Title string `json:"title,omitempty"` - Section string `json:"section,omitempty"` - ContentExcerpt string `json:"content_excerpt,omitempty"` - BucketName string `json:"bucket_name,omitempty"` - ReadableIdentifier string `json:"readable_identifier,omitempty"` - ReadableSGID string `json:"readable_sgid,omitempty"` - Subscribed bool `json:"subscribed,omitempty"` - Named bool `json:"named,omitempty"` - UnreadCount int32 `json:"unread_count,omitempty"` - ImageURL string `json:"image_url,omitempty"` - AppURL string `json:"app_url,omitempty"` - BookmarkURL string `json:"bookmark_url,omitempty"` - MemoryURL string `json:"memory_url,omitempty"` + ID int64 `json:"id"` + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Section string `json:"section,omitempty"` + ContentExcerpt string `json:"content_excerpt,omitempty"` + BucketName string `json:"bucket_name,omitempty"` + ReadableIdentifier string `json:"readable_identifier,omitempty"` + ReadableSGID string `json:"readable_sgid,omitempty"` + Subscribed bool `json:"subscribed,omitempty"` + Named bool `json:"named,omitempty"` + UnreadCount int32 `json:"unread_count,omitempty"` + ImageURL string `json:"image_url,omitempty"` + AppURL string `json:"app_url,omitempty"` + BookmarkURL string `json:"bookmark_url,omitempty"` + MemoryURL string `json:"memory_url,omitempty"` // BubbleUpURL is the BC5-added URL for the Bubble Up record covering this // notification. Eligibility-gated — only present on items the current user // can bubble up. BubbleUpURL string `json:"bubble_up_url,omitempty"` // BubbleUpAt is the BC5-added scheduled resurfacing time when this item is // queued as a scheduled Bubble Up. Zero when there is no scheduled time. - BubbleUpAt time.Time `json:"bubble_up_at,omitempty"` - UnreadURL string `json:"unread_url,omitempty"` - SubscriptionURL string `json:"subscription_url,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ReadAt time.Time `json:"read_at,omitempty"` - UnreadAt time.Time `json:"unread_at,omitempty"` + BubbleUpAt time.Time `json:"bubble_up_at,omitempty"` + UnreadURL string `json:"unread_url,omitempty"` + SubscriptionURL string `json:"subscription_url,omitempty"` + Creator *Person `json:"creator,omitempty"` + Participants []Person `json:"participants,omitempty"` + PreviewableAttachments []PreviewableAttachment `json:"previewable_attachments,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ReadAt time.Time `json:"read_at,omitempty"` + UnreadAt time.Time `json:"unread_at,omitempty"` +} + +// PreviewableAttachment represents a preview-renderable attachment surfaced on +// a Notification (e.g. images in a ping). +type PreviewableAttachment struct { + ID *int64 `json:"id,omitempty"` + AppURL string `json:"app_url,omitempty"` + URL string `json:"url,omitempty"` + ContentType string `json:"content_type,omitempty"` + Filename string `json:"filename,omitempty"` + Filesize int64 `json:"filesize,omitempty"` + Width int32 `json:"width,omitempty"` + Height int32 `json:"height,omitempty"` } // NotificationsResult contains the notifications grouped by status. diff --git a/go/pkg/basecamp/my_notifications_test.go b/go/pkg/basecamp/my_notifications_test.go index f6d940711..12ca115e1 100644 --- a/go/pkg/basecamp/my_notifications_test.go +++ b/go/pkg/basecamp/my_notifications_test.go @@ -63,10 +63,11 @@ func TestMyNotificationsService_Get_WithPage(t *testing.T) { } func TestMyNotificationsService_Get_SentinelCreatorID(t *testing.T) { - // The BC3 API returns system-generated notifications with creator.id: "basecamp". - // The generated parser must not crash on this non-numeric sentinel — FlexibleInt64 - // maps it to zero so the hand-written NotificationsResult (which omits Creator) - // can parse the response without error. + // The BC3 API returns system-generated notifications with creator.id: "basecamp" + // and personable_type: "LocalPerson". normalizeJSON walks Person-shaped objects + // (anything carrying personable_type) and coerces the non-numeric id to 0 while + // preserving the original label as system_label. The wrapper then decodes the + // resulting numeric payload into Notification.Creator without error. svc := testMyNotificationsServer(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) @@ -78,7 +79,8 @@ func TestMyNotificationsService_Get_SentinelCreatorID(t *testing.T) { "updated_at": "2024-01-01T00:00:00Z", "creator": { "id": "basecamp", - "name": "Basecamp" + "name": "Basecamp", + "personable_type": "LocalPerson" } }], "reads": [], @@ -96,6 +98,20 @@ func TestMyNotificationsService_Get_SentinelCreatorID(t *testing.T) { if result.Unreads[0].Title != "System notification" { t.Errorf("expected 'System notification', got %q", result.Unreads[0].Title) } + // Creator now flows through the wrapper. Verify the sentinel was normalized: + // id collapsed to 0, original label preserved as system_label. + if result.Unreads[0].Creator == nil { + t.Fatal("expected Creator to be populated after wrapper exposes the field") + } + if result.Unreads[0].Creator.ID != 0 { + t.Errorf("expected sentinel creator.id to normalize to 0, got %d", result.Unreads[0].Creator.ID) + } + if result.Unreads[0].Creator.SystemLabel != "basecamp" { + t.Errorf("expected system_label %q, got %q", "basecamp", result.Unreads[0].Creator.SystemLabel) + } + if result.Unreads[0].Creator.PersonableType != "LocalPerson" { + t.Errorf("expected personable_type 'LocalPerson', got %q", result.Unreads[0].Creator.PersonableType) + } } func TestMyNotificationsService_MarkAsRead(t *testing.T) { diff --git a/go/pkg/basecamp/people.go b/go/pkg/basecamp/people.go index 10a80c288..57e80458f 100644 --- a/go/pkg/basecamp/people.go +++ b/go/pkg/basecamp/people.go @@ -732,22 +732,25 @@ func (s *PeopleService) DisableOutOfOffice(ctx context.Context, personID int64) // personFromGenerated converts a generated Person to our clean Person type. func personFromGenerated(gp generated.Person) Person { p := Person{ - AttachableSGID: gp.AttachableSgid, - Name: gp.Name, - EmailAddress: gp.EmailAddress, - PersonableType: gp.PersonableType, - Title: gp.Title, - Bio: gp.Bio, - Tagline: gp.Tagline, // BC5 forward-compat field - Location: gp.Location, - Admin: gp.Admin, - Owner: gp.Owner, - Client: gp.Client, - Employee: gp.Employee, - TimeZone: gp.TimeZone, - AvatarURL: gp.AvatarUrl, - CanManageProjects: gp.CanManageProjects, - CanManagePeople: gp.CanManagePeople, + AttachableSGID: gp.AttachableSgid, + Name: gp.Name, + EmailAddress: gp.EmailAddress, + PersonableType: gp.PersonableType, + Title: gp.Title, + Bio: gp.Bio, + Tagline: gp.Tagline, // BC5 forward-compat field + Location: gp.Location, + Admin: gp.Admin, + Owner: gp.Owner, + Client: gp.Client, + Employee: gp.Employee, + TimeZone: gp.TimeZone, + AvatarURL: gp.AvatarUrl, + CanPing: gp.CanPing, + CanAccessHillCharts: gp.CanAccessHillCharts, + CanAccessTimesheet: gp.CanAccessTimesheet, + CanManageProjects: gp.CanManageProjects, + CanManagePeople: gp.CanManagePeople, } if gp.Id != 0 { diff --git a/go/pkg/basecamp/projects.go b/go/pkg/basecamp/projects.go index d3d1146ae..b195fc2a7 100644 --- a/go/pkg/basecamp/projects.go +++ b/go/pkg/basecamp/projects.go @@ -395,21 +395,7 @@ func projectFromGenerated(gp generated.Project) Project { if len(gp.Dock) > 0 { p.Dock = make([]DockItem, 0, len(gp.Dock)) for _, gd := range gp.Dock { - di := DockItem{ - Title: gd.Title, - Name: gd.Name, - Enabled: gd.Enabled, - URL: gd.Url, - AppURL: gd.AppUrl, - } - if gd.Id != 0 { - di.ID = gd.Id - } - if gd.Position != 0 { - pos := int(gd.Position) - di.Position = &pos - } - p.Dock = append(p.Dock, di) + p.Dock = append(p.Dock, dockItemFromGenerated(gd)) } } @@ -431,3 +417,23 @@ func projectFromGenerated(gp generated.Project) Project { return p } + +// dockItemFromGenerated converts a generated DockItem to our clean DockItem type. +// Shared by Project and Template wrappers. +func dockItemFromGenerated(gd generated.DockItem) DockItem { + di := DockItem{ + Title: gd.Title, + Name: gd.Name, + Enabled: gd.Enabled, + URL: gd.Url, + AppURL: gd.AppUrl, + } + if gd.Id != 0 { + di.ID = gd.Id + } + if gd.Position != 0 { + pos := int(gd.Position) + di.Position = &pos + } + return di +} diff --git a/go/pkg/basecamp/templates.go b/go/pkg/basecamp/templates.go index 5a1037511..4d55fc982 100644 --- a/go/pkg/basecamp/templates.go +++ b/go/pkg/basecamp/templates.go @@ -21,12 +21,15 @@ type TemplateListOptions struct { // Template represents a Basecamp project template. type Template struct { - ID int64 `json:"id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Name string `json:"name"` - Description string `json:"description"` + ID int64 `json:"id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + Description string `json:"description"` + URL string `json:"url,omitempty"` + AppURL string `json:"app_url,omitempty"` + Dock []DockItem `json:"dock,omitempty"` } // ProjectConstruction represents the status of a project being created from a template. @@ -383,12 +386,21 @@ func templateFromGenerated(gt generated.Template) Template { UpdatedAt: gt.UpdatedAt, Name: gt.Name, Description: gt.Description, + URL: gt.Url, + AppURL: gt.AppUrl, } if gt.Id != 0 { t.ID = gt.Id } + if len(gt.Dock) > 0 { + t.Dock = make([]DockItem, 0, len(gt.Dock)) + for _, gd := range gt.Dock { + t.Dock = append(t.Dock, dockItemFromGenerated(gd)) + } + } + return t } diff --git a/go/pkg/basecamp/todos.go b/go/pkg/basecamp/todos.go index 802c744cc..d5a036bad 100644 --- a/go/pkg/basecamp/todos.go +++ b/go/pkg/basecamp/todos.go @@ -74,16 +74,18 @@ type Person struct { Location string `json:"location,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` - Admin bool `json:"admin,omitempty"` - Owner bool `json:"owner,omitempty"` - Client bool `json:"client,omitempty"` - Employee bool `json:"employee,omitempty"` - TimeZone string `json:"time_zone,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` - CanPing bool `json:"can_ping,omitempty"` - Company *PersonCompany `json:"company,omitempty"` - CanManageProjects bool `json:"can_manage_projects,omitempty"` - CanManagePeople bool `json:"can_manage_people,omitempty"` + Admin bool `json:"admin,omitempty"` + Owner bool `json:"owner,omitempty"` + Client bool `json:"client,omitempty"` + Employee bool `json:"employee,omitempty"` + TimeZone string `json:"time_zone,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + CanPing bool `json:"can_ping,omitempty"` + CanAccessHillCharts bool `json:"can_access_hill_charts,omitempty"` + CanAccessTimesheet bool `json:"can_access_timesheet,omitempty"` + Company *PersonCompany `json:"company,omitempty"` + CanManageProjects bool `json:"can_manage_projects,omitempty"` + CanManagePeople bool `json:"can_manage_people,omitempty"` } // PersonCompany represents a company associated with a person. From e4a6b946d213809467a12d84596194b7b3b88b48 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 16 May 2026 10:34:03 -0700 Subject: [PATCH 05/26] wrappers: close remaining structural gaps surfaced by drift audit The field-level audit (formalized as the new check-wrapper-drift script in the next commit) surfaced these additional pre-existing structural gaps that the explicit lists in the prior commits did not cover. - Card: + BoostsURL (sibling of existing BoostsCount) - CardColumn: + CommentsCount (alongside the existing oddly-named CommentCount whose json tag was `comment_count`; the canonical `comments_count` tag now also flows through) - CardStep: + CompletionURL - Document: + BoostsURL - Event: + BoostsCount, BoostsURL - Todo: + VisibleToClients, SubscriptionURL, BoostsURL, CommentsCount, CommentsURL, CompletionURL, CompletionSubscribers []Person - Upload: + BoostsURL All additions follow the same forward-compat additive pattern: zero/empty when absent on the wire, populated through the existing *FromGenerated conversion paths. --- go/pkg/basecamp/cards.go | 6 +++ go/pkg/basecamp/events.go | 4 ++ go/pkg/basecamp/todos.go | 88 ++++++++++++++++++++++----------------- go/pkg/basecamp/vaults.go | 4 ++ 4 files changed, 63 insertions(+), 39 deletions(-) diff --git a/go/pkg/basecamp/cards.go b/go/pkg/basecamp/cards.go index db4d4c096..1a1862030 100644 --- a/go/pkg/basecamp/cards.go +++ b/go/pkg/basecamp/cards.go @@ -48,6 +48,7 @@ type CardColumn struct { Description string `json:"description,omitempty"` CardsCount int `json:"cards_count"` CommentCount int `json:"comment_count"` + CommentsCount int `json:"comments_count,omitempty"` CardsURL string `json:"cards_url,omitempty"` OnHold *CardColumnOnHold `json:"on_hold,omitempty"` Parent *Parent `json:"parent,omitempty"` @@ -90,6 +91,7 @@ type Card struct { CompletedAt *time.Time `json:"completed_at,omitempty"` CommentsCount int `json:"comments_count"` BoostsCount int `json:"boosts_count"` + BoostsURL string `json:"boosts_url,omitempty"` CommentsURL string `json:"comments_url,omitempty"` CommentCount int `json:"comment_count"` CompletionURL string `json:"completion_url,omitempty"` @@ -115,6 +117,7 @@ type CardStep struct { URL string `json:"url"` AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` + CompletionURL string `json:"completion_url,omitempty"` Position int `json:"position"` DueOn string `json:"due_on,omitempty"` Completed bool `json:"completed"` @@ -1244,6 +1247,7 @@ func cardColumnFromGenerated(gc generated.CardColumn) CardColumn { Description: gc.Description, CardsCount: int(gc.CardsCount), CommentCount: int(gc.CommentsCount), + CommentsCount: int(gc.CommentsCount), CardsURL: gc.CardsUrl, CreatedAt: gc.CreatedAt, UpdatedAt: gc.UpdatedAt, @@ -1317,6 +1321,7 @@ func cardFromGenerated(gc generated.Card) Card { Completed: gc.Completed, CommentsCount: int(gc.CommentsCount), BoostsCount: int(gc.BoostsCount), + BoostsURL: gc.BoostsUrl, CommentsURL: gc.CommentsUrl, CompletionURL: gc.CompletionUrl, CreatedAt: gc.CreatedAt, @@ -1400,6 +1405,7 @@ func cardStepFromGenerated(gs generated.CardStep) CardStep { URL: gs.Url, AppURL: gs.AppUrl, BookmarkURL: gs.BookmarkUrl, + CompletionURL: gs.CompletionUrl, Position: int(gs.Position), Completed: gs.Completed, CreatedAt: gs.CreatedAt, diff --git a/go/pkg/basecamp/events.go b/go/pkg/basecamp/events.go index 884202508..0dd3cb11a 100644 --- a/go/pkg/basecamp/events.go +++ b/go/pkg/basecamp/events.go @@ -38,6 +38,8 @@ type Event struct { ID int64 `json:"id"` RecordingID int64 `json:"recording_id"` Action string `json:"action"` + BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` Details *EventDetails `json:"details,omitempty"` CreatedAt time.Time `json:"created_at"` Creator *Person `json:"creator,omitempty"` @@ -151,6 +153,8 @@ func eventFromGenerated(ge generated.Event) Event { e := Event{ RecordingID: ge.RecordingId, Action: ge.Action, + BoostsCount: int(ge.BoostsCount), + BoostsURL: ge.BoostsUrl, CreatedAt: ge.CreatedAt, } diff --git a/go/pkg/basecamp/todos.go b/go/pkg/basecamp/todos.go index d5a036bad..acf2c9f53 100644 --- a/go/pkg/basecamp/todos.go +++ b/go/pkg/basecamp/todos.go @@ -15,36 +15,41 @@ const DefaultTodoLimit = 100 // Todo represents a Basecamp todo item. type Todo struct { - ID int64 `json:"id"` - Status string `json:"status"` - VisibleTo []int64 `json:"visible_to"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Title string `json:"title"` - InheritsVis bool `json:"inherits_status"` - Type string `json:"type"` - URL string `json:"url"` - AppURL string `json:"app_url"` - BookmarkURL string `json:"bookmark_url"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` - Content string `json:"content"` - Description string `json:"description"` - StartsOn string `json:"starts_on,omitempty"` - DueOn string `json:"due_on,omitempty"` - Completed bool `json:"completed"` - BoostsCount int `json:"boosts_count,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Completer *Person `json:"completer,omitempty"` - Assignees []Person `json:"assignees,omitempty"` + ID int64 `json:"id"` + Status string `json:"status"` + VisibleTo []int64 `json:"visible_to"` + VisibleToClients bool `json:"visible_to_clients,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title"` + InheritsVis bool `json:"inherits_status"` + Type string `json:"type"` + URL string `json:"url"` + AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url"` + SubscriptionURL string `json:"subscription_url,omitempty"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` + Content string `json:"content"` + Description string `json:"description"` + StartsOn string `json:"starts_on,omitempty"` + DueOn string `json:"due_on,omitempty"` + Completed bool `json:"completed"` + BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` + CommentsCount int `json:"comments_count,omitempty"` + CommentsURL string `json:"comments_url,omitempty"` + CompletionURL string `json:"completion_url,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Completer *Person `json:"completer,omitempty"` + Assignees []Person `json:"assignees,omitempty"` // CompletionSubscribers distinguishes present-but-empty from absent: // a non-nil zero-length slice means the server sent an empty list, // nil means the property was absent from the response. Deliberately // not omitempty so re-encoding keeps the field visible either way // (nil marshals as null, empty as []) instead of dropping it. CompletionSubscribers []Person `json:"completion_subscribers"` - CommentsCount int `json:"comments_count,omitempty"` Position int `json:"position"` // Steps are the BC5-added subtasks embedded in a Todo response. // The shared `steps/step` jbuilder partial emits the same shape as @@ -763,21 +768,26 @@ func (s *TodosService) Reposition(ctx context.Context, todoID int64, position in // todoFromGenerated converts a generated Todo to our clean Todo type. func todoFromGenerated(gt generated.Todo) Todo { t := Todo{ - Status: gt.Status, - Title: gt.Title, - Type: gt.Type, - URL: gt.Url, - AppURL: gt.AppUrl, - BookmarkURL: gt.BookmarkUrl, - Content: gt.Content, - Description: gt.Description, - Completed: gt.Completed, - Position: int(gt.Position), - CreatedAt: gt.CreatedAt, - UpdatedAt: gt.UpdatedAt, - InheritsVis: gt.InheritsStatus, - BoostsCount: int(gt.BoostsCount), - CommentsCount: int(gt.CommentsCount), + Status: gt.Status, + VisibleToClients: gt.VisibleToClients, + Title: gt.Title, + Type: gt.Type, + URL: gt.Url, + AppURL: gt.AppUrl, + BookmarkURL: gt.BookmarkUrl, + SubscriptionURL: gt.SubscriptionUrl, + Content: gt.Content, + Description: gt.Description, + Completed: gt.Completed, + Position: int(gt.Position), + CreatedAt: gt.CreatedAt, + UpdatedAt: gt.UpdatedAt, + InheritsVis: gt.InheritsStatus, + BoostsCount: int(gt.BoostsCount), + BoostsURL: gt.BoostsUrl, + CommentsCount: int(gt.CommentsCount), + CommentsURL: gt.CommentsUrl, + CompletionURL: gt.CompletionUrl, } if gt.Id != 0 { diff --git a/go/pkg/basecamp/vaults.go b/go/pkg/basecamp/vaults.go index e53bf4faf..a64de8e21 100644 --- a/go/pkg/basecamp/vaults.go +++ b/go/pkg/basecamp/vaults.go @@ -111,6 +111,7 @@ type Document struct { SubscriptionURL string `json:"subscription_url"` CommentsCount int `json:"comments_count"` BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` CommentsURL string `json:"comments_url"` Position int `json:"position,omitempty"` Parent *Parent `json:"parent,omitempty"` @@ -135,6 +136,7 @@ type Upload struct { SubscriptionURL string `json:"subscription_url"` CommentsCount int `json:"comments_count"` BoostsCount int `json:"boosts_count,omitempty"` + BoostsURL string `json:"boosts_url,omitempty"` CommentsURL string `json:"comments_url"` Position int `json:"position,omitempty"` Parent *Parent `json:"parent,omitempty"` @@ -1114,6 +1116,7 @@ func documentFromGenerated(gd generated.Document) Document { SubscriptionURL: gd.SubscriptionUrl, CommentsCount: int(gd.CommentsCount), BoostsCount: int(gd.BoostsCount), + BoostsURL: gd.BoostsUrl, CommentsURL: gd.CommentsUrl, Position: int(gd.Position), Content: gd.Content, @@ -1165,6 +1168,7 @@ func uploadFromGenerated(gu generated.Upload) Upload { SubscriptionURL: gu.SubscriptionUrl, CommentsCount: int(gu.CommentsCount), BoostsCount: int(gu.BoostsCount), + BoostsURL: gu.BoostsUrl, CommentsURL: gu.CommentsUrl, Position: int(gu.Position), Description: gu.Description, From 1ae18bcd2252c44fb7d1e6e98845aed9d3ff8c38 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 16 May 2026 10:37:05 -0700 Subject: [PATCH 06/26] scripts: field-level wrapper drift check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Go AST-based field-level drift check, sibling of the existing operation-level scripts/check-service-drift.sh. The existing check confirms that every generated operation has a hand-written service wrapper; this new check confirms that every generated *struct field* is propagated through the hand-written wrapper layer (or explicitly marked as intentionally-omitted). Discovery: walks (wrapper, generated) pairs in two ways — 1. signature reading of every `xFromGenerated(g generated.Y) X` function declaration in go/pkg/basecamp/*.go (non-test). 2. an explicit `directDecodePairs` map covering wrappers that decode straight from JSON bytes (Notification, NotificationsResult, MyAssignment, Gauge, GaugeNeedle) without a *FromGenerated function. Matching is keyed on JSON tag, not Go field name, so shape-equivalent collisions (`URL string \`json:"url"\`` vs `Url string \`json:"url"\``) are handled correctly. Opt-out marker format // intentionally-omitted: - (ASCII hyphen + space + reason, validated by regex) can appear anywhere inside the wrapper struct body. Wired into: - new `make go-check-wrapper-drift` target - existing `make check` aggregate (next to `go-check-drift`) - go.work `use` block, so `go run ./scripts/check-wrapper-drift/` works without extra setup. Self-tests in main_test.go exercise the parser/extractor helpers, the marker regex (reason-required), the excluded-FromGenerated exception (webhookPersonFromGenerated → WebhookEventPerson), and a happy/drift end-to-end pair. Result on the fully-fixed tree: 51 (wrapper, generated) pairs walked, 842 generated fields verified, zero drift, zero intentionally-omitted markers required. --- Makefile | 14 +- go.work | 1 + scripts/check-wrapper-drift/go.mod | 3 + scripts/check-wrapper-drift/main.go | 446 +++++++++++++++++++++++ scripts/check-wrapper-drift/main_test.go | 245 +++++++++++++ 5 files changed, 706 insertions(+), 3 deletions(-) create mode 100644 scripts/check-wrapper-drift/go.mod create mode 100644 scripts/check-wrapper-drift/main.go create mode 100644 scripts/check-wrapper-drift/main_test.go diff --git a/Makefile b/Makefile index 704b24422..69e047317 100644 --- a/Makefile +++ b/Makefile @@ -204,7 +204,7 @@ sync-api-version-check: # Go SDK targets (delegates to go/Makefile) #------------------------------------------------------------------------------ -.PHONY: go-test go-lint go-check go-clean go-check-drift +.PHONY: go-test go-lint go-check go-clean go-check-drift go-check-wrapper-drift go-test: @$(MAKE) -C go test @@ -223,6 +223,13 @@ go-check-drift: @echo "==> Checking service layer drift..." @./scripts/check-service-drift.sh +# Check for field-level drift between generated structs and hand-written +# wrappers in go/pkg/basecamp/. Sibling of go-check-drift; that check is +# operation-level, this one is field-level. +go-check-wrapper-drift: + @echo "==> Checking wrapper field-level drift..." + @cd $(CURDIR) && go run ./scripts/check-wrapper-drift/ + .PHONY: auth-routable-check # Check that hop-2-only primitives are not called outside the authenticated @@ -623,7 +630,7 @@ generate: @echo "==> Generation complete" # Run all checks (Smithy + Go + TypeScript + Ruby + Kotlin + Swift + Python + Behavior Model + Conformance + Provenance + Actions lint) -check: lint-actions sync-spec-version-check smithy-check behavior-model-check provenance-check sync-api-version-check go-check-drift auth-routable-check kt-check-drift go-check ts-check rb-check kt-check swift-check py-check conformance check-bucket-flat-parity validate-api-gaps +check: lint-actions sync-spec-version-check smithy-check behavior-model-check provenance-check sync-api-version-check go-check-drift go-check-wrapper-drift auth-routable-check kt-check-drift go-check ts-check rb-check kt-check swift-check py-check conformance check-bucket-flat-parity validate-api-gaps @echo "==> All checks passed" # Clean all build artifacts @@ -654,7 +661,8 @@ help: @echo " go-test Run Go tests" @echo " go-lint Run Go linter" @echo " go-check Run all Go checks" - @echo " go-check-drift Check service layer drift vs generated client" + @echo " go-check-drift Check service layer drift vs generated client (operation-level)" + @echo " go-check-wrapper-drift Check wrapper struct drift vs generated structs (field-level)" @echo " go-clean Remove Go build artifacts" @echo "" @echo "TypeScript SDK:" diff --git a/go.work b/go.work index 758e63952..0bc921b4d 100644 --- a/go.work +++ b/go.work @@ -3,4 +3,5 @@ go 1.26 use ( ./conformance/runner/go ./go + ./scripts/check-wrapper-drift ) diff --git a/scripts/check-wrapper-drift/go.mod b/scripts/check-wrapper-drift/go.mod new file mode 100644 index 000000000..78b0fe19d --- /dev/null +++ b/scripts/check-wrapper-drift/go.mod @@ -0,0 +1,3 @@ +module github.com/basecamp/basecamp-sdk/scripts/check-wrapper-drift + +go 1.26 diff --git a/scripts/check-wrapper-drift/main.go b/scripts/check-wrapper-drift/main.go new file mode 100644 index 000000000..5f5f357d0 --- /dev/null +++ b/scripts/check-wrapper-drift/main.go @@ -0,0 +1,446 @@ +// Command check-wrapper-drift performs a field-level drift check between the +// hand-written wrappers in go/pkg/basecamp/ and the generated types in +// go/pkg/generated/. +// +// Discovery +// +// The script walks (wrapper, generated) pairs in two ways: +// +// 1. By signature reading every `FromGenerated(g generated.X) Y` +// function declaration in go/pkg/basecamp/*.go (non-test). The argument +// type names the generated struct; the return type names the wrapper +// struct. (`webhookPersonFromGenerated` is special-cased and excluded +// from the *FromGenerated convention check below — it is a parallel +// mapping for WebhookEventPerson, not a Person wrapper.) +// +// 2. By a small explicit `directDecodePairs` map covering wrappers that +// decode straight from JSON bytes (Notification, NotificationsResult, +// MyAssignment, Gauge, GaugeNeedle). These do not have *FromGenerated +// declarations; the JSON tags on the wrapper struct fields are what the +// JSON decoder uses to read the wire payload. +// +// Check +// +// For each pair, the script compares JSON tag names (not Go field names — +// shape-equivalent tag collisions like wrapper `URL string \`json:"url"\`` +// vs generated `Url string \`json:"url"\`` are handled correctly because the +// match is keyed on the `json:"…"` tag value, e.g. "url"). For every JSON +// tag declared on the generated struct, the wrapper must either: +// +// - declare a field with the same JSON tag, or +// - carry an `// intentionally-omitted: - ` marker (ASCII +// hyphen, matching the repo's default comment convention) anywhere +// inside the wrapper struct's definition block. +// +// If neither is present, the script reports drift and exits 1. +// +// The wrapper may declare additional fields not in the generated struct +// (e.g. SystemLabel on Person, BillableStatus on TimesheetEntry); these are +// not flagged. +// +// Nested struct checks terminate at one level: TodoBucket fields are not +// compared against Bucket wrapper fields recursively. Each (wrapper, +// generated) pair is walked independently. This means a wrapper missing a +// nested struct entirely (e.g. dropping `bucket`) would surface as a missing +// tag on the parent, while a partial nested copy (where the nested wrapper +// itself drifts) would surface only if that nested wrapper has its own pair +// in the map. +package main + +import ( + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// directDecodePairs maps the wrapper struct name to the generated struct +// name for §C-2 wrappers that decode via json.Unmarshal on the (sometimes +// pre-normalized) raw body. Their *FromGenerated function does not exist; +// the wrapper struct's JSON tags are the contract. +var directDecodePairs = map[string]string{ + "Notification": "Notification", + "NotificationsResult": "GetMyNotificationsResponseContent", + "MyAssignment": "MyAssignment", + "Gauge": "Gauge", + "GaugeNeedle": "GaugeNeedle", +} + +// excludedFromGenerated lists *FromGenerated functions whose argument type +// is not the structurally-aligned generated struct of their return type +// (e.g. webhookPersonFromGenerated maps generated.Person → WebhookEventPerson, +// which is a parallel webhook-flavored representation, not a Person wrapper). +// Such pairs are exempt from the field-level check. +var excludedFromGenerated = map[string]bool{ + "webhookPersonFromGenerated": true, +} + +// markerRe matches the wrapper-side opt-out comment. The reason is +// required: `// intentionally-omitted: - `. The tag +// portion is captured for matching; the reason portion is validated as +// non-empty but otherwise free-form. +var markerRe = regexp.MustCompile(`intentionally-omitted:\s*([a-zA-Z0-9_]+)\s*-\s*\S`) + +// structFields captures the JSON tag set of a struct plus the +// intentionally-omitted markers associated with it. Tag is the JSON tag +// (the part before any comma, e.g. "tagline" from `json:"tagline,omitempty"`). +type structFields struct { + tags map[string]bool + omitted map[string]bool + declaration token.Pos +} + +func main() { + verbose := flag.Bool("v", false, "verbose output (list every pair walked)") + root := flag.String("root", "", "repo root (default: walk up from cwd until go/pkg/basecamp/ is found)") + flag.Parse() + + repoRoot, err := resolveRoot(*root) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(2) + } + + wrapperDir := filepath.Join(repoRoot, "go", "pkg", "basecamp") + generatedFile := filepath.Join(repoRoot, "go", "pkg", "generated", "client.gen.go") + + if err := run(wrapperDir, generatedFile, *verbose); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} + +// resolveRoot finds the repo root. If root is set, use it directly. Otherwise +// walk up from cwd looking for go/pkg/basecamp/. +func resolveRoot(root string) (string, error) { + if root != "" { + return root, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + dir := cwd + for { + marker := filepath.Join(dir, "go", "pkg", "basecamp") + if info, err := os.Stat(marker); err == nil && info.IsDir() { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("could not find repo root (no go/pkg/basecamp/ in any ancestor of %s)", cwd) + } + dir = parent + } +} + +func run(wrapperDir, generatedFile string, verbose bool) error { + fset := token.NewFileSet() + + // Parse the generated client. + genFile, err := parser.ParseFile(fset, generatedFile, nil, parser.ParseComments) + if err != nil { + return fmt.Errorf("parse generated: %w", err) + } + genStructs := collectStructsAndMarkers(fset, genFile) + + // Parse all wrapper files. + entries, err := os.ReadDir(wrapperDir) + if err != nil { + return fmt.Errorf("read wrapper dir: %w", err) + } + wrapperStructs := map[string]*structFields{} + fromGenPairs := map[string]string{} // wrapper name -> generated name (derived from *FromGenerated signatures) + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + path := filepath.Join(wrapperDir, name) + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + for k, v := range collectStructsAndMarkers(fset, f) { + wrapperStructs[k] = v + } + for k, v := range collectFromGeneratedPairs(f) { + if !excludedFromGenerated[k+"FromGenerated"] && !excludedFromGenerated[lowercaseFirst(k)+"FromGenerated"] { + fromGenPairs[k] = v + } + } + } + + // Build the final pair list: union of fromGen + directDecode. + pairs := map[string]string{} + for k, v := range fromGenPairs { + pairs[k] = v + } + for k, v := range directDecodePairs { + pairs[k] = v + } + + // Check each pair. + pairNames := make([]string, 0, len(pairs)) + for k := range pairs { + pairNames = append(pairNames, k) + } + sort.Strings(pairNames) + + var drift []string + totalFieldsChecked := 0 + for _, wrapName := range pairNames { + genName := pairs[wrapName] + gen := genStructs[genName] + wrap := wrapperStructs[wrapName] + if gen == nil { + drift = append(drift, fmt.Sprintf("PAIR ERROR: wrapper %s expects generated %s but it was not found in client.gen.go", wrapName, genName)) + continue + } + if wrap == nil { + drift = append(drift, fmt.Sprintf("PAIR ERROR: wrapper %s referenced in %sFromGenerated or directDecodePairs but the wrapper struct was not found in go/pkg/basecamp/", wrapName, lowercaseFirst(wrapName))) + continue + } + + // Walk every JSON tag declared on the generated struct. + tags := make([]string, 0, len(gen.tags)) + for t := range gen.tags { + tags = append(tags, t) + } + sort.Strings(tags) + + var missing []string + for _, tag := range tags { + totalFieldsChecked++ + if wrap.tags[tag] { + continue + } + if wrap.omitted[tag] { + continue + } + missing = append(missing, tag) + } + if len(missing) > 0 { + drift = append(drift, fmt.Sprintf("%s ↔ generated.%s: missing JSON tags %v (add to wrapper struct or mark with `// intentionally-omitted: - `)", wrapName, genName, missing)) + } + if verbose { + fmt.Printf(" %s ↔ generated.%s (%d generated tags, %d wrapper tags, %d omitted)\n", + wrapName, genName, len(gen.tags), len(wrap.tags), len(wrap.omitted)) + } + } + + // Validate any intentionally-omitted markers point at real generated tags. + // This catches typos where a wrapper claims to omit "foo" but the generated + // type emits "foo_bar". + for _, wrapName := range pairNames { + genName := pairs[wrapName] + gen := genStructs[genName] + wrap := wrapperStructs[wrapName] + if gen == nil || wrap == nil { + continue + } + for t := range wrap.omitted { + if !gen.tags[t] { + drift = append(drift, fmt.Sprintf("%s: intentionally-omitted marker for %q does not match any field in generated.%s", wrapName, t, genName)) + } + } + } + + fmt.Printf("Wrapper drift check: %d pairs walked, %d generated fields verified\n", len(pairNames), totalFieldsChecked) + + if len(drift) > 0 { + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "=== DRIFT DETECTED ===") + for _, d := range drift { + fmt.Fprintln(os.Stderr, " -", d) + } + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Fix: either propagate the generated field on the wrapper struct + the FromGenerated function, or add a comment of the form") + fmt.Fprintln(os.Stderr, " `// intentionally-omitted: - ` inside the wrapper struct's declaration.") + return fmt.Errorf("wrapper drift: %d issue(s)", len(drift)) + } + + return nil +} + +// collectStructsAndMarkers walks the AST and returns a map of struct name +// -> tag/omitted info. Only top-level type X struct {…} declarations are +// collected. Intentionally-omitted markers are scraped from ALL comments +// that fall within the struct's source range (between the opening { and +// closing }), so markers don't need to be attached to a specific field — +// they can sit on their own line inside the struct body. +func collectStructsAndMarkers(fset *token.FileSet, f *ast.File) map[string]*structFields { + out := map[string]*structFields{} + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + sf := &structFields{ + tags: map[string]bool{}, + omitted: map[string]bool{}, + declaration: ts.Pos(), + } + for _, field := range st.Fields.List { + if field.Tag == nil { + continue + } + tagVal := field.Tag.Value + if tag := extractJSONTag(tagVal); tag != "" { + sf.tags[tag] = true + } + } + // Scan every comment inside the struct body for opt-out markers. + // (Field-attached comments are duplicates of these for our purposes; + // scanning the full range catches free-standing marker lines too.) + start := st.Fields.Opening + end := st.Fields.Closing + for _, cg := range f.Comments { + if cg.Pos() < start || cg.End() > end { + continue + } + for _, c := range cg.List { + if m := markerRe.FindStringSubmatch(c.Text); m != nil { + sf.omitted[m[1]] = true + } + } + } + out[ts.Name.Name] = sf + } + } + return out +} + +// collectFromGeneratedPairs walks the AST for function declarations of the form +// +// func xFromGenerated(g generated.Y) X +// +// and returns a map of wrapper struct name -> generated struct name. The +// function name does not need to match anything specific; the type signature +// is authoritative. +func collectFromGeneratedPairs(f *ast.File) map[string]string { + out := map[string]string{} + for _, decl := range f.Decls { + fd, ok := decl.(*ast.FuncDecl) + if !ok || fd.Recv != nil { + continue + } + if !strings.HasSuffix(fd.Name.Name, "FromGenerated") { + continue + } + if excludedFromGenerated[fd.Name.Name] { + continue + } + // Need exactly one param and one result. + if fd.Type.Params == nil || len(fd.Type.Params.List) != 1 { + continue + } + if fd.Type.Results == nil || len(fd.Type.Results.List) != 1 { + continue + } + paramType := extractGeneratedTypeName(fd.Type.Params.List[0].Type) + if paramType == "" { + continue + } + resultType := extractLocalTypeName(fd.Type.Results.List[0].Type) + if resultType == "" { + continue + } + out[resultType] = paramType + } + return out +} + +// extractGeneratedTypeName recognizes `generated.X` (SelectorExpr) and returns +// X. Returns "" otherwise. +func extractGeneratedTypeName(expr ast.Expr) string { + sel, ok := expr.(*ast.SelectorExpr) + if !ok { + return "" + } + ident, ok := sel.X.(*ast.Ident) + if !ok || ident.Name != "generated" { + return "" + } + return sel.Sel.Name +} + +// extractLocalTypeName recognizes a bare identifier (the wrapper struct +// returned by FromGenerated) and returns its name. +func extractLocalTypeName(expr ast.Expr) string { + ident, ok := expr.(*ast.Ident) + if !ok { + return "" + } + return ident.Name +} + +// extractJSONTag pulls the tag name from a struct tag literal like +// "`json:\"foo,omitempty\"`". Returns "" if no json tag is present. +func extractJSONTag(tagLiteral string) string { + // Strip the surrounding backticks. + if len(tagLiteral) < 2 || tagLiteral[0] != '`' || tagLiteral[len(tagLiteral)-1] != '`' { + return "" + } + inner := tagLiteral[1 : len(tagLiteral)-1] + // Use reflect-style key-value parsing. Tags look like `json:"foo,omitempty" xml:"bar"`. + for inner != "" { + // Skip leading spaces. + i := 0 + for i < len(inner) && inner[i] == ' ' { + i++ + } + inner = inner[i:] + if inner == "" { + break + } + // Find key (up to ':'). + colon := strings.IndexByte(inner, ':') + if colon == -1 { + break + } + key := inner[:colon] + // Value must start with a quote. + if colon+1 >= len(inner) || inner[colon+1] != '"' { + break + } + // Find closing quote (Go struct tags don't escape quotes in values). + end := strings.IndexByte(inner[colon+2:], '"') + if end == -1 { + break + } + val := inner[colon+2 : colon+2+end] + if key == "json" { + // Take everything before the first comma. + comma := strings.IndexByte(val, ',') + if comma == -1 { + return val + } + return val[:comma] + } + inner = inner[colon+3+end:] + } + return "" +} + +func lowercaseFirst(s string) string { + if s == "" { + return s + } + return strings.ToLower(s[:1]) + s[1:] +} diff --git a/scripts/check-wrapper-drift/main_test.go b/scripts/check-wrapper-drift/main_test.go new file mode 100644 index 000000000..432270555 --- /dev/null +++ b/scripts/check-wrapper-drift/main_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "go/parser" + "go/token" + "strings" + "testing" +) + +func TestExtractJSONTag(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"`json:\"foo\"`", "foo"}, + {"`json:\"foo,omitempty\"`", "foo"}, + {"`json:\"foo,omitempty\" xml:\"bar\"`", "foo"}, + {"`xml:\"bar\" json:\"foo\"`", "foo"}, + {"`json:\"-\"`", "-"}, + {"`xml:\"bar\"`", ""}, + {"", ""}, + {"`json:\"\"`", ""}, + } + for _, c := range cases { + got := extractJSONTag(c.in) + if got != c.want { + t.Errorf("extractJSONTag(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestCollectStructs_TagsAndOmittedMarkers(t *testing.T) { + src := `package fixture + +// Wrapper has two fields and two intentionally-omitted markers sitting on +// their own lines inside the struct body. +type Wrapper struct { + Foo string ` + "`json:\"foo\"`" + ` + // intentionally-omitted: secret_field - never expose + // intentionally-omitted: another_field - not user-visible + Bar int ` + "`json:\"bar,omitempty\"`" + ` +} +` + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "fixture.go", src, parser.ParseComments) + if err != nil { + t.Fatalf("parse: %v", err) + } + structs := collectStructsAndMarkers(fset, f) + w, ok := structs["Wrapper"] + if !ok { + t.Fatal("expected Wrapper struct to be collected") + } + if !w.tags["foo"] || !w.tags["bar"] { + t.Errorf("expected tags foo+bar, got %v", w.tags) + } + if !w.omitted["secret_field"] { + t.Errorf("expected omitted secret_field, got %v", w.omitted) + } + if !w.omitted["another_field"] { + t.Errorf("expected omitted another_field, got %v", w.omitted) + } +} + +func TestCollectFromGeneratedPairs(t *testing.T) { + src := `package fixture + +import "generated" + +// barFromGenerated maps generated.Bar to Bar. +func barFromGenerated(g generated.Bar) Bar { return Bar{} } + +// receiverFnFromGenerated is a method, must be skipped. +func (s *Service) receiverFnFromGenerated(g generated.X) X { return X{} } + +// noGeneratedPrefix is missing the generated. qualifier on the param, skipped. +func wrongParamFromGenerated(g Bar) Bar { return Bar{} } +` + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "fixture.go", src, parser.ParseComments) + if err != nil { + t.Fatalf("parse: %v", err) + } + pairs := collectFromGeneratedPairs(f) + if got := pairs["Bar"]; got != "Bar" { + t.Errorf("expected Bar -> Bar pair, got %q", got) + } + if _, ok := pairs["X"]; ok { + t.Error("method receiver fn must be excluded from pair extraction") + } + if _, ok := pairs["Foo"]; ok { + t.Error("non-generated-prefixed param must be excluded") + } +} + +func TestExtractJSONTag_MultipleKeysIntermixed(t *testing.T) { + // Defensive: a tag that uses an exotic ordering should still resolve. + got := extractJSONTag("`xml:\"x_bar\" json:\"the_json,omitempty\" yaml:\"yam\"`") + if got != "the_json" { + t.Errorf("expected the_json, got %q", got) + } +} + +func TestMarkerRegex_RequiresReason(t *testing.T) { + cases := []struct { + in string + match bool + capture string + }{ + {"// intentionally-omitted: foo - because", true, "foo"}, + {"// intentionally-omitted: foo - x", true, "foo"}, + {"// intentionally-omitted: foo -", false, ""}, + {"// intentionally-omitted: foo ", false, ""}, + {"// not-the-marker: foo - reason", false, ""}, + } + for _, c := range cases { + m := markerRe.FindStringSubmatch(c.in) + if c.match { + if m == nil { + t.Errorf("expected match for %q", c.in) + continue + } + if m[1] != c.capture { + t.Errorf("for %q expected capture %q, got %q", c.in, c.capture, m[1]) + } + } else if m != nil { + t.Errorf("expected no match for %q, got %v", c.in, m) + } + } +} + +// TestEndToEnd_HappyAndDrift drives the full check against two minimal +// fixtures: one in sync, one with a missing tag and an omitted-marker hit. +func TestEndToEnd_HappyAndDrift(t *testing.T) { + genSrc := `package generated + +type Foo struct { + Id int64 ` + "`json:\"id\"`" + ` + Title string ` + "`json:\"title\"`" + ` + Hidden string ` + "`json:\"hidden,omitempty\"`" + ` +} + +type Bar struct { + Id int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` + NewField string ` + "`json:\"new_field,omitempty\"`" + ` +} +` + // Wrapper fixture: Foo is in sync (Hidden marked as intentionally-omitted), + // Bar is drifted (missing NewField). + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Foo struct { + ID int64 ` + "`json:\"id\"`" + ` + Title string ` + "`json:\"title\"`" + ` + // intentionally-omitted: hidden - internal echo, not part of the public surface + internalNote string +} + +func fooFromGenerated(g generated.Foo) Foo { return Foo{} } + +type Bar struct { + ID int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` +} + +func barFromGenerated(g generated.Bar) Bar { return Bar{} } +` + fset := token.NewFileSet() + genFile, err := parser.ParseFile(fset, "gen.go", genSrc, parser.ParseComments) + if err != nil { + t.Fatalf("parse gen: %v", err) + } + wrapFile, err := parser.ParseFile(fset, "wrapper.go", wrapperSrc, parser.ParseComments) + if err != nil { + t.Fatalf("parse wrap: %v", err) + } + genStructs := collectStructsAndMarkers(fset, genFile) + wrapStructs := collectStructsAndMarkers(fset, wrapFile) + pairs := collectFromGeneratedPairs(wrapFile) + + // Foo: in sync (Hidden marked as intentionally-omitted). + fooGen := genStructs["Foo"] + fooWrap := wrapStructs["Foo"] + for tag := range fooGen.tags { + if !fooWrap.tags[tag] && !fooWrap.omitted[tag] { + t.Errorf("Foo: expected tag %q to be matched or omitted, got drift", tag) + } + } + + // Bar: missing new_field, no marker → drift expected. + barGen := genStructs["Bar"] + barWrap := wrapStructs["Bar"] + missing := []string{} + for tag := range barGen.tags { + if !barWrap.tags[tag] && !barWrap.omitted[tag] { + missing = append(missing, tag) + } + } + if len(missing) != 1 || missing[0] != "new_field" { + t.Errorf("Bar: expected drift on [new_field], got %v", missing) + } + + // Sanity: pair extraction worked. + if pairs["Foo"] != "Foo" || pairs["Bar"] != "Bar" { + t.Errorf("pair extraction: got %v", pairs) + } +} + +// TestExcludedFromGenerated verifies that the special-cased mapping +// (webhookPersonFromGenerated → WebhookEventPerson, NOT Person) is skipped +// during automatic pair discovery so the drift check doesn't double-count +// generated.Person as the parent for two unrelated wrappers. +func TestExcludedFromGenerated(t *testing.T) { + src := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +func webhookPersonFromGenerated(g generated.Person) WebhookEventPerson { + return WebhookEventPerson{} +} +` + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "wrapper.go", src, parser.ParseComments) + if err != nil { + t.Fatal(err) + } + pairs := collectFromGeneratedPairs(f) + if _, ok := pairs["WebhookEventPerson"]; ok { + t.Error("webhookPersonFromGenerated should be excluded from auto-discovered pairs") + } +} + +// TestExtractJSONTag_DashSentinel covers the edge case of `json:"-"`, which +// reflect treats as "skip this field". The drift check matches on the literal +// tag value, so `-` is treated like any other JSON tag name. The check still +// holds: a generated struct field with tag `-` would not normally exist +// (oapi-codegen doesn't emit them), but the parser must not crash on it. +func TestExtractJSONTag_DashSentinel(t *testing.T) { + if !strings.HasPrefix(extractJSONTag("`json:\"-,omitempty\"`"), "-") { + t.Error("expected `-` to be captured from `json:\"-,omitempty\"`") + } +} From a83029e1b345d72528c937b131b9d56328f92584 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 16 May 2026 10:39:59 -0700 Subject: [PATCH 07/26] wrappers: tests for new field propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds wrapper_propagation_test.go with three test patterns: 1. TestFromGenerated_PropagatesNewFields — one per wrapper file that gained fields. Asserts only the new fields against a fully-populated generated.X fixture; the existing *FromGenerated_FullPopulated tests still cover the full surface. 2. TestPerson_NewFields_NestedPropagation — end-to-end check that the personFromGenerated standardization causes a fully-populated nested generated.Person (Bio, Tagline, Location, Title, AttachableSGID, PersonableType, Client, Employee, TimeZone, CanPing, CanAccessHillCharts, CanAccessTimesheet, CanManageProjects, CanManagePeople, Company, CreatedAt, UpdatedAt) to flow through every nested context (Recording, Comment, Message, Todo, ScheduleEntry.Creator + Participants, Card.Creator + Completer + Assignees). Pins commit 1's standardization end-to-end. 3. Test_DirectDecode_PropagatesNewFields — JSON-fixture tests for the §C-2 direct-decode wrappers (Notification, NotificationsResult, MyAssignment, Gauge, GaugeNeedle) that decode via json.Unmarshal on the (sometimes pre-normalized) raw body. Also commits the original brief at go/BRIEF-bc5-forward-compat-wrappers.md that triggered this PR — filed by the basecamp-cli dev agent against SDK commit 7e9c4345. Preserving it in-tree keeps the audit trail of why Category A field additions look the way they do, alongside the wider audit (Categories B + C) that the brief flagged would be welcome. 21 new tests in total; all pass on the current tree. --- go/BRIEF-bc5-forward-compat-wrappers.md | 195 +++++ go/pkg/basecamp/wrapper_propagation_test.go | 774 ++++++++++++++++++++ 2 files changed, 969 insertions(+) create mode 100644 go/BRIEF-bc5-forward-compat-wrappers.md create mode 100644 go/pkg/basecamp/wrapper_propagation_test.go diff --git a/go/BRIEF-bc5-forward-compat-wrappers.md b/go/BRIEF-bc5-forward-compat-wrappers.md new file mode 100644 index 000000000..8043455a5 --- /dev/null +++ b/go/BRIEF-bc5-forward-compat-wrappers.md @@ -0,0 +1,195 @@ +# Brief: Surface BC5 forward-compat fields through Go hand-written wrappers + +**Status**: blocker for `basecamp-cli` Phase 1 absorption. +**Audience**: SDK dev agent working from `basecamp-5-is-releasing-eager-spring.md`. +**Source**: filed by `basecamp-cli` `five` branch after attempting Phase 1 against SDK commit `7e9c4345` (Go regen of BC5 spec additions). + +## Summary + +SDK PR 1 added BC5 forward-compat fields to the **generated** Go client +(`go/pkg/generated/client.gen.go`), but did not propagate them through the +**hand-written wrappers** in `go/pkg/basecamp/`. The wrappers are the +public Go API; CLI / external Go consumers never see the generated types +directly. As a result, the new fields are dropped during the +`*FromGenerated` conversion step and Phase 1 of the CLI absorption plan +cannot deliver any user-visible value. + +The CLI's andon cord — *"if the SDK lacks a Go service wrapper for a +generated endpoint, never call the raw generated client from CLI code"* — +applies here in spirit: the wrapper exists, but it discards fields the +spec now declares. The fix lives in the SDK, not the CLI. + +## Evidence + +`generated.Todo` now has `Steps []CardStep` (per `7e9c4345`'s diff to +`client.gen.go:~1990`). The wrapper: + +```go +// go/pkg/basecamp/todos.go:543 +func todoFromGenerated(gt generated.Todo) Todo { + t := Todo{ + Status: gt.Status, + Title: gt.Title, + // … no Steps assignment … + } + // … + return t +} +``` + +`Todo` (`go/pkg/basecamp/todos.go:17`) has no `Steps` field. CLI builds +fail when referencing `todo.Steps` after `app.Account().Todos().Get(...)`. + +The same gap exists for every Phase-1 surface the CLI plan calls out: + +| Generated type (after PR 1) | New fields in `generated/client.gen.go` | Hand-written wrapper file | Wrapper exposes them? | +|---|---|---|---| +| `generated.Todo` | `Steps []CardStep` | `pkg/basecamp/todos.go` `Todo` (line 17) | **no** | +| `generated.Todoset` | `TodosCount`, `CompletedLooseTodosCount`, `TodosUrl`, `AppTodosUrl` | `pkg/basecamp/todosets.go` `Todoset` | **no** | +| `generated.Person` | `Tagline` (alongside existing `Bio`) | `pkg/basecamp/todos.go` `Person` (line 50) | **no** | +| `generated.Notification` | `BubbleUpUrl`, `BubbleUpAt` | `pkg/basecamp/my_notifications.go` `Notification` (line 13) | **no** | +| `generated.GetMyNotificationsResponseContent` | `BubbleUps []Notification`, `ScheduledBubbleUps []Notification` | `pkg/basecamp/my_notifications.go` `NotificationsResult` (line 38) | **no** | + +(There are likely more — these are just the ones the CLI Phase 1 hits +first. A wider audit comparing every `*FromGenerated` function against +its generated source after `7e9c4345` would be welcome.) + +## Why this happened + +The SDK plan §1 ("Smithy spec — forward-compat additions") prescribes +the spec edits and PR 1 ships their regenerated client code. There is no +explicit step in the plan that says *"after regen, propagate new fields +through the hand-written `*FromGenerated` wrappers."* Implicit because +the Go SDK is the only one with this layer, easy to miss when the rest +of the SDK family is purely generated. + +`SPEC.md` documents the architecture ("Go demonstrates the hand-written +service wrapper pattern"), and `CONTRIBUTING.md` mentions the +`go-check-drift` target — that target verifies *all generated operations +are covered by hand-written services*, but does **not** verify that +every generated *field* on covered structures is propagated. The current +drift check is operation-level, not field-level. + +## Contract / acceptance criteria + +For each generated field added in PR 1, do exactly one of: + +1. **Add a corresponding field on the wrapper struct** with the same + semantic meaning, JSON tag matching the wire format, and propagate + it inside the relevant `*FromGenerated` conversion. Default to this + for fields the CLI / external consumers will want to read. +2. **Document the omission inline** with a `// intentionally not + surfaced because ` comment on the wrapper struct, if a field + is genuinely not appropriate for the public Go surface (e.g. an + internal echo). Phase 1 fields should not need this — they're all + user-visible. + +Per-type concrete proposals (signatures, no behavior change for +existing fields): + +```go +// pkg/basecamp/todos.go — Todo +type Todo struct { + // … existing fields … + Steps []CardStep `json:"steps,omitempty"` +} + +func todoFromGenerated(gt generated.Todo) Todo { + t := Todo{ /* existing assignments */ } + if len(gt.Steps) > 0 { + t.Steps = make([]CardStep, 0, len(gt.Steps)) + for _, gs := range gt.Steps { + t.Steps = append(t.Steps, cardStepFromGenerated(gs)) + } + } + return t +} +``` + +```go +// pkg/basecamp/todosets.go — Todoset +type Todoset struct { + // … existing fields … + TodosCount int `json:"todos_count"` // BC5 + CompletedLooseTodosCount int `json:"completed_loose_todos_count"` // BC5 + TodosURL string `json:"todos_url,omitempty"` // BC5 + AppTodosURL string `json:"app_todos_url,omitempty"` // BC5 +} +``` + +```go +// pkg/basecamp/todos.go — Person +type Person struct { + // … existing fields, including Bio … + Tagline string `json:"tagline,omitempty"` // BC5; alias of Bio per spec note +} +``` + +```go +// pkg/basecamp/my_notifications.go +type Notification struct { + // … existing fields … + BubbleUpURL string `json:"bubble_up_url,omitempty"` // BC5 + BubbleUpAt time.Time `json:"bubble_up_at,omitempty"` // BC5 +} + +type NotificationsResult struct { + // … existing fields, including Memories … + BubbleUps []Notification `json:"bubble_ups,omitempty"` // BC5 + ScheduledBubbleUps []Notification `json:"scheduled_bubble_ups,omitempty"` // BC5 +} +``` + +For `Notification.BubbleUpAt`, follow the same `time.Time` zero-value +pattern the wrapper already uses for `ReadAt` / `UnreadAt` — the CLI +checks `IsZero()` on those today. + +## Verification + +A field-level drift check would prevent recurrence. Sketch: + +- Walk every wrapper struct in `pkg/basecamp/*.go`. +- Locate the corresponding generated struct (by either explicit + conversion func or by name match). +- For each generated field, fail if the wrapper has no field with a + matching JSON tag *and* no `// intentionally-omitted: ...` marker on + the wrapper struct. + +This is a separate hardening PR; not blocking the immediate wrapper +update. + +For the immediate PR, manual verification is enough: + +``` +go build ./... # cli & sdk both clean +go test ./pkg/basecamp/... # existing wrapper tests pass +make go-check-drift # current drift check passes +``` + +Then the CLI side runs: + +``` +make bump-sdk REF= +go build ./... # cli compiles with new fields +go test ./... # cli tests still pass +``` + +## Out of scope for this brief + +- Other-language SDK behavior. TS / Ruby / Python / Swift / Kotlin + consume generated types directly (per `AGENTS.md`: "No hand-written + API methods exist in any SDK runtime"); those SDKs picked up the new + fields automatically when their generators ran. +- Spec changes. Spec is correct; this is purely a Go-layer omission. +- Recording.Bubbleupable. Per the CLI plan, that field tracks Phase 3e + (`recording-bubbleupable-field` brief, currently `no-json-contract`) + rather than Phase 1. + +## Why now + +`basecamp-cli` Phase 1 is the first downstream consumer to attempt +absorbing the BC5 forward-compat fields. The Phase 1 work is presenter-only +on paper and could ship in a single commit per surface — but every +surface is gated on the wrapper exposing the field. CLI work will resume +the moment a wrapper-update PR lands; the bump-sdk → presenter changes +chain is well understood. diff --git a/go/pkg/basecamp/wrapper_propagation_test.go b/go/pkg/basecamp/wrapper_propagation_test.go new file mode 100644 index 000000000..2925d5982 --- /dev/null +++ b/go/pkg/basecamp/wrapper_propagation_test.go @@ -0,0 +1,774 @@ +package basecamp + +// Tests covering field propagation through the hand-written wrapper layer +// added by the BC5 readiness wrapper sweep (PR 6). Each test populates a +// fully-fleshed generated.X fixture, runs it through xFromGenerated, and +// asserts ONLY the newly-propagated fields are non-zero on the wrapper — +// avoids duplicating the existing *FromGenerated_FullPopulated tests. +// +// Three patterns: +// +// * `TestFromGenerated_PropagatesNewFields` — for wrappers that +// gained struct fields. Asserts each new field is populated. +// * `TestPerson_NewFields_NestedPropagation` — end-to-end test that the +// standardize-nested-Person refactor (commit 1) causes commit-3's +// Person.Tagline (and other previously-dropped Person fields) to flow +// through every nested context. +// * `Test_DirectDecode_PropagatesNewFields` — for wrappers that +// decode via json.Unmarshal on raw bytes (no FromGenerated function). +// These take a JSON fixture and assert the new fields decode. + +import ( + "encoding/json" + "testing" + "time" + + "github.com/basecamp/basecamp-sdk/go/pkg/generated" + "github.com/basecamp/basecamp-sdk/go/pkg/types" +) + +// ----------------------------------------------------------------------------- +// Commit 1 — nested Person standardization +// ----------------------------------------------------------------------------- + +// TestPerson_NewFields_NestedPropagation exercises the change from inline +// 6-field shallow Person copies to personFromGenerated calls inside every +// *FromGenerated function. After standardization, a fully-populated nested +// generated.Person (Bio, Tagline, Location, Title, PersonableType, +// AttachableSGID, Client, Employee, TimeZone, CanPing, CanAccessHillCharts, +// CanAccessTimesheet, CanManageProjects, CanManagePeople, CreatedAt, +// UpdatedAt, Company) must populate the matching wrapper Person fields +// regardless of the parent context. +func TestPerson_NewFields_NestedPropagation(t *testing.T) { + fullCreator := generated.Person{ + Id: 42, + Name: "Edited Person", + EmailAddress: "p@example.com", + AvatarUrl: "https://example.com/avatar.png", + Admin: true, + Owner: true, + AttachableSgid: "sgid://bc3/Person/42", + PersonableType: "User", + Title: "Lead", + Bio: "Bio text", + Tagline: "Tagline text", + Location: "Chicago", + Client: false, + Employee: true, + TimeZone: "America/Chicago", + CanPing: true, + CanAccessHillCharts: true, + CanAccessTimesheet: true, + CanManageProjects: true, + CanManagePeople: true, + CreatedAt: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2024, 1, 5, 12, 0, 0, 0, time.UTC), + Company: generated.PersonCompany{Id: 7, Name: "Acme Inc."}, + } + + // Exercise nested Person propagation through several wrappers that + // previously inlined a 6-field shallow copy. + t.Run("recording.Creator", func(t *testing.T) { + gr := generated.Recording{Id: 1, Status: "active", Creator: fullCreator} + w := recordingFromGenerated(gr) + assertCreatorFullyPropagated(t, w.Creator, fullCreator) + }) + + t.Run("comment.Creator", func(t *testing.T) { + gc := generated.Comment{Id: 1, Status: "active", Creator: fullCreator} + w := commentFromGenerated(gc) + assertCreatorFullyPropagated(t, w.Creator, fullCreator) + }) + + t.Run("message.Creator", func(t *testing.T) { + gm := generated.Message{Id: 1, Status: "active", Creator: fullCreator} + w := messageFromGenerated(gm) + assertCreatorFullyPropagated(t, w.Creator, fullCreator) + }) + + t.Run("todo.Creator", func(t *testing.T) { + gt := generated.Todo{Id: 1, Status: "active", Creator: fullCreator} + w := todoFromGenerated(gt) + assertCreatorFullyPropagated(t, w.Creator, fullCreator) + }) + + t.Run("schedule_entry.Creator + Participants", func(t *testing.T) { + ge := generated.ScheduleEntry{ + Id: 1, + Status: "active", + Creator: fullCreator, + Participants: []generated.Person{fullCreator}, + } + w := scheduleEntryFromGenerated(ge) + assertCreatorFullyPropagated(t, w.Creator, fullCreator) + if len(w.Participants) != 1 { + t.Fatalf("expected 1 participant, got %d", len(w.Participants)) + } + assertCreatorFullyPropagated(t, &w.Participants[0], fullCreator) + }) + + t.Run("card.Creator + Completer + Assignees", func(t *testing.T) { + gc := generated.Card{ + Id: 1, + Status: "active", + Creator: fullCreator, + Completer: fullCreator, + Assignees: []generated.Person{fullCreator}, + } + w := cardFromGenerated(gc) + assertCreatorFullyPropagated(t, w.Creator, fullCreator) + assertCreatorFullyPropagated(t, w.Completer, fullCreator) + if len(w.Assignees) != 1 { + t.Fatalf("expected 1 assignee, got %d", len(w.Assignees)) + } + assertCreatorFullyPropagated(t, &w.Assignees[0], fullCreator) + }) +} + +func assertCreatorFullyPropagated(t *testing.T, p *Person, gp generated.Person) { + t.Helper() + if p == nil { + t.Fatal("expected wrapper Person to be non-nil") + } + if p.ID != int64(gp.Id) { + t.Errorf("ID: got %d, want %d", p.ID, int64(gp.Id)) + } + if p.Name != gp.Name { + t.Errorf("Name: got %q, want %q", p.Name, gp.Name) + } + if p.EmailAddress != gp.EmailAddress { + t.Errorf("EmailAddress: got %q, want %q", p.EmailAddress, gp.EmailAddress) + } + if p.AvatarURL != gp.AvatarUrl { + t.Errorf("AvatarURL: got %q, want %q", p.AvatarURL, gp.AvatarUrl) + } + if p.Admin != gp.Admin { + t.Errorf("Admin: got %v, want %v", p.Admin, gp.Admin) + } + if p.Owner != gp.Owner { + t.Errorf("Owner: got %v, want %v", p.Owner, gp.Owner) + } + // Fields that the old shallow copy silently dropped. + if p.AttachableSGID != gp.AttachableSgid { + t.Errorf("AttachableSGID: got %q, want %q", p.AttachableSGID, gp.AttachableSgid) + } + if p.PersonableType != gp.PersonableType { + t.Errorf("PersonableType: got %q, want %q", p.PersonableType, gp.PersonableType) + } + if p.Title != gp.Title { + t.Errorf("Title: got %q, want %q", p.Title, gp.Title) + } + if p.Bio != gp.Bio { + t.Errorf("Bio: got %q, want %q", p.Bio, gp.Bio) + } + if p.Tagline != gp.Tagline { + t.Errorf("Tagline (BC5): got %q, want %q", p.Tagline, gp.Tagline) + } + if p.Location != gp.Location { + t.Errorf("Location: got %q, want %q", p.Location, gp.Location) + } + if p.Client != gp.Client { + t.Errorf("Client: got %v, want %v", p.Client, gp.Client) + } + if p.Employee != gp.Employee { + t.Errorf("Employee: got %v, want %v", p.Employee, gp.Employee) + } + if p.TimeZone != gp.TimeZone { + t.Errorf("TimeZone: got %q, want %q", p.TimeZone, gp.TimeZone) + } + if p.CanPing != gp.CanPing { + t.Errorf("CanPing: got %v, want %v", p.CanPing, gp.CanPing) + } + if p.CanAccessHillCharts != gp.CanAccessHillCharts { + t.Errorf("CanAccessHillCharts: got %v, want %v", p.CanAccessHillCharts, gp.CanAccessHillCharts) + } + if p.CanAccessTimesheet != gp.CanAccessTimesheet { + t.Errorf("CanAccessTimesheet: got %v, want %v", p.CanAccessTimesheet, gp.CanAccessTimesheet) + } + if p.CanManageProjects != gp.CanManageProjects { + t.Errorf("CanManageProjects: got %v, want %v", p.CanManageProjects, gp.CanManageProjects) + } + if p.CanManagePeople != gp.CanManagePeople { + t.Errorf("CanManagePeople: got %v, want %v", p.CanManagePeople, gp.CanManagePeople) + } + if p.Company == nil { + t.Error("Company: expected non-nil") + } else { + if p.Company.ID != gp.Company.Id { + t.Errorf("Company.ID: got %d, want %d", p.Company.ID, gp.Company.Id) + } + if p.Company.Name != gp.Company.Name { + t.Errorf("Company.Name: got %q, want %q", p.Company.Name, gp.Company.Name) + } + } + if p.CreatedAt == "" { + t.Error("CreatedAt: expected non-empty string from non-zero time") + } + if p.UpdatedAt == "" { + t.Error("UpdatedAt: expected non-empty string from non-zero time") + } +} + +// ----------------------------------------------------------------------------- +// Commit 3 — BC5 forward-compat fields (Todo, Todoset) +// ----------------------------------------------------------------------------- + +func TestTodoFromGenerated_PropagatesNewFields(t *testing.T) { + gt := generated.Todo{ + Id: 42, + Status: "active", + Title: "T", + VisibleToClients: true, + BoostsCount: 3, + BoostsUrl: "https://example.com/boosts", + CommentsCount: 5, + CommentsUrl: "https://example.com/comments", + CompletionUrl: "https://example.com/completion", + SubscriptionUrl: "https://example.com/subscription", + CompletionSubscribers: []generated.Person{ + {Id: 7, Name: "Subscriber"}, + }, + Steps: []generated.CardStep{ + {Id: 99, Title: "Step 1", Status: "active"}, + {Id: 100, Title: "Step 2", Status: "active"}, + }, + } + w := todoFromGenerated(gt) + + if !w.VisibleToClients { + t.Error("VisibleToClients: expected true") + } + if w.BoostsCount != 3 { + t.Errorf("BoostsCount: got %d, want 3", w.BoostsCount) + } + if w.BoostsURL != "https://example.com/boosts" { + t.Errorf("BoostsURL: got %q", w.BoostsURL) + } + if w.CommentsCount != 5 { + t.Errorf("CommentsCount: got %d, want 5", w.CommentsCount) + } + if w.CommentsURL != "https://example.com/comments" { + t.Errorf("CommentsURL: got %q", w.CommentsURL) + } + if w.CompletionURL != "https://example.com/completion" { + t.Errorf("CompletionURL: got %q", w.CompletionURL) + } + if w.SubscriptionURL != "https://example.com/subscription" { + t.Errorf("SubscriptionURL: got %q", w.SubscriptionURL) + } + if len(w.CompletionSubscribers) != 1 || w.CompletionSubscribers[0].Name != "Subscriber" { + t.Errorf("CompletionSubscribers: got %+v", w.CompletionSubscribers) + } + if len(w.Steps) != 2 { + t.Fatalf("Steps (BC5): got %d, want 2", len(w.Steps)) + } + if w.Steps[0].Title != "Step 1" || w.Steps[1].Title != "Step 2" { + t.Errorf("Steps (BC5) titles: got %q, %q", w.Steps[0].Title, w.Steps[1].Title) + } +} + +func TestTodosetFromGenerated_PropagatesNewFields(t *testing.T) { + gts := generated.Todoset{ + Id: 42, + Status: "active", + Title: "T", + TodosCount: 17, + CompletedLooseTodosCount: 3, + TodosUrl: "https://example.com/todos", + AppTodosUrl: "https://example.com/app/todos", + } + w := todosetFromGenerated(gts) + if w.TodosCount != 17 { + t.Errorf("TodosCount: got %d, want 17", w.TodosCount) + } + if w.CompletedLooseTodosCount != 3 { + t.Errorf("CompletedLooseTodosCount: got %d, want 3", w.CompletedLooseTodosCount) + } + if w.TodosURL != "https://example.com/todos" { + t.Errorf("TodosURL: got %q", w.TodosURL) + } + if w.AppTodosURL != "https://example.com/app/todos" { + t.Errorf("AppTodosURL: got %q", w.AppTodosURL) + } +} + +// ----------------------------------------------------------------------------- +// Commit 2 — cross-cutting Recording-shaped fields +// ----------------------------------------------------------------------------- + +func TestRecordingFromGenerated_PropagatesNewFields(t *testing.T) { + gr := generated.Recording{ + Id: 42, + Status: "active", + Title: "R", + Content: "the content", + CommentsCount: 9, + CommentsUrl: "https://example.com/comments", + SubscriptionUrl: "https://example.com/sub", + } + w := recordingFromGenerated(gr) + if w.Content != "the content" { + t.Errorf("Content: got %q", w.Content) + } + if w.CommentsCount != 9 { + t.Errorf("CommentsCount: got %d", w.CommentsCount) + } + if w.CommentsURL != "https://example.com/comments" { + t.Errorf("CommentsURL: got %q", w.CommentsURL) + } + if w.SubscriptionURL != "https://example.com/sub" { + t.Errorf("SubscriptionURL: got %q", w.SubscriptionURL) + } +} + +func TestCommentFromGenerated_PropagatesNewFields(t *testing.T) { + gc := generated.Comment{ + Id: 42, + Status: "active", + Title: "C", + VisibleToClients: true, + InheritsStatus: true, + BookmarkUrl: "https://example.com/bm", + BoostsCount: 3, + BoostsUrl: "https://example.com/boosts", + } + w := commentFromGenerated(gc) + if !w.VisibleToClients { + t.Error("VisibleToClients") + } + if w.Title != "C" { + t.Errorf("Title: got %q", w.Title) + } + if !w.InheritsStatus { + t.Error("InheritsStatus") + } + if w.BookmarkURL != "https://example.com/bm" { + t.Errorf("BookmarkURL: got %q", w.BookmarkURL) + } + if w.BoostsCount != 3 { + t.Errorf("BoostsCount: got %d", w.BoostsCount) + } + if w.BoostsURL != "https://example.com/boosts" { + t.Errorf("BoostsURL: got %q", w.BoostsURL) + } +} + +func TestMessageFromGenerated_PropagatesNewFields(t *testing.T) { + gm := generated.Message{ + Id: 42, + Status: "active", + Title: "M", + VisibleToClients: true, + InheritsStatus: true, + BookmarkUrl: "https://example.com/bm", + BoostsUrl: "https://example.com/boosts", + CommentsCount: 7, + CommentsUrl: "https://example.com/comments", + SubscriptionUrl: "https://example.com/sub", + } + w := messageFromGenerated(gm) + if !w.VisibleToClients || w.Title != "M" || !w.InheritsStatus { + t.Errorf("base recording-shaped fields not propagated: %+v", w) + } + if w.BookmarkURL == "" || w.BoostsURL == "" || w.CommentsURL == "" || w.SubscriptionURL == "" { + t.Errorf("URL fields not propagated: %+v", w) + } + if w.CommentsCount != 7 { + t.Errorf("CommentsCount: got %d", w.CommentsCount) + } +} + +func TestMessageBoardFromGenerated_PropagatesNewFields(t *testing.T) { + gb := generated.MessageBoard{ + Id: 42, + Status: "active", + Title: "MB", + VisibleToClients: true, + InheritsStatus: true, + BookmarkUrl: "https://example.com/bm", + AppMessagesUrl: "https://example.com/app/messages", + Position: 3, + } + w := messageBoardFromGenerated(gb) + if !w.VisibleToClients || !w.InheritsStatus { + t.Errorf("flags: %+v", w) + } + if w.BookmarkURL != "https://example.com/bm" { + t.Errorf("BookmarkURL: got %q", w.BookmarkURL) + } + if w.AppMessagesURL != "https://example.com/app/messages" { + t.Errorf("AppMessagesURL: got %q", w.AppMessagesURL) + } + if w.Position != 3 { + t.Errorf("Position: got %d", w.Position) + } +} + +func TestForwardFromGenerated_PropagatesNewFields(t *testing.T) { + gf := generated.Forward{ + Id: 42, + Status: "active", + Title: "F", + Subject: "subj", + VisibleToClients: true, + InheritsStatus: true, + BookmarkUrl: "https://example.com/bm", + SubscriptionUrl: "https://example.com/sub", + RepliesCount: 5, + RepliesUrl: "https://example.com/replies", + } + w := forwardFromGenerated(gf) + if !w.VisibleToClients || !w.InheritsStatus || w.Title != "F" { + t.Errorf("recording-shaped flags: %+v", w) + } + if w.BookmarkURL == "" || w.SubscriptionURL == "" { + t.Errorf("URLs not propagated: %+v", w) + } + if w.RepliesCount != 5 || w.RepliesURL == "" { + t.Errorf("Replies: count=%d url=%q", w.RepliesCount, w.RepliesURL) + } +} + +func TestForwardReplyFromGenerated_PropagatesNewFields(t *testing.T) { + gr := generated.ForwardReply{ + Id: 42, + Status: "active", + Title: "FR", + Content: "x", + VisibleToClients: true, + InheritsStatus: true, + BookmarkUrl: "https://example.com/bm", + BoostsCount: 3, + BoostsUrl: "https://example.com/boosts", + } + w := forwardReplyFromGenerated(gr) + if !w.VisibleToClients || !w.InheritsStatus || w.Title != "FR" { + t.Errorf("flags: %+v", w) + } + if w.BookmarkURL == "" || w.BoostsURL == "" { + t.Errorf("URLs not propagated: %+v", w) + } + if w.BoostsCount != 3 { + t.Errorf("BoostsCount: %d", w.BoostsCount) + } +} + +func TestCampfireLineFromGenerated_PropagatesNewFields(t *testing.T) { + gl := generated.CampfireLine{ + Id: 42, + Status: "active", + Title: "L", + BookmarkUrl: "https://example.com/bm", + BoostsCount: 3, + BoostsUrl: "https://example.com/boosts", + } + w := campfireLineFromGenerated(gl) + if w.BookmarkURL == "" || w.BoostsURL == "" { + t.Errorf("URLs: %+v", w) + } + if w.BoostsCount != 3 { + t.Errorf("BoostsCount: %d", w.BoostsCount) + } +} + +func TestScheduleEntryFromGenerated_PropagatesNewFields(t *testing.T) { + ge := generated.ScheduleEntry{ + Id: 42, + Status: "active", + Summary: "s", + BoostsCount: 3, + BoostsUrl: "https://example.com/boosts", + } + w := scheduleEntryFromGenerated(ge) + if w.BoostsCount != 3 || w.BoostsURL == "" { + t.Errorf("Boosts: %+v", w) + } +} + +func TestQuestionAnswerFromGenerated_PropagatesNewFields(t *testing.T) { + ga := generated.QuestionAnswer{ + Id: 42, + Status: "active", + Title: "A", + Content: "x", + BoostsCount: 3, + BoostsUrl: "https://example.com/boosts", + } + w := questionAnswerFromGenerated(ga) + if w.BoostsCount != 3 || w.BoostsURL == "" { + t.Errorf("Boosts: %+v", w) + } +} + +func TestTodolistFromGenerated_PropagatesNewFields(t *testing.T) { + gtl := generated.Todolist{ + Id: 42, + Status: "active", + Title: "TL", + BoostsCount: 3, + BoostsUrl: "https://example.com/boosts", + } + w := todolistFromGenerated(gtl) + if w.BoostsCount != 3 || w.BoostsURL == "" { + t.Errorf("Boosts: %+v", w) + } +} + +func TestTimesheetEntryFromGenerated_PropagatesNewFields(t *testing.T) { + ge := generated.TimesheetEntry{ + Id: 42, + Date: "2024-01-15", + Hours: "1.5", + Status: "active", + Title: "TE", + Type: "Timesheets::Entry", + Url: "https://example.com/u", + AppUrl: "https://example.com/au", + BookmarkUrl: "https://example.com/bm", + VisibleToClients: true, + InheritsStatus: true, + } + w := timesheetEntryFromGenerated(ge) + if w.Status != "active" || w.Title != "TE" || w.Type == "" || w.URL == "" || w.AppURL == "" || w.BookmarkURL == "" { + t.Errorf("Recording-shape fields not propagated: %+v", w) + } + if !w.VisibleToClients || !w.InheritsStatus { + t.Errorf("Flags not propagated: %+v", w) + } +} + +func TestInboxFromGenerated_PropagatesNewFields(t *testing.T) { + gi := generated.Inbox{ + Id: 42, + Status: "active", + Title: "IB", + Position: 5, + VisibleToClients: true, + InheritsStatus: true, + BookmarkUrl: "https://example.com/bm", + ForwardsCount: 12, + ForwardsUrl: "https://example.com/forwards", + } + w := inboxFromGenerated(gi) + if !w.VisibleToClients || !w.InheritsStatus { + t.Errorf("flags: %+v", w) + } + if w.Position != 5 || w.BookmarkURL == "" || w.ForwardsCount != 12 || w.ForwardsURL == "" { + t.Errorf("new fields: %+v", w) + } +} + +// ----------------------------------------------------------------------------- +// Commit 4 — one-off structural fields +// ----------------------------------------------------------------------------- + +func TestCampfireFromGenerated_PropagatesNewFields(t *testing.T) { + gc := generated.Campfire{ + Id: 42, + Status: "active", + Title: "C", + Topic: "fellowship of the campfire", + Position: 3, + BookmarkUrl: "https://example.com/bm", + SubscriptionUrl: "https://example.com/sub", + } + w := campfireFromGenerated(gc) + if w.Topic != "fellowship of the campfire" { + t.Errorf("Topic: got %q", w.Topic) + } + if w.Position != 3 { + t.Errorf("Position: got %d", w.Position) + } + if w.BookmarkURL == "" || w.SubscriptionURL == "" { + t.Errorf("URLs: %+v", w) + } +} + +func TestTemplateFromGenerated_PropagatesNewFields(t *testing.T) { + gt := generated.Template{ + Id: 42, + Name: "T", + Url: "https://example.com/u", + AppUrl: "https://example.com/au", + Dock: []generated.DockItem{ + {Id: 1, Title: "Chat", Name: "campfire", Enabled: true, Url: "u1", AppUrl: "au1", Position: 0}, + {Id: 2, Title: "Docs", Name: "vault", Enabled: false, Url: "u2", AppUrl: "au2", Position: 1}, + }, + } + w := templateFromGenerated(gt) + if w.URL != "https://example.com/u" || w.AppURL != "https://example.com/au" { + t.Errorf("URLs: %+v", w) + } + if len(w.Dock) != 2 { + t.Fatalf("Dock len: got %d", len(w.Dock)) + } + if w.Dock[0].Title != "Chat" || w.Dock[0].Name != "campfire" || !w.Dock[0].Enabled { + t.Errorf("Dock[0]: %+v", w.Dock[0]) + } + if w.Dock[1].Title != "Docs" || w.Dock[1].Name != "vault" || w.Dock[1].Enabled { + t.Errorf("Dock[1]: %+v", w.Dock[1]) + } +} + +// ----------------------------------------------------------------------------- +// Direct-decode wrappers — JSON fixture tests +// ----------------------------------------------------------------------------- + +func TestNotification_DirectDecode_PropagatesNewFields(t *testing.T) { + // BC5 forward-compat fields BubbleUpURL + BubbleUpAt plus the + // one-off Creator + Participants + PreviewableAttachments fields + // (added in commit 4). + raw := []byte(`{ + "id": 1, + "title": "Bubble", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "bubble_up_url": "https://example.com/bubble", + "bubble_up_at": "2024-02-01T08:00:00Z", + "creator": { + "id": 7, + "name": "Author", + "personable_type": "User" + }, + "participants": [ + {"id": 8, "name": "P1", "personable_type": "User"}, + {"id": 9, "name": "P2", "personable_type": "User"} + ], + "previewable_attachments": [ + {"id": 100, "url": "https://example.com/u", "app_url": "https://example.com/au", "content_type": "image/png", "filename": "img.png", "filesize": 1234, "width": 100, "height": 200} + ] + }`) + + var n Notification + if err := json.Unmarshal(raw, &n); err != nil { + t.Fatalf("decode: %v", err) + } + if n.BubbleUpURL != "https://example.com/bubble" { + t.Errorf("BubbleUpURL: got %q", n.BubbleUpURL) + } + if n.BubbleUpAt.IsZero() { + t.Error("BubbleUpAt: expected non-zero") + } + if n.Creator == nil || n.Creator.Name != "Author" { + t.Errorf("Creator: %+v", n.Creator) + } + if len(n.Participants) != 2 { + t.Errorf("Participants: got %d", len(n.Participants)) + } + if len(n.PreviewableAttachments) != 1 { + t.Fatalf("PreviewableAttachments: got %d", len(n.PreviewableAttachments)) + } + pa := n.PreviewableAttachments[0] + if pa.ID == nil || *pa.ID != 100 || pa.URL == "" || pa.ContentType == "" { + t.Errorf("PreviewableAttachment[0]: %+v", pa) + } +} + +func TestNotificationsResult_DirectDecode_PropagatesNewFields(t *testing.T) { + // BC5 forward-compat fields BubbleUps + ScheduledBubbleUps on the + // envelope. + raw := []byte(`{ + "unreads": [], + "reads": [], + "memories": [], + "bubble_ups": [ + {"id": 1, "title": "Today", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-02T00:00:00Z"} + ], + "scheduled_bubble_ups": [ + {"id": 2, "title": "Future", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-02T00:00:00Z", "bubble_up_at": "2024-02-01T08:00:00Z"} + ] + }`) + + var r NotificationsResult + if err := json.Unmarshal(raw, &r); err != nil { + t.Fatalf("decode: %v", err) + } + if len(r.BubbleUps) != 1 || r.BubbleUps[0].Title != "Today" { + t.Errorf("BubbleUps: %+v", r.BubbleUps) + } + if len(r.ScheduledBubbleUps) != 1 || r.ScheduledBubbleUps[0].Title != "Future" { + t.Errorf("ScheduledBubbleUps: %+v", r.ScheduledBubbleUps) + } + if r.ScheduledBubbleUps[0].BubbleUpAt.IsZero() { + t.Error("ScheduledBubbleUps[0].BubbleUpAt: expected non-zero") + } +} + +func TestMyAssignment_DirectDecode_PropagatesNewFields(t *testing.T) { + // MyAssignment is direct-decode. Its fields predate this PR but the + // drift check exercises the contract; this test pins the JSON shape. + raw := []byte(`{ + "id": 1, + "type": "Todo", + "content": "Do thing", + "completed": false, + "due_on": "2024-02-15", + "starts_on": "2024-02-01", + "comments_count": 3, + "app_url": "https://example.com/app/1", + "assignees": [{"id": 7, "name": "A", "avatar_url": "u"}], + "bucket": {"id": 9, "name": "Project", "app_url": "au"}, + "parent": {"id": 11, "title": "List", "app_url": "au"} + }`) + var a MyAssignment + if err := json.Unmarshal(raw, &a); err != nil { + t.Fatalf("decode: %v", err) + } + if a.ID != 1 || a.Type != "Todo" || a.Content != "Do thing" || a.CommentsCount != 3 || a.AppURL == "" { + t.Errorf("base fields: %+v", a) + } + if a.DueOn != "2024-02-15" || a.StartsOn != "2024-02-01" { + t.Errorf("dates: %+v", a) + } + if len(a.Assignees) != 1 || a.Assignees[0].Name != "A" { + t.Errorf("Assignees: %+v", a.Assignees) + } +} + +func TestGauge_DirectDecode_PropagatesNewFields(t *testing.T) { + raw := []byte(`{ + "id": 1, + "title": "G", + "creator": {"id": 7, "name": "Author"}, + "bucket": {"id": 9, "name": "Project", "type": "Project"} + }`) + var g Gauge + if err := json.Unmarshal(raw, &g); err != nil { + t.Fatalf("decode: %v", err) + } + if g.Creator == nil || g.Creator.Name != "Author" { + t.Errorf("Creator: %+v", g.Creator) + } + if g.Bucket == nil || g.Bucket.Name != "Project" { + t.Errorf("Bucket: %+v", g.Bucket) + } +} + +func TestGaugeNeedle_DirectDecode_PropagatesNewFields(t *testing.T) { + raw := []byte(`{ + "id": 1, + "title": "GN", + "creator": {"id": 7, "name": "Author"}, + "bucket": {"id": 9, "name": "Project", "type": "Project"}, + "parent": {"id": 11, "title": "G", "type": "Gauge", "url": "u", "app_url": "au"} + }`) + var n GaugeNeedle + if err := json.Unmarshal(raw, &n); err != nil { + t.Fatalf("decode: %v", err) + } + if n.Creator == nil || n.Creator.Name != "Author" { + t.Errorf("Creator: %+v", n.Creator) + } + if n.Bucket == nil || n.Bucket.Name != "Project" { + t.Errorf("Bucket: %+v", n.Bucket) + } + if n.Parent == nil || n.Parent.Title != "G" { + t.Errorf("Parent: %+v", n.Parent) + } +} + +// ----------------------------------------------------------------------------- +// types helper (silences unused-import lint when go test rebuilds) +// ----------------------------------------------------------------------------- + +var _ = types.Date{} From 1c92e1e208b4b72d8732fbb2eaa2c2d68a1dd8a4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 16 May 2026 10:42:09 -0700 Subject: [PATCH 08/26] wrappers: gofmt long-tag struct alignment `gofmt -s -w` rewrites the column-aligned tag layouts in Notification, NotificationsResult, Person, Todo, and Todoset where the newly-added fields pushed the existing alignment past gofmt's preferred width. No behavior change; closes `make fmt-check` (and thus `make check`). --- go/pkg/basecamp/my_notifications.go | 30 ++++++------ go/pkg/basecamp/todos.go | 24 ++++----- go/pkg/basecamp/todosets.go | 76 ++++++++++++++--------------- 3 files changed, 65 insertions(+), 65 deletions(-) diff --git a/go/pkg/basecamp/my_notifications.go b/go/pkg/basecamp/my_notifications.go index c67cc31a1..a54fb5867 100644 --- a/go/pkg/basecamp/my_notifications.go +++ b/go/pkg/basecamp/my_notifications.go @@ -11,21 +11,21 @@ import ( // Notification represents a single notification item. type Notification struct { - ID int64 `json:"id"` - Type string `json:"type,omitempty"` - Title string `json:"title,omitempty"` - Section string `json:"section,omitempty"` - ContentExcerpt string `json:"content_excerpt,omitempty"` - BucketName string `json:"bucket_name,omitempty"` - ReadableIdentifier string `json:"readable_identifier,omitempty"` - ReadableSGID string `json:"readable_sgid,omitempty"` - Subscribed bool `json:"subscribed,omitempty"` - Named bool `json:"named,omitempty"` - UnreadCount int32 `json:"unread_count,omitempty"` - ImageURL string `json:"image_url,omitempty"` - AppURL string `json:"app_url,omitempty"` - BookmarkURL string `json:"bookmark_url,omitempty"` - MemoryURL string `json:"memory_url,omitempty"` + ID int64 `json:"id"` + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Section string `json:"section,omitempty"` + ContentExcerpt string `json:"content_excerpt,omitempty"` + BucketName string `json:"bucket_name,omitempty"` + ReadableIdentifier string `json:"readable_identifier,omitempty"` + ReadableSGID string `json:"readable_sgid,omitempty"` + Subscribed bool `json:"subscribed,omitempty"` + Named bool `json:"named,omitempty"` + UnreadCount int32 `json:"unread_count,omitempty"` + ImageURL string `json:"image_url,omitempty"` + AppURL string `json:"app_url,omitempty"` + BookmarkURL string `json:"bookmark_url,omitempty"` + MemoryURL string `json:"memory_url,omitempty"` // BubbleUpURL is the BC5-added URL for the Bubble Up record covering this // notification. Eligibility-gated — only present on items the current user // can bubble up. diff --git a/go/pkg/basecamp/todos.go b/go/pkg/basecamp/todos.go index acf2c9f53..78156cccc 100644 --- a/go/pkg/basecamp/todos.go +++ b/go/pkg/basecamp/todos.go @@ -64,21 +64,21 @@ type Todo struct { // Endpoints that decode through the generated parser lose the label — // use PersonableType == "LocalPerson" as the authoritative discriminator. type Person struct { - ID int64 `json:"id"` - SystemLabel string `json:"system_label,omitempty"` - AttachableSGID string `json:"attachable_sgid,omitempty"` - Name string `json:"name"` - EmailAddress string `json:"email_address,omitempty"` - PersonableType string `json:"personable_type,omitempty"` - Title string `json:"title,omitempty"` - Bio string `json:"bio,omitempty"` + ID int64 `json:"id"` + SystemLabel string `json:"system_label,omitempty"` + AttachableSGID string `json:"attachable_sgid,omitempty"` + Name string `json:"name"` + EmailAddress string `json:"email_address,omitempty"` + PersonableType string `json:"personable_type,omitempty"` + Title string `json:"title,omitempty"` + Bio string `json:"bio,omitempty"` // Tagline is the BC5-added alias of Bio. BC3 emits both keys with identical // content; older BC4 responses may omit `tagline`. Prefer `Bio` for // cross-version reads. Surfaced as a separate wrapper field per spec note. - Tagline string `json:"tagline,omitempty"` - Location string `json:"location,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` + Tagline string `json:"tagline,omitempty"` + Location string `json:"location,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` Admin bool `json:"admin,omitempty"` Owner bool `json:"owner,omitempty"` Client bool `json:"client,omitempty"` diff --git a/go/pkg/basecamp/todosets.go b/go/pkg/basecamp/todosets.go index c11ff9173..ed9651ea7 100644 --- a/go/pkg/basecamp/todosets.go +++ b/go/pkg/basecamp/todosets.go @@ -11,31 +11,31 @@ import ( // Todoset represents a Basecamp todoset (container for todolists in a project). // Each project has exactly one todoset in its dock. type Todoset struct { - ID int64 `json:"id"` - Status string `json:"status"` - VisibleToClients bool `json:"visible_to_clients"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Title string `json:"title"` - InheritsStatus bool `json:"inherits_status"` - Type string `json:"type"` - URL string `json:"url"` - AppURL string `json:"app_url"` - BookmarkURL string `json:"bookmark_url"` - Position *int `json:"position,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` - Name string `json:"name"` - TodolistsCount int `json:"todolists_count"` - TodolistsURL string `json:"todolists_url"` - CompletedRatio string `json:"completed_ratio"` - Completed bool `json:"completed"` - AppTodolistsURL string `json:"app_todolists_url"` + ID int64 `json:"id"` + Status string `json:"status"` + VisibleToClients bool `json:"visible_to_clients"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title"` + InheritsStatus bool `json:"inherits_status"` + Type string `json:"type"` + URL string `json:"url"` + AppURL string `json:"app_url"` + BookmarkURL string `json:"bookmark_url"` + Position *int `json:"position,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` + Name string `json:"name"` + TodolistsCount int `json:"todolists_count"` + TodolistsURL string `json:"todolists_url"` + CompletedRatio string `json:"completed_ratio"` + Completed bool `json:"completed"` + AppTodolistsURL string `json:"app_todolists_url"` // BC5 additions: total + completed counts and the top-level todos URLs. - TodosCount int `json:"todos_count,omitempty"` - CompletedLooseTodosCount int `json:"completed_loose_todos_count,omitempty"` - TodosURL string `json:"todos_url,omitempty"` - AppTodosURL string `json:"app_todos_url,omitempty"` + TodosCount int `json:"todos_count,omitempty"` + CompletedLooseTodosCount int `json:"completed_loose_todos_count,omitempty"` + TodosURL string `json:"todos_url,omitempty"` + AppTodosURL string `json:"app_todos_url,omitempty"` } // TodosetsService handles todoset operations. @@ -83,20 +83,20 @@ func (s *TodosetsService) Get(ctx context.Context, todosetID int64) (result *Tod // todosetFromGenerated converts a generated Todoset to our clean Todoset type. func todosetFromGenerated(gts generated.Todoset) Todoset { ts := Todoset{ - Status: gts.Status, - VisibleToClients: gts.VisibleToClients, - Title: gts.Title, - InheritsStatus: gts.InheritsStatus, - Type: gts.Type, - URL: gts.Url, - AppURL: gts.AppUrl, - BookmarkURL: gts.BookmarkUrl, - Name: gts.Name, - TodolistsCount: int(gts.TodolistsCount), - TodolistsURL: gts.TodolistsUrl, - CompletedRatio: gts.CompletedRatio, - Completed: gts.Completed, - AppTodolistsURL: gts.AppTodolistsUrl, + Status: gts.Status, + VisibleToClients: gts.VisibleToClients, + Title: gts.Title, + InheritsStatus: gts.InheritsStatus, + Type: gts.Type, + URL: gts.Url, + AppURL: gts.AppUrl, + BookmarkURL: gts.BookmarkUrl, + Name: gts.Name, + TodolistsCount: int(gts.TodolistsCount), + TodolistsURL: gts.TodolistsUrl, + CompletedRatio: gts.CompletedRatio, + Completed: gts.Completed, + AppTodolistsURL: gts.AppTodolistsUrl, // BC5 forward-compat fields. TodosCount: int(gts.TodosCount), CompletedLooseTodosCount: int(gts.CompletedLooseTodosCount), From c2b1ed67acc16515c128fb8da0bb33dbea9a95f1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 17:17:08 -0700 Subject: [PATCH 09/26] scripts: wrapper drift check verifies field population, not just tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field-level wrapper drift check previously confirmed only that each generated JSON tag had a matching tag on the wrapper struct. A wrapper field could carry the right tag yet never be assigned by its *FromGenerated conversion function, leaving it zero-valued on the wire while the check passed. Extend the check with a population pass: for every *FromGenerated-backed pair it AST-walks the conversion body and confirms each tagged wrapper field is actually assigned (via the wrapper's composite literal or a selector-target assignment). A tag-present-but-never-assigned field is now reported as drift. Direct-decode wrappers are exempt — json.Unmarshal populates them straight from the tags, so tag presence is population. The package doc spells out the check's reachability semantics and limits. Also: - Remove the dead exclusion loop in run(): it keyed off the wrapper struct name (k+"FromGenerated") but collectFromGeneratedPairs already filters by function name, so the guard never matched. The inner filter is the only effective one. - Inject the direct-decode pair map into run() so the end-to-end tests drive the real run() entry point over their own fixtures instead of reimplementing the pipeline. Replaces the partial TestEndToEnd with run()-driven cases covering in-sync, missing-tag, tag-present-but- unassigned (the population regression), both assignment forms, and stale omit-marker detection. - Drop the unquoted cd $(CURDIR) from the go-check-wrapper-drift target so it is safe on paths with spaces; go run with a relative path needs no cd. --- Makefile | 2 +- scripts/check-wrapper-drift/main.go | 269 ++++++++++++++++++++--- scripts/check-wrapper-drift/main_test.go | 246 +++++++++++++++++---- 3 files changed, 433 insertions(+), 84 deletions(-) diff --git a/Makefile b/Makefile index 69e047317..e2acf4fde 100644 --- a/Makefile +++ b/Makefile @@ -228,7 +228,7 @@ go-check-drift: # operation-level, this one is field-level. go-check-wrapper-drift: @echo "==> Checking wrapper field-level drift..." - @cd $(CURDIR) && go run ./scripts/check-wrapper-drift/ + @go run ./scripts/check-wrapper-drift/ .PHONY: auth-routable-check diff --git a/scripts/check-wrapper-drift/main.go b/scripts/check-wrapper-drift/main.go index 5f5f357d0..6780c60d8 100644 --- a/scripts/check-wrapper-drift/main.go +++ b/scripts/check-wrapper-drift/main.go @@ -2,30 +2,30 @@ // hand-written wrappers in go/pkg/basecamp/ and the generated types in // go/pkg/generated/. // -// Discovery +// # Discovery // // The script walks (wrapper, generated) pairs in two ways: // -// 1. By signature reading every `FromGenerated(g generated.X) Y` -// function declaration in go/pkg/basecamp/*.go (non-test). The argument -// type names the generated struct; the return type names the wrapper -// struct. (`webhookPersonFromGenerated` is special-cased and excluded -// from the *FromGenerated convention check below — it is a parallel -// mapping for WebhookEventPerson, not a Person wrapper.) +// 1. By signature reading every `FromGenerated(g generated.X) Y` +// function declaration in go/pkg/basecamp/*.go (non-test). The argument +// type names the generated struct; the return type names the wrapper +// struct. (`webhookPersonFromGenerated` is special-cased and excluded +// from the *FromGenerated convention check below — it is a parallel +// mapping for WebhookEventPerson, not a Person wrapper.) // -// 2. By a small explicit `directDecodePairs` map covering wrappers that -// decode straight from JSON bytes (Notification, NotificationsResult, -// MyAssignment, Gauge, GaugeNeedle). These do not have *FromGenerated -// declarations; the JSON tags on the wrapper struct fields are what the -// JSON decoder uses to read the wire payload. +// 2. By a small explicit `directDecodePairs` map covering wrappers that +// decode straight from JSON bytes (Notification, NotificationsResult, +// MyAssignment, Gauge, GaugeNeedle). These do not have *FromGenerated +// declarations; the JSON tags on the wrapper struct fields are what the +// JSON decoder uses to read the wire payload. // -// Check +// # Check // // For each pair, the script compares JSON tag names (not Go field names — -// shape-equivalent tag collisions like wrapper `URL string \`json:"url"\`` -// vs generated `Url string \`json:"url"\`` are handled correctly because the -// match is keyed on the `json:"…"` tag value, e.g. "url"). For every JSON -// tag declared on the generated struct, the wrapper must either: +// shape-equivalent tag collisions like wrapper URL with json tag "url" vs +// generated Url with json tag "url" are handled correctly because the match +// is keyed on the json:"…" tag value, e.g. "url"). For every JSON tag +// declared on the generated struct, the wrapper must either: // // - declare a field with the same JSON tag, or // - carry an `// intentionally-omitted: - ` marker (ASCII @@ -34,6 +34,40 @@ // // If neither is present, the script reports drift and exits 1. // +// # Population check +// +// Declaring the tag is necessary but not sufficient: a wrapper field can carry +// the right JSON tag yet never be assigned by its *FromGenerated conversion +// function, so it silently stays zero-valued on the wire while the tag-presence +// check passes. For every *FromGenerated-backed pair, the script therefore also +// confirms the conversion body actually assigns each tagged wrapper field. It +// AST-walks the function body and collects assigned wrapper fields from two +// forms (see collectAssignedFields): the wrapper's own composite literal +// (`c := Card{Status: ...}`) and selector-target assignments (`c.Creator = +// ...`, `c.Steps = append(...)`). A tag-present-but-never-assigned field is +// reported as drift. +// +// Scope and limitations of the population check (verified against the current +// go/pkg/basecamp/ corpus, where every *FromGenerated follows this shape): +// +// - It is a *reachability* check, not a value check: it proves the field is +// written somewhere in the body, not that the written value is correct or +// that the assignment is unconditional. A field assigned only inside an +// `if` branch (e.g. nested Creator/Bucket pointers, which are gated on the +// generated value being non-empty) counts as populated — matching the +// wrappers' intentional "leave nil when the source is empty" semantics. +// - One level of nesting only, consistent with the tag check: a parent field +// assigned via a nested helper (`c.Creator = &creator` where `creator = +// personFromGenerated(...)`) counts because the parent field is assigned; +// the nested Person's own fields are verified through the separate +// Person ↔ generated.Person pair. +// - Direct-decode wrappers (the directDecodePairs set) are EXEMPT: they have +// no *FromGenerated body and are populated by json.Unmarshal straight onto +// the struct tags, so tag presence already is population for them. +// - A field genuinely populated by some mechanism the walker cannot see +// (none exist today) should carry an `// intentionally-omitted` marker with +// a reason, which suppresses both the tag and population checks for it. +// // The wrapper may declare additional fields not in the generated struct // (e.g. SystemLabel on Person, BillableStatus on TimesheetEntry); these are // not flagged. @@ -90,10 +124,16 @@ var markerRe = regexp.MustCompile(`intentionally-omitted:\s*([a-zA-Z0-9_]+)\s*-\ // structFields captures the JSON tag set of a struct plus the // intentionally-omitted markers associated with it. Tag is the JSON tag // (the part before any comma, e.g. "tagline" from `json:"tagline,omitempty"`). +// +// tagToGoField maps each JSON tag back to its Go field identifier (e.g. +// "tagline" -> "Tagline"). The population check (see run) uses it to translate +// the set of assigned Go fields collected from a *FromGenerated body into the +// JSON-tag space the rest of the check operates in. type structFields struct { - tags map[string]bool - omitted map[string]bool - declaration token.Pos + tags map[string]bool + omitted map[string]bool + tagToGoField map[string]string + declaration token.Pos } func main() { @@ -110,7 +150,7 @@ func main() { wrapperDir := filepath.Join(repoRoot, "go", "pkg", "basecamp") generatedFile := filepath.Join(repoRoot, "go", "pkg", "generated", "client.gen.go") - if err := run(wrapperDir, generatedFile, *verbose); err != nil { + if err := run(wrapperDir, generatedFile, directDecodePairs, *verbose); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } @@ -140,7 +180,12 @@ func resolveRoot(root string) (string, error) { } } -func run(wrapperDir, generatedFile string, verbose bool) error { +// run performs the full drift check. directDecode is injected (rather than read +// from the package global) so tests can drive run() end-to-end with their own +// fixtures without dragging in the production direct-decode pair set, whose +// generated structs would otherwise have to exist in every test fixture. +// main() passes the package-level directDecodePairs. +func run(wrapperDir, generatedFile string, directDecode map[string]string, verbose bool) error { fset := token.NewFileSet() // Parse the generated client. @@ -156,7 +201,8 @@ func run(wrapperDir, generatedFile string, verbose bool) error { return fmt.Errorf("read wrapper dir: %w", err) } wrapperStructs := map[string]*structFields{} - fromGenPairs := map[string]string{} // wrapper name -> generated name (derived from *FromGenerated signatures) + fromGenPairs := map[string]string{} // wrapper name -> generated name (derived from *FromGenerated signatures) + assignedFields := map[string]map[string]bool{} // wrapper name -> set of Go fields its *FromGenerated body assigns for _, entry := range entries { name := entry.Name() if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { @@ -170,9 +216,22 @@ func run(wrapperDir, generatedFile string, verbose bool) error { for k, v := range collectStructsAndMarkers(fset, f) { wrapperStructs[k] = v } + // collectFromGeneratedPairs already drops excluded functions by their + // function name (see excludedFromGenerated check inside it), so no + // second exclusion is needed here. Re-filtering by wrapper struct name + // would be dead code: the keys are wrapper struct names (e.g. + // WebhookEventPerson), not function names (webhookPersonFromGenerated). for k, v := range collectFromGeneratedPairs(f) { - if !excludedFromGenerated[k+"FromGenerated"] && !excludedFromGenerated[lowercaseFirst(k)+"FromGenerated"] { - fromGenPairs[k] = v + fromGenPairs[k] = v + } + for k, fields := range collectAssignedFields(f) { + set := assignedFields[k] + if set == nil { + set = map[string]bool{} + assignedFields[k] = set + } + for fn := range fields { + set[fn] = true } } } @@ -182,7 +241,7 @@ func run(wrapperDir, generatedFile string, verbose bool) error { for k, v := range fromGenPairs { pairs[k] = v } - for k, v := range directDecodePairs { + for k, v := range directDecode { pairs[k] = v } @@ -195,6 +254,7 @@ func run(wrapperDir, generatedFile string, verbose bool) error { var drift []string totalFieldsChecked := 0 + totalFieldsPopChecked := 0 for _, wrapName := range pairNames { genName := pairs[wrapName] gen := genStructs[genName] @@ -208,6 +268,13 @@ func run(wrapperDir, generatedFile string, verbose bool) error { continue } + // Direct-decode wrappers (Notification, MyAssignment, ...) have no + // *FromGenerated body: the JSON decoder populates them straight from the + // struct tags, so tag presence IS population. The population check below + // only applies to *FromGenerated-backed pairs. + _, isDirectDecode := directDecode[wrapName] + assigned := assignedFields[wrapName] + // Walk every JSON tag declared on the generated struct. tags := make([]string, 0, len(gen.tags)) for t := range gen.tags { @@ -216,22 +283,37 @@ func run(wrapperDir, generatedFile string, verbose bool) error { sort.Strings(tags) var missing []string + var unpopulated []string for _, tag := range tags { totalFieldsChecked++ - if wrap.tags[tag] { + if wrap.omitted[tag] { continue } - if wrap.omitted[tag] { + if !wrap.tags[tag] { + missing = append(missing, tag) continue } - missing = append(missing, tag) + // Tag is declared on the wrapper. For *FromGenerated-backed pairs, + // also confirm the conversion body actually assigns the field — + // otherwise a tag-present-but-unassigned field silently stays + // zero-valued while this check would otherwise pass. + if !isDirectDecode { + totalFieldsPopChecked++ + goField := wrap.tagToGoField[tag] + if goField != "" && (assigned == nil || !assigned[goField]) { + unpopulated = append(unpopulated, fmt.Sprintf("%s (field %s)", tag, goField)) + } + } } if len(missing) > 0 { drift = append(drift, fmt.Sprintf("%s ↔ generated.%s: missing JSON tags %v (add to wrapper struct or mark with `// intentionally-omitted: - `)", wrapName, genName, missing)) } + if len(unpopulated) > 0 { + drift = append(drift, fmt.Sprintf("%s ↔ generated.%s: wrapper declares these tags but %sFromGenerated never assigns them %v (assign the field in the conversion function, or mark with `// intentionally-omitted: - ` if the wrapper field is populated by some other means)", wrapName, genName, lowercaseFirst(wrapName), unpopulated)) + } if verbose { - fmt.Printf(" %s ↔ generated.%s (%d generated tags, %d wrapper tags, %d omitted)\n", - wrapName, genName, len(gen.tags), len(wrap.tags), len(wrap.omitted)) + fmt.Printf(" %s ↔ generated.%s (%d generated tags, %d wrapper tags, %d omitted, %d assigned fields, directDecode=%v)\n", + wrapName, genName, len(gen.tags), len(wrap.tags), len(wrap.omitted), len(assigned), isDirectDecode) } } @@ -252,7 +334,7 @@ func run(wrapperDir, generatedFile string, verbose bool) error { } } - fmt.Printf("Wrapper drift check: %d pairs walked, %d generated fields verified\n", len(pairNames), totalFieldsChecked) + fmt.Printf("Wrapper drift check: %d pairs walked, %d generated fields verified (%d field assignments verified in *FromGenerated bodies)\n", len(pairNames), totalFieldsChecked, totalFieldsPopChecked) if len(drift) > 0 { fmt.Fprintln(os.Stderr) @@ -261,7 +343,7 @@ func run(wrapperDir, generatedFile string, verbose bool) error { fmt.Fprintln(os.Stderr, " -", d) } fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "Fix: either propagate the generated field on the wrapper struct + the FromGenerated function, or add a comment of the form") + fmt.Fprintln(os.Stderr, "Fix: either propagate the generated field on the wrapper struct + assign it in the *FromGenerated function, or add a comment of the form") fmt.Fprintln(os.Stderr, " `// intentionally-omitted: - ` inside the wrapper struct's declaration.") return fmt.Errorf("wrapper drift: %d issue(s)", len(drift)) } @@ -292,9 +374,10 @@ func collectStructsAndMarkers(fset *token.FileSet, f *ast.File) map[string]*stru continue } sf := &structFields{ - tags: map[string]bool{}, - omitted: map[string]bool{}, - declaration: ts.Pos(), + tags: map[string]bool{}, + omitted: map[string]bool{}, + tagToGoField: map[string]string{}, + declaration: ts.Pos(), } for _, field := range st.Fields.List { if field.Tag == nil { @@ -303,6 +386,13 @@ func collectStructsAndMarkers(fset *token.FileSet, f *ast.File) map[string]*stru tagVal := field.Tag.Value if tag := extractJSONTag(tagVal); tag != "" { sf.tags[tag] = true + // Record the Go field identifier for this tag. Tagged + // fields in these structs always have exactly one name; + // if a field ever had multiple names sharing a tag, the + // last wins (still correct for membership lookups). + for _, fn := range field.Names { + sf.tagToGoField[tag] = fn.Name + } } } // Scan every comment inside the struct body for opt-out markers. @@ -366,6 +456,113 @@ func collectFromGeneratedPairs(f *ast.File) map[string]string { return out } +// collectAssignedFields walks every non-excluded *FromGenerated function in the +// file and, for each, records the set of wrapper Go fields the body actually +// assigns. Two assignment forms are recognized, which together cover every +// *FromGenerated in go/pkg/basecamp/: +// +// 1. The wrapper's own composite literal, e.g. `c := Card{Status: ..., ...}` — +// every KeyValueExpr key whose enclosing composite-literal type names the +// wrapper struct. Nested literals like `&Parent{ID: ...}` and `&Bucket{...}` +// are correctly ignored because their type identifier is Parent/Bucket, not +// the wrapper, so only the parent field (`c.Parent = ...`) counts as +// populated — matching the check's one-level-nesting termination. +// 2. Selector-target assignments, e.g. `c.ID = ...`, `c.Creator = &creator`, +// `c.Assignees = append(...)` — every AssignStmt / IncDecStmt whose LHS is a +// SelectorExpr rooted in a bare identifier. *FromGenerated bodies only ever +// write to the wrapper local (the generated param `g*` is read-only), so the +// selected field name is a wrapper field. +// +// The result maps wrapper struct name -> set of assigned Go field names. It is +// keyed on the function's *return* type, so it lines up with the wrapper-side of +// each (wrapper, generated) pair. Multiple functions returning the same wrapper +// (across files) accumulate into one set. +func collectAssignedFields(f *ast.File) map[string]map[string]bool { + out := map[string]map[string]bool{} + for _, decl := range f.Decls { + fd, ok := decl.(*ast.FuncDecl) + if !ok || fd.Recv != nil || fd.Body == nil { + continue + } + if !strings.HasSuffix(fd.Name.Name, "FromGenerated") { + continue + } + if excludedFromGenerated[fd.Name.Name] { + continue + } + if fd.Type.Results == nil || len(fd.Type.Results.List) != 1 { + continue + } + wrapper := extractLocalTypeName(fd.Type.Results.List[0].Type) + if wrapper == "" { + continue + } + assigned := out[wrapper] + if assigned == nil { + assigned = map[string]bool{} + out[wrapper] = assigned + } + ast.Inspect(fd.Body, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.CompositeLit: + // Only the wrapper's own literal contributes field names; + // nested helper literals (Parent/Bucket/...) are skipped. + if litTypeName(node.Type) != wrapper { + return true + } + for _, elt := range node.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + if key, ok := kv.Key.(*ast.Ident); ok { + assigned[key.Name] = true + } + } + case *ast.AssignStmt: + for _, lhs := range node.Lhs { + if name := selectorFieldName(lhs); name != "" { + assigned[name] = true + } + } + case *ast.IncDecStmt: + if name := selectorFieldName(node.X); name != "" { + assigned[name] = true + } + } + return true + }) + } + return out +} + +// litTypeName returns the type identifier of a composite-literal type +// expression (`Card{}` -> "Card"). Returns "" for non-identifier types +// (slices, maps, qualified types like generated.X). +func litTypeName(expr ast.Expr) string { + if expr == nil { + return "" + } + if ident, ok := expr.(*ast.Ident); ok { + return ident.Name + } + return "" +} + +// selectorFieldName returns the field name of an `x.Field` selector rooted in a +// bare identifier (`c.Creator` -> "Creator"). Returns "" for anything else +// (index expressions, deeper chains, non-selector LHS). +func selectorFieldName(expr ast.Expr) string { + sel, ok := expr.(*ast.SelectorExpr) + if !ok { + return "" + } + if _, ok := sel.X.(*ast.Ident); !ok { + return "" + } + return sel.Sel.Name +} + // extractGeneratedTypeName recognizes `generated.X` (SelectorExpr) and returns // X. Returns "" otherwise. func extractGeneratedTypeName(expr ast.Expr) string { diff --git a/scripts/check-wrapper-drift/main_test.go b/scripts/check-wrapper-drift/main_test.go index 432270555..5e1259169 100644 --- a/scripts/check-wrapper-drift/main_test.go +++ b/scripts/check-wrapper-drift/main_test.go @@ -3,6 +3,8 @@ package main import ( "go/parser" "go/token" + "os" + "path/filepath" "strings" "testing" ) @@ -129,25 +131,41 @@ func TestMarkerRegex_RequiresReason(t *testing.T) { } } -// TestEndToEnd_HappyAndDrift drives the full check against two minimal -// fixtures: one in sync, one with a missing tag and an omitted-marker hit. -func TestEndToEnd_HappyAndDrift(t *testing.T) { +// writeDriftFixtures writes a generated client file and one or more wrapper +// files into a temp tree laid out the way run() expects (a wrapper dir + a +// separate generated file path) and returns the two paths. This lets tests +// drive the real run() entry point end-to-end instead of reimplementing the +// check's internals. +func writeDriftFixtures(t *testing.T, genSrc string, wrapperSrcByName map[string]string) (wrapperDir, generatedFile string) { + t.Helper() + root := t.TempDir() + wrapperDir = filepath.Join(root, "wrappers") + if err := os.MkdirAll(wrapperDir, 0o755); err != nil { + t.Fatalf("mkdir wrappers: %v", err) + } + generatedFile = filepath.Join(root, "client.gen.go") + if err := os.WriteFile(generatedFile, []byte(genSrc), 0o644); err != nil { + t.Fatalf("write generated: %v", err) + } + for name, src := range wrapperSrcByName { + if err := os.WriteFile(filepath.Join(wrapperDir, name), []byte(src), 0o644); err != nil { + t.Fatalf("write wrapper %s: %v", name, err) + } + } + return wrapperDir, generatedFile +} + +// TestRun_InSync drives the real run() over a tree where every generated tag is +// either propagated + assigned or intentionally-omitted. run() must return nil. +func TestRun_InSync(t *testing.T) { genSrc := `package generated type Foo struct { - Id int64 ` + "`json:\"id\"`" + ` - Title string ` + "`json:\"title\"`" + ` + Id int64 ` + "`json:\"id\"`" + ` + Title string ` + "`json:\"title\"`" + ` Hidden string ` + "`json:\"hidden,omitempty\"`" + ` } - -type Bar struct { - Id int64 ` + "`json:\"id\"`" + ` - Name string ` + "`json:\"name\"`" + ` - NewField string ` + "`json:\"new_field,omitempty\"`" + ` -} ` - // Wrapper fixture: Foo is in sync (Hidden marked as intentionally-omitted), - // Bar is drifted (missing NewField). wrapperSrc := `package basecamp import "github.com/basecamp/basecamp-sdk/go/pkg/generated" @@ -159,53 +177,187 @@ type Foo struct { internalNote string } -func fooFromGenerated(g generated.Foo) Foo { return Foo{} } +func fooFromGenerated(g generated.Foo) Foo { + f := Foo{Title: g.Title} + f.ID = g.Id + return f +} +` + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"foo.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, nil, false); err != nil { + t.Errorf("run: expected no drift, got %v", err) + } +} + +// TestRun_MissingTag drives run() over a wrapper missing a generated tag with no +// marker. run() must return a drift error. +func TestRun_MissingTag(t *testing.T) { + genSrc := `package generated + +type Bar struct { + Id int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` + NewField string ` + "`json:\"new_field,omitempty\"`" + ` +} +` + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" type Bar struct { ID int64 ` + "`json:\"id\"`" + ` Name string ` + "`json:\"name\"`" + ` } -func barFromGenerated(g generated.Bar) Bar { return Bar{} } +func barFromGenerated(g generated.Bar) Bar { + b := Bar{Name: g.Name} + b.ID = g.Id + return b +} ` - fset := token.NewFileSet() - genFile, err := parser.ParseFile(fset, "gen.go", genSrc, parser.ParseComments) - if err != nil { - t.Fatalf("parse gen: %v", err) + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"bar.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, nil, false); err == nil { + t.Error("run: expected drift on missing tag new_field, got nil") } - wrapFile, err := parser.ParseFile(fset, "wrapper.go", wrapperSrc, parser.ParseComments) - if err != nil { - t.Fatalf("parse wrap: %v", err) - } - genStructs := collectStructsAndMarkers(fset, genFile) - wrapStructs := collectStructsAndMarkers(fset, wrapFile) - pairs := collectFromGeneratedPairs(wrapFile) - - // Foo: in sync (Hidden marked as intentionally-omitted). - fooGen := genStructs["Foo"] - fooWrap := wrapStructs["Foo"] - for tag := range fooGen.tags { - if !fooWrap.tags[tag] && !fooWrap.omitted[tag] { - t.Errorf("Foo: expected tag %q to be matched or omitted, got drift", tag) - } +} + +// TestRun_TagPresentButUnassigned is the P1 regression: a wrapper that DECLARES +// the right tag but whose *FromGenerated never assigns the field must still be +// caught. This is exactly the case the tag-only check let through. +func TestRun_TagPresentButUnassigned(t *testing.T) { + genSrc := `package generated + +type Baz struct { + Id int64 ` + "`json:\"id\"`" + ` + Tagline string ` + "`json:\"tagline\"`" + ` +} +` + // Tagline carries the right tag but bazFromGenerated never assigns it. + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Baz struct { + ID int64 ` + "`json:\"id\"`" + ` + Tagline string ` + "`json:\"tagline\"`" + ` +} + +func bazFromGenerated(g generated.Baz) Baz { + b := Baz{} + b.ID = g.Id + return b +} +` + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"baz.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, nil, false); err == nil { + t.Error("run: expected population drift on unassigned Tagline, got nil") } +} - // Bar: missing new_field, no marker → drift expected. - barGen := genStructs["Bar"] - barWrap := wrapStructs["Bar"] - missing := []string{} - for tag := range barGen.tags { - if !barWrap.tags[tag] && !barWrap.omitted[tag] { - missing = append(missing, tag) - } +// TestRun_AssignedViaSelectorAndCompositeLit confirms both assignment forms the +// population walker recognizes count: a field set in the composite literal and a +// field set via a later `x.Field = ...` statement. +func TestRun_AssignedViaSelectorAndCompositeLit(t *testing.T) { + genSrc := `package generated + +type Qux struct { + Id int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` + Title string ` + "`json:\"title\"`" + ` +} +` + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Qux struct { + ID int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` + Title string ` + "`json:\"title\"`" + ` +} + +func quxFromGenerated(g generated.Qux) Qux { + q := Qux{Name: g.Name} + q.ID = g.Id + q.Title = g.Title + return q +} +` + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"qux.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, nil, false); err != nil { + t.Errorf("run: expected no drift (all fields assigned), got %v", err) } - if len(missing) != 1 || missing[0] != "new_field" { - t.Errorf("Bar: expected drift on [new_field], got %v", missing) +} + +// TestRun_OmitMarkerMismatch confirms run() flags an intentionally-omitted +// marker that names a tag the generated struct does not emit. +func TestRun_OmitMarkerMismatch(t *testing.T) { + genSrc := `package generated + +type Foo struct { + Id int64 ` + "`json:\"id\"`" + ` +} +` + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Foo struct { + ID int64 ` + "`json:\"id\"`" + ` + // intentionally-omitted: not_a_real_tag - typo that should be flagged + note string +} + +func fooFromGenerated(g generated.Foo) Foo { + f := Foo{} + f.ID = g.Id + return f +} +` + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"foo.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, nil, false); err == nil { + t.Error("run: expected drift on stale omit marker not_a_real_tag, got nil") } +} + +// TestCollectAssignedFields verifies the walker collects fields from both the +// wrapper composite literal and selector assignments, and does NOT collect keys +// from nested helper literals (Parent/Bucket) — the one-level-nesting boundary. +func TestCollectAssignedFields(t *testing.T) { + src := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" - // Sanity: pair extraction worked. - if pairs["Foo"] != "Foo" || pairs["Bar"] != "Bar" { - t.Errorf("pair extraction: got %v", pairs) +type Thing struct { + ID int64 + Status string + Parent *Parent +} + +func thingFromGenerated(g generated.Thing) Thing { + t := Thing{Status: g.Status} + t.ID = g.Id + if g.Parent.Id != 0 { + t.Parent = &Parent{ID: g.Parent.Id, Title: g.Parent.Title} + } + return t +} +` + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "wrapper.go", src, parser.ParseComments) + if err != nil { + t.Fatalf("parse: %v", err) + } + got := collectAssignedFields(f)["Thing"] + for _, want := range []string{"Status", "ID", "Parent"} { + if !got[want] { + t.Errorf("expected %q to be collected as assigned, got %v", want, got) + } + } + // Title is a key on the nested &Parent{} literal, NOT a Thing field — it + // must not leak into Thing's assigned set. + if got["Title"] { + t.Errorf("nested literal key Title must not be attributed to Thing: %v", got) } } From 3cd76081a164b8c95eaea86c0e5874932bc32fa2 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 17:17:15 -0700 Subject: [PATCH 10/26] wrappers: stop omitempty hiding false bools and zero timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omitempty on a bool drops a legitimate false, and omitempty on a value time.Time does not suppress the zero time at all — it marshals as 0001-01-01T00:00:00Z instead of being omitted. - Drop omitempty from the visible_to_clients and inherits_status bool response fields on TimesheetEntry, Inbox, Forward, and ForwardReply so a false value stays on the wire. This matches the generated structs, which carry these tags without omitempty. - Change Notification.BubbleUpAt from time.Time to *time.Time so an unset scheduled resurfacing time omits cleanly (nil) rather than emitting the zero-time sentinel. Mirrors the existing *time.Time convention for Card.CompletedAt / Todo.CompletedAt. --- go/pkg/basecamp/forwards.go | 12 ++++++------ go/pkg/basecamp/my_notifications.go | 7 +++++-- go/pkg/basecamp/timesheet.go | 4 ++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/go/pkg/basecamp/forwards.go b/go/pkg/basecamp/forwards.go index a73803833..44e6145dd 100644 --- a/go/pkg/basecamp/forwards.go +++ b/go/pkg/basecamp/forwards.go @@ -59,11 +59,11 @@ type ForwardReplyListResult struct { type Inbox struct { ID int64 `json:"id"` Status string `json:"status"` - VisibleToClients bool `json:"visible_to_clients,omitempty"` + VisibleToClients bool `json:"visible_to_clients"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Title string `json:"title"` - InheritsStatus bool `json:"inherits_status,omitempty"` + InheritsStatus bool `json:"inherits_status"` Position int `json:"position,omitempty"` Type string `json:"type"` URL string `json:"url"` @@ -79,11 +79,11 @@ type Inbox struct { type Forward struct { ID int64 `json:"id"` Status string `json:"status"` - VisibleToClients bool `json:"visible_to_clients,omitempty"` + VisibleToClients bool `json:"visible_to_clients"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Title string `json:"title,omitempty"` - InheritsStatus bool `json:"inherits_status,omitempty"` + InheritsStatus bool `json:"inherits_status"` Subject string `json:"subject"` Content string `json:"content"` From string `json:"from"` @@ -103,11 +103,11 @@ type Forward struct { type ForwardReply struct { ID int64 `json:"id"` Status string `json:"status"` - VisibleToClients bool `json:"visible_to_clients,omitempty"` + VisibleToClients bool `json:"visible_to_clients"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Title string `json:"title,omitempty"` - InheritsStatus bool `json:"inherits_status,omitempty"` + InheritsStatus bool `json:"inherits_status"` Content string `json:"content"` Type string `json:"type"` URL string `json:"url"` diff --git a/go/pkg/basecamp/my_notifications.go b/go/pkg/basecamp/my_notifications.go index a54fb5867..39ece7603 100644 --- a/go/pkg/basecamp/my_notifications.go +++ b/go/pkg/basecamp/my_notifications.go @@ -31,8 +31,11 @@ type Notification struct { // can bubble up. BubbleUpURL string `json:"bubble_up_url,omitempty"` // BubbleUpAt is the BC5-added scheduled resurfacing time when this item is - // queued as a scheduled Bubble Up. Zero when there is no scheduled time. - BubbleUpAt time.Time `json:"bubble_up_at,omitempty"` + // queued as a scheduled Bubble Up. A pointer so an absent scheduled time + // omits cleanly on the wire instead of marshaling as the zero time + // (0001-01-01T00:00:00Z); mirrors the *time.Time convention used for + // Card.CompletedAt / Todo.CompletedAt. + BubbleUpAt *time.Time `json:"bubble_up_at,omitempty"` UnreadURL string `json:"unread_url,omitempty"` SubscriptionURL string `json:"subscription_url,omitempty"` Creator *Person `json:"creator,omitempty"` diff --git a/go/pkg/basecamp/timesheet.go b/go/pkg/basecamp/timesheet.go index 743f81bb6..9bfaa1661 100644 --- a/go/pkg/basecamp/timesheet.go +++ b/go/pkg/basecamp/timesheet.go @@ -13,9 +13,9 @@ import ( type TimesheetEntry struct { ID int64 `json:"id"` Status string `json:"status,omitempty"` - VisibleToClients bool `json:"visible_to_clients,omitempty"` + VisibleToClients bool `json:"visible_to_clients"` Title string `json:"title,omitempty"` - InheritsStatus bool `json:"inherits_status,omitempty"` + InheritsStatus bool `json:"inherits_status"` Type string `json:"type,omitempty"` URL string `json:"url,omitempty"` AppURL string `json:"app_url,omitempty"` From 5092743a026a1dccef7a2b436a3cd7b45a795328 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 17:17:23 -0700 Subject: [PATCH 11/26] wrappers: tighten propagation test assertions to exact values The propagation tests checked several mapped fields only for non-empty, so a swapped field mapping would still pass. - Assert exact URLs for the Message wrapper (BookmarkURL, BoostsURL, CommentsURL, SubscriptionURL) instead of just non-empty. - Assert nested Person CreatedAt/UpdatedAt against the expected RFC3339 formatted timestamps rather than non-empty strings, catching a CreatedAt/UpdatedAt source swap. - Assert BubbleUpAt against its exact expected value (and nil for the unscheduled entry) now that it is a *time.Time, replacing the IsZero checks the pointer change broke. - Drop the unused types import and the var _ = types.Date{} that kept it alive; the test no longer references the package. --- go/pkg/basecamp/wrapper_propagation_test.go | 51 ++++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/go/pkg/basecamp/wrapper_propagation_test.go b/go/pkg/basecamp/wrapper_propagation_test.go index 2925d5982..13af90ef5 100644 --- a/go/pkg/basecamp/wrapper_propagation_test.go +++ b/go/pkg/basecamp/wrapper_propagation_test.go @@ -24,7 +24,6 @@ import ( "time" "github.com/basecamp/basecamp-sdk/go/pkg/generated" - "github.com/basecamp/basecamp-sdk/go/pkg/types" ) // ----------------------------------------------------------------------------- @@ -201,11 +200,14 @@ func assertCreatorFullyPropagated(t *testing.T, p *Person, gp generated.Person) t.Errorf("Company.Name: got %q, want %q", p.Company.Name, gp.Company.Name) } } - if p.CreatedAt == "" { - t.Error("CreatedAt: expected non-empty string from non-zero time") + // Exact timestamp comparison against the same RFC3339 format + // personFromGenerated applies — a wrong field mapping (e.g. CreatedAt + // sourced from UpdatedAt) would pass a mere non-empty check. + if want := gp.CreatedAt.Format("2006-01-02T15:04:05Z07:00"); p.CreatedAt != want { + t.Errorf("CreatedAt: got %q, want %q", p.CreatedAt, want) } - if p.UpdatedAt == "" { - t.Error("UpdatedAt: expected non-empty string from non-zero time") + if want := gp.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"); p.UpdatedAt != want { + t.Errorf("UpdatedAt: got %q, want %q", p.UpdatedAt, want) } } @@ -370,8 +372,19 @@ func TestMessageFromGenerated_PropagatesNewFields(t *testing.T) { if !w.VisibleToClients || w.Title != "M" || !w.InheritsStatus { t.Errorf("base recording-shaped fields not propagated: %+v", w) } - if w.BookmarkURL == "" || w.BoostsURL == "" || w.CommentsURL == "" || w.SubscriptionURL == "" { - t.Errorf("URL fields not propagated: %+v", w) + // Exact URL comparisons — a swapped field mapping (e.g. BookmarkURL + // sourced from BoostsUrl) passes a mere non-empty check. + if w.BookmarkURL != "https://example.com/bm" { + t.Errorf("BookmarkURL: got %q, want %q", w.BookmarkURL, "https://example.com/bm") + } + if w.BoostsURL != "https://example.com/boosts" { + t.Errorf("BoostsURL: got %q, want %q", w.BoostsURL, "https://example.com/boosts") + } + if w.CommentsURL != "https://example.com/comments" { + t.Errorf("CommentsURL: got %q, want %q", w.CommentsURL, "https://example.com/comments") + } + if w.SubscriptionURL != "https://example.com/sub" { + t.Errorf("SubscriptionURL: got %q, want %q", w.SubscriptionURL, "https://example.com/sub") } if w.CommentsCount != 7 { t.Errorf("CommentsCount: got %d", w.CommentsCount) @@ -646,8 +659,11 @@ func TestNotification_DirectDecode_PropagatesNewFields(t *testing.T) { if n.BubbleUpURL != "https://example.com/bubble" { t.Errorf("BubbleUpURL: got %q", n.BubbleUpURL) } - if n.BubbleUpAt.IsZero() { - t.Error("BubbleUpAt: expected non-zero") + wantBubbleUpAt := time.Date(2024, 2, 1, 8, 0, 0, 0, time.UTC) + if n.BubbleUpAt == nil { + t.Error("BubbleUpAt: expected non-nil") + } else if !n.BubbleUpAt.Equal(wantBubbleUpAt) { + t.Errorf("BubbleUpAt: got %v, want %v", *n.BubbleUpAt, wantBubbleUpAt) } if n.Creator == nil || n.Creator.Name != "Author" { t.Errorf("Creator: %+v", n.Creator) @@ -689,8 +705,15 @@ func TestNotificationsResult_DirectDecode_PropagatesNewFields(t *testing.T) { if len(r.ScheduledBubbleUps) != 1 || r.ScheduledBubbleUps[0].Title != "Future" { t.Errorf("ScheduledBubbleUps: %+v", r.ScheduledBubbleUps) } - if r.ScheduledBubbleUps[0].BubbleUpAt.IsZero() { - t.Error("ScheduledBubbleUps[0].BubbleUpAt: expected non-zero") + wantBubbleUpAt := time.Date(2024, 2, 1, 8, 0, 0, 0, time.UTC) + if got := r.ScheduledBubbleUps[0].BubbleUpAt; got == nil { + t.Error("ScheduledBubbleUps[0].BubbleUpAt: expected non-nil") + } else if !got.Equal(wantBubbleUpAt) { + t.Errorf("ScheduledBubbleUps[0].BubbleUpAt: got %v, want %v", *got, wantBubbleUpAt) + } + // BubbleUps entry has no scheduled time → pointer stays nil (omits cleanly). + if r.BubbleUps[0].BubbleUpAt != nil { + t.Errorf("BubbleUps[0].BubbleUpAt: expected nil, got %v", *r.BubbleUps[0].BubbleUpAt) } } @@ -766,9 +789,3 @@ func TestGaugeNeedle_DirectDecode_PropagatesNewFields(t *testing.T) { t.Errorf("Parent: %+v", n.Parent) } } - -// ----------------------------------------------------------------------------- -// types helper (silences unused-import lint when go test rebuilds) -// ----------------------------------------------------------------------------- - -var _ = types.Date{} From f6e77ee31a528b04ea465828b57c27ee27623f68 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 17:41:35 -0700 Subject: [PATCH 12/26] scripts: scope wrapper-drift population walk to the wrapper instance The population walk counted any `x.Field = ...` selector write as a wrapper-field write, so writes to helper locals (a nested Person, a WebhookDelivery, a WebhookCopy) could be miscounted as populating the wrapper and mask genuine drift when the helper local shared a field name with the wrapper. Identify the wrapper instance up front (named result, the local bound to the wrapper composite literal, and the returned identifier) and only count selector writes whose base is that instance. Also extend directDecodePairs to the nested direct-decode structs reachable from the top-level raw-body wrappers (PreviewableAttachment, and the MyAssignmentAssignee/Bucket/Parent companions). These have generated counterparts but no *FromGenerated, so the parent check previously stopped at the parent field and would miss future generated-field drift inside them; listing them carries the tag-presence check into the nested structs. Rework the pair-extraction test so the unqualified-parameter exclusion is observable: the excluded function now returns a distinct type whose absence from the pair map is a meaningful assertion. Add soundness regressions proving a helper-local selector write is not attributed to the wrapper and that run() reports drift when a tag is only assigned via such a local. --- scripts/check-wrapper-drift/main.go | 139 ++++++++++++++++++++--- scripts/check-wrapper-drift/main_test.go | 119 +++++++++++++++++-- 2 files changed, 233 insertions(+), 25 deletions(-) diff --git a/scripts/check-wrapper-drift/main.go b/scripts/check-wrapper-drift/main.go index 6780c60d8..4a6567ca1 100644 --- a/scripts/check-wrapper-drift/main.go +++ b/scripts/check-wrapper-drift/main.go @@ -15,9 +15,13 @@ // // 2. By a small explicit `directDecodePairs` map covering wrappers that // decode straight from JSON bytes (Notification, NotificationsResult, -// MyAssignment, Gauge, GaugeNeedle). These do not have *FromGenerated +// MyAssignment, Gauge, GaugeNeedle) plus the nested public wrapper structs +// reachable from them (PreviewableAttachment, MyAssignmentAssignee, +// MyAssignmentBucket, MyAssignmentParent). These do not have *FromGenerated // declarations; the JSON tags on the wrapper struct fields are what the -// JSON decoder uses to read the wire payload. +// JSON decoder uses to read the wire payload. Listing the nested structs +// explicitly carries the tag-presence check into them rather than stopping +// at the parent field. // // # Check // @@ -98,12 +102,28 @@ import ( // name for §C-2 wrappers that decode via json.Unmarshal on the (sometimes // pre-normalized) raw body. Their *FromGenerated function does not exist; // the wrapper struct's JSON tags are the contract. +// +// The set covers both the top-level raw-body wrappers AND the nested public +// wrapper structs reachable from them that have a generated counterpart but no +// *FromGenerated of their own (they are populated by the same json.Unmarshal +// pass as their parent). Listing the nested structs explicitly extends the +// tag-presence check into them; without these entries the parent check only +// verifies the parent field (e.g. previewable_attachments, assignees, bucket) +// and a future generated-field addition inside the nested struct would slip +// through. Because they are direct-decode, only the tag-presence check applies +// (json.Unmarshal populates them straight from the tags), not the population +// check. var directDecodePairs = map[string]string{ "Notification": "Notification", "NotificationsResult": "GetMyNotificationsResponseContent", "MyAssignment": "MyAssignment", "Gauge": "Gauge", "GaugeNeedle": "GaugeNeedle", + // Nested direct-decode structs (no *FromGenerated; decoded with their parent). + "PreviewableAttachment": "PreviewableAttachment", // nested in Notification.previewable_attachments + "MyAssignmentAssignee": "MyAssignmentAssignee", // nested in MyAssignment.assignees + "MyAssignmentBucket": "MyAssignmentBucket", // nested in MyAssignment.bucket + "MyAssignmentParent": "MyAssignmentParent", // nested in MyAssignment.parent } // excludedFromGenerated lists *FromGenerated functions whose argument type @@ -467,11 +487,19 @@ func collectFromGeneratedPairs(f *ast.File) map[string]string { // are correctly ignored because their type identifier is Parent/Bucket, not // the wrapper, so only the parent field (`c.Parent = ...`) counts as // populated — matching the check's one-level-nesting termination. -// 2. Selector-target assignments, e.g. `c.ID = ...`, `c.Creator = &creator`, -// `c.Assignees = append(...)` — every AssignStmt / IncDecStmt whose LHS is a -// SelectorExpr rooted in a bare identifier. *FromGenerated bodies only ever -// write to the wrapper local (the generated param `g*` is read-only), so the -// selected field name is a wrapper field. +// 2. Selector-target assignments to the wrapper instance, e.g. `c.ID = ...`, +// `c.Creator = &creator`, `c.Assignees = append(...)` — every +// AssignStmt / IncDecStmt whose LHS is a SelectorExpr rooted in the wrapper +// variable. The wrapper variable is identified up front (see +// findWrapperVars): the named result, the local the wrapper composite +// literal is bound to (`c := Card{...}`), and the operand of `return c`. +// Selector writes to any OTHER local are ignored — a *FromGenerated body +// frequently builds nested helper values via their own locals +// (`creator := personFromGenerated(...)`, `d := WebhookDelivery{...}; +// d.ID = *gd.Id`, `c := &WebhookCopy{...}; c.ID = *ge.Copy.Id`). Counting +// a `d.ID`/`c.ID` selector write as a wrapper-field write would wrongly +// mark the wrapper's same-named field populated and mask genuine drift, so +// only writes whose base identifier is the wrapper instance count. // // The result maps wrapper struct name -> set of assigned Go field names. It is // keyed on the function's *return* type, so it lines up with the wrapper-side of @@ -502,6 +530,9 @@ func collectAssignedFields(f *ast.File) map[string]map[string]bool { assigned = map[string]bool{} out[wrapper] = assigned } + // Identify the variable(s) that hold the wrapper instance this function + // builds and returns, so selector-target writes can be scoped to it. + wrapperVars := findWrapperVars(fd, wrapper) ast.Inspect(fd.Body, func(n ast.Node) bool { switch node := n.(type) { case *ast.CompositeLit: @@ -521,12 +552,12 @@ func collectAssignedFields(f *ast.File) map[string]map[string]bool { } case *ast.AssignStmt: for _, lhs := range node.Lhs { - if name := selectorFieldName(lhs); name != "" { + if base, name := selectorBaseAndField(lhs); name != "" && wrapperVars[base] { assigned[name] = true } } case *ast.IncDecStmt: - if name := selectorFieldName(node.X); name != "" { + if base, name := selectorBaseAndField(node.X); name != "" && wrapperVars[base] { assigned[name] = true } } @@ -536,6 +567,77 @@ func collectAssignedFields(f *ast.File) map[string]map[string]bool { return out } +// findWrapperVars returns the set of local identifier names that hold the +// wrapper instance a *FromGenerated function builds and returns. Selector-target +// assignments (`x.Field = ...`) only count as wrapper-field population when their +// base identifier is in this set; writes to helper locals (a nested Person, a +// WebhookDelivery, a WebhookCopy) are excluded so they cannot masquerade as +// wrapper-field writes and mask drift. +// +// Three sources, covering every shape a *FromGenerated may take: +// +// - Named result values: `func f(...) (w Wrapper)`. The result identifier is +// the wrapper instance even before any assignment. +// - The local bound to the wrapper's composite literal: `c := Card{...}` (or +// `c := &Card{...}`, or `var c Card`). This is the universal shape in the +// current corpus (`x := Wrapper{...}; ...; return x`). +// - The operand of a bare `return c`. Redundant with the composite-literal +// binding for today's code, but it keeps the var set correct if a body ever +// constructs the wrapper without a recognizable literal binding. +func findWrapperVars(fd *ast.FuncDecl, wrapper string) map[string]bool { + vars := map[string]bool{} + // Named results. + if fd.Type.Results != nil { + for _, field := range fd.Type.Results.List { + for _, name := range field.Names { + if name.Name != "" && name.Name != "_" { + vars[name.Name] = true + } + } + } + } + ast.Inspect(fd.Body, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.AssignStmt: + // `c := Wrapper{...}` / `c = Wrapper{...}` / `c := &Wrapper{...}`. + // Bind each LHS identifier whose paired RHS is a composite literal + // of the wrapper type. + if len(node.Lhs) == len(node.Rhs) { + for i, rhs := range node.Rhs { + if compositeLitTypeName(rhs) == wrapper { + if id, ok := node.Lhs[i].(*ast.Ident); ok && id.Name != "_" { + vars[id.Name] = true + } + } + } + } + case *ast.ReturnStmt: + // `return c` — the returned identifier is the wrapper instance. + for _, res := range node.Results { + if id, ok := res.(*ast.Ident); ok && id.Name != "_" { + vars[id.Name] = true + } + } + } + return true + }) + return vars +} + +// compositeLitTypeName returns the wrapper-type name of a composite-literal +// expression, transparently unwrapping a leading address-of (`&Wrapper{}`). +// Returns "" for anything that is not a bare-identifier-typed composite literal. +func compositeLitTypeName(expr ast.Expr) string { + if u, ok := expr.(*ast.UnaryExpr); ok && u.Op == token.AND { + expr = u.X + } + cl, ok := expr.(*ast.CompositeLit) + if !ok { + return "" + } + return litTypeName(cl.Type) +} + // litTypeName returns the type identifier of a composite-literal type // expression (`Card{}` -> "Card"). Returns "" for non-identifier types // (slices, maps, qualified types like generated.X). @@ -549,18 +651,21 @@ func litTypeName(expr ast.Expr) string { return "" } -// selectorFieldName returns the field name of an `x.Field` selector rooted in a -// bare identifier (`c.Creator` -> "Creator"). Returns "" for anything else -// (index expressions, deeper chains, non-selector LHS). -func selectorFieldName(expr ast.Expr) string { +// selectorBaseAndField decomposes an `x.Field` selector rooted in a bare +// identifier into its base identifier and field name (`c.Creator` -> "c", +// "Creator"). Returns "", "" for anything else (index expressions, deeper +// chains like `a.b.c`, non-selector expressions). The base lets callers scope +// the write to a known wrapper variable. +func selectorBaseAndField(expr ast.Expr) (base, field string) { sel, ok := expr.(*ast.SelectorExpr) if !ok { - return "" + return "", "" } - if _, ok := sel.X.(*ast.Ident); !ok { - return "" + ident, ok := sel.X.(*ast.Ident) + if !ok { + return "", "" } - return sel.Sel.Name + return ident.Name, sel.Sel.Name } // extractGeneratedTypeName recognizes `generated.X` (SelectorExpr) and returns diff --git a/scripts/check-wrapper-drift/main_test.go b/scripts/check-wrapper-drift/main_test.go index 5e1259169..4e6ef8ee8 100644 --- a/scripts/check-wrapper-drift/main_test.go +++ b/scripts/check-wrapper-drift/main_test.go @@ -65,18 +65,23 @@ type Wrapper struct { } func TestCollectFromGeneratedPairs(t *testing.T) { + // Each exclusion case uses a DISTINCT return type so its absence from the + // pair map is a meaningful assertion: a regression that started accepting the + // excluded shape would surface that type as a key. (The previous version + // asserted on "Foo", a type no fixture produced, so it could never fail.) src := `package fixture import "generated" -// barFromGenerated maps generated.Bar to Bar. +// barFromGenerated maps generated.Bar to Bar. This is the one valid pair. func barFromGenerated(g generated.Bar) Bar { return Bar{} } -// receiverFnFromGenerated is a method, must be skipped. -func (s *Service) receiverFnFromGenerated(g generated.X) X { return X{} } +// receiverFnFromGenerated is a method returning Recv, must be skipped. +func (s *Service) receiverFnFromGenerated(g generated.Recv) Recv { return Recv{} } -// noGeneratedPrefix is missing the generated. qualifier on the param, skipped. -func wrongParamFromGenerated(g Bar) Bar { return Bar{} } +// unqualifiedParamFromGenerated has an unqualified (non-generated.X) param and +// returns the distinct type Unqualified, so its exclusion is observable. +func unqualifiedParamFromGenerated(g Unqualified) Unqualified { return Unqualified{} } ` fset := token.NewFileSet() f, err := parser.ParseFile(fset, "fixture.go", src, parser.ParseComments) @@ -87,11 +92,15 @@ func wrongParamFromGenerated(g Bar) Bar { return Bar{} } if got := pairs["Bar"]; got != "Bar" { t.Errorf("expected Bar -> Bar pair, got %q", got) } - if _, ok := pairs["X"]; ok { + if _, ok := pairs["Recv"]; ok { t.Error("method receiver fn must be excluded from pair extraction") } - if _, ok := pairs["Foo"]; ok { - t.Error("non-generated-prefixed param must be excluded") + if _, ok := pairs["Unqualified"]; ok { + t.Error("function with a non-generated.X param must be excluded from pair extraction") + } + // The only pair must be Bar; nothing leaked from the excluded shapes. + if len(pairs) != 1 { + t.Errorf("expected exactly one pair (Bar), got %d: %v", len(pairs), pairs) } } @@ -254,6 +263,50 @@ func bazFromGenerated(g generated.Baz) Baz { } } +// TestRun_HelperLocalDoesNotMaskDrift is the end-to-end soundness regression for +// the scoped population walk. The wrapper declares the `name` tag but its +// *FromGenerated never assigns the wrapper's Name field — it only writes +// `child.Name` on a helper local that happens to share the field name. The old +// broad walk attributed `child.Name` to the wrapper and let this pass; the +// scoped walk must report `name` as unpopulated drift. +func TestRun_HelperLocalDoesNotMaskDrift(t *testing.T) { + genSrc := `package generated + +type Wrap struct { + Id int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` +} +` + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Child struct { + Name string ` + "`json:\"name\"`" + ` +} + +type Wrap struct { + ID int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` + Child *Child ` + "`json:\"child,omitempty\"`" + ` +} + +func wrapFromGenerated(g generated.Wrap) Wrap { + w := Wrap{} + w.ID = g.Id + // Only the helper local's Name is written, never w.Name. + child := Child{} + child.Name = g.Name + w.Child = &child + return w +} +` + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"wrap.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, nil, false); err == nil { + t.Error("run: expected population drift on Wrap.Name (only a helper local assigns name), got nil") + } +} + // TestRun_AssignedViaSelectorAndCompositeLit confirms both assignment forms the // population walker recognizes count: a field set in the composite literal and a // field set via a later `x.Field = ...` statement. @@ -361,6 +414,56 @@ func thingFromGenerated(g generated.Thing) Thing { } } +// TestCollectAssignedFields_HelperLocalSelectorExcluded is the soundness +// regression for the scoped population walk. A *FromGenerated body routinely +// builds a nested helper value via its own local and writes that local's fields +// by selector (here `child.Name = ...` on a `child := Child{}`). Those writes +// must NOT be attributed to the wrapper, even when the helper local shares a +// field name with the wrapper (`Name`). Under the old broad walk — which +// counted every `x.Field = ...` regardless of base — `Name` would be falsely +// marked assigned on the wrapper, masking the fact that the wrapper itself never +// assigns it. +func TestCollectAssignedFields_HelperLocalSelectorExcluded(t *testing.T) { + src := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Wrap struct { + ID int64 + Name string + Child *Child +} + +func wrapFromGenerated(g generated.Wrap) Wrap { + w := Wrap{} + w.ID = g.Id + // Helper local of a different type; its Name field shares the wrapper's + // field name but must not count toward the wrapper. + child := Child{} + child.Name = g.Child.Name + w.Child = &child + return w +} +` + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "wrapper.go", src, parser.ParseComments) + if err != nil { + t.Fatalf("parse: %v", err) + } + got := collectAssignedFields(f)["Wrap"] + if !got["ID"] { + t.Errorf("expected ID (written on the wrapper var) to be collected, got %v", got) + } + if !got["Child"] { + t.Errorf("expected Child (written on the wrapper var) to be collected, got %v", got) + } + // The wrapper never assigns its own Name; only the helper local does. The + // scoped walk must not attribute the helper-local write to the wrapper. + if got["Name"] { + t.Errorf("helper-local selector write (child.Name) must not count as wrapper Wrap.Name: %v", got) + } +} + // TestExcludedFromGenerated verifies that the special-cased mapping // (webhookPersonFromGenerated → WebhookEventPerson, NOT Person) is skipped // during automatic pair discovery so the drift check doesn't double-count From b07a0af42553d8db3d692fddaebbace626999f4d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 17:41:42 -0700 Subject: [PATCH 13/26] wrappers: preserve flexible notification person ids on decode Exposing Notification.Creator/Participants (this branch's change) routes notification people through Person.ID, a plain int64. BC3 serializes some notification person ids as JSON strings, and those person objects often omit personable_type, so the existing normalizePersonIds pass (keyed on personable_type) skipped them and the decode failed with a string-into-int64 unmarshal error. Add normalizeNotificationsJSON for the /my/readings.json path: it runs the generic person normalization plus a creator/participants pass that coerces string ids by their structural position regardless of personable_type, reusing the shared id rule (numeric string to json.Number, non-numeric sentinel to 0 with the label preserved as system_label, overflow left for the decoder to reject). Add a decode test covering a string-id creator and participants without personable_type, including the sentinel case. --- go/pkg/basecamp/my_notifications.go | 2 +- go/pkg/basecamp/my_notifications_test.go | 62 +++++++++++++ go/pkg/basecamp/normalize.go | 109 +++++++++++++++++++---- go/pkg/basecamp/todos.go | 9 +- 4 files changed, 162 insertions(+), 20 deletions(-) diff --git a/go/pkg/basecamp/my_notifications.go b/go/pkg/basecamp/my_notifications.go index 39ece7603..3a63967e4 100644 --- a/go/pkg/basecamp/my_notifications.go +++ b/go/pkg/basecamp/my_notifications.go @@ -115,7 +115,7 @@ func (s *MyNotificationsService) Get(ctx context.Context, page int32) (result *N return nil, err } - normalized, normalizeErr := normalizeJSON(resp.Body) + normalized, normalizeErr := normalizeNotificationsJSON(resp.Body) if normalizeErr != nil { normalized = resp.Body // fallback to raw } diff --git a/go/pkg/basecamp/my_notifications_test.go b/go/pkg/basecamp/my_notifications_test.go index 12ca115e1..72f458695 100644 --- a/go/pkg/basecamp/my_notifications_test.go +++ b/go/pkg/basecamp/my_notifications_test.go @@ -114,6 +114,68 @@ func TestMyNotificationsService_Get_SentinelCreatorID(t *testing.T) { } } +func TestMyNotificationsService_Get_StringCreatorIDWithoutPersonableType(t *testing.T) { + // BC3 sometimes serializes a real person's notification creator/participants + // id as a JSON string ("1049715914") on an object that has no + // personable_type. Notification.Creator/Participants decode into Person.ID + // (a plain int64), which cannot unmarshal a JSON string, so the whole Get + // would fail. normalizeNotificationsJSON coerces these ids by their + // creator/participants position even without personable_type. + svc := testMyNotificationsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{ + "unreads": [{ + "id": 7, + "title": "Comment", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + "creator": { + "id": "1049715914", + "name": "Jane" + }, + "participants": [ + {"id": "2000000001", "name": "P1"}, + {"id": "basecamp", "name": "System"} + ] + }], + "reads": [], + "memories": [] + }`)) + }) + + result, err := svc.Get(context.Background(), 0) + if err != nil { + t.Fatalf("unexpected error (string creator.id without personable_type should not crash): %v", err) + } + if len(result.Unreads) != 1 { + t.Fatalf("expected 1 unread, got %d", len(result.Unreads)) + } + n := result.Unreads[0] + if n.Creator == nil { + t.Fatal("expected Creator to be populated") + } + if n.Creator.ID != 1049715914 { + t.Errorf("expected creator.id 1049715914, got %d", n.Creator.ID) + } + if n.Creator.Name != "Jane" { + t.Errorf("expected creator name Jane, got %q", n.Creator.Name) + } + if len(n.Participants) != 2 { + t.Fatalf("expected 2 participants, got %d", len(n.Participants)) + } + if n.Participants[0].ID != 2000000001 { + t.Errorf("expected participant[0].id 2000000001, got %d", n.Participants[0].ID) + } + // The sentinel participant collapses to 0 with the label preserved. + if n.Participants[1].ID != 0 { + t.Errorf("expected sentinel participant[1].id to normalize to 0, got %d", n.Participants[1].ID) + } + if n.Participants[1].SystemLabel != "basecamp" { + t.Errorf("expected participant[1].system_label %q, got %q", "basecamp", n.Participants[1].SystemLabel) + } +} + func TestMyNotificationsService_MarkAsRead(t *testing.T) { var receivedBody map[string]any svc := testMyNotificationsServer(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/go/pkg/basecamp/normalize.go b/go/pkg/basecamp/normalize.go index 376ba88e3..f4f247746 100644 --- a/go/pkg/basecamp/normalize.go +++ b/go/pkg/basecamp/normalize.go @@ -17,22 +17,7 @@ func normalizePersonIds(v any) { switch val := v.(type) { case map[string]any: if _, hasPT := val["personable_type"]; hasPT { - if idStr, ok := val["id"].(string); ok { - n, err := strconv.ParseInt(idStr, 10, 64) - if err == nil { - val["id"] = json.Number(idStr) // preserve as json.Number - } else { - var numErr *strconv.NumError - if errors.As(err, &numErr) && numErr.Err == strconv.ErrRange { - // Numeric overflow — leave as string, let decoder reject - } else { - // Non-numeric sentinel - val["system_label"] = idStr - val["id"] = json.Number("0") - } - } - _ = n // used only for parse check - } + coercePersonID(val) } for _, child := range val { normalizePersonIds(child) @@ -44,6 +29,68 @@ func normalizePersonIds(v any) { } } +// coercePersonID rewrites a Person-shaped object's string "id" in place to the +// numeric form the wrapper Person.ID (a plain int64) can decode. A numeric +// string ("12345") becomes a json.Number; a non-numeric sentinel ("basecamp") +// collapses to 0 with the original label preserved as "system_label"; a numeric +// overflow string is left untouched so the decoder rejects it. Objects whose id +// is already a number (or absent) are left unchanged. This is the shared id +// rule used by both the personable_type-keyed pass and the notification +// creator/participants pass. +func coercePersonID(obj map[string]any) { + idStr, ok := obj["id"].(string) + if !ok { + return + } + if _, err := strconv.ParseInt(idStr, 10, 64); err == nil { + obj["id"] = json.Number(idStr) // numeric string — preserve as json.Number + return + } else { + var numErr *strconv.NumError + if errors.As(err, &numErr) && numErr.Err == strconv.ErrRange { + return // numeric overflow — leave as string, let decoder reject + } + } + // Non-numeric sentinel (e.g. "basecamp" for system-generated entities). + obj["system_label"] = idStr + obj["id"] = json.Number("0") +} + +// normalizeNotificationPeople walks a JSON-decoded notifications payload and +// coerces the string ids of notification "creator" and "participants" people, +// regardless of whether those person objects carry a "personable_type" field. +// +// BC3 serializes notification person ids as strings (the wire-format mismatch +// FlexibleInt64 documents). The wrapper Notification.Creator/Participants decode +// into Person.ID (a plain int64), which cannot unmarshal a JSON string, so an +// un-normalized string id fails the whole decode. The generic +// normalizePersonIds pass only fires on objects that have "personable_type"; +// notification people frequently omit it, so this pass targets them by their +// known structural position (the creator object and each participants element) +// and applies the same coercePersonID rule. +func normalizeNotificationPeople(v any) { + switch val := v.(type) { + case map[string]any: + if creator, ok := val["creator"].(map[string]any); ok { + coercePersonID(creator) + } + if participants, ok := val["participants"].([]any); ok { + for _, p := range participants { + if person, ok := p.(map[string]any); ok { + coercePersonID(person) + } + } + } + for _, child := range val { + normalizeNotificationPeople(child) + } + case []any: + for _, item := range val { + normalizeNotificationPeople(item) + } + } +} + // normalizeJSON parses raw JSON, normalizes Person-shaped objects, and // re-serializes. Uses json.Number to preserve integer precision. func normalizeJSON(data []byte) ([]byte, error) { @@ -62,3 +109,33 @@ func normalizeJSON(data []byte) ([]byte, error) { normalizePersonIds(raw) return json.Marshal(raw) } + +// normalizeNotificationsJSON normalizes a /my/readings.json payload before it is +// decoded into NotificationsResult. It applies both the generic +// personable_type-keyed person normalization AND the notification-specific +// creator/participants pass, so notification people with string ids decode into +// Notification.Creator/Participants (Person.ID is a plain int64) even when those +// person objects omit "personable_type". +// +// Unlike normalizeJSON it short-circuits only when the body contains neither +// "creator" nor "participants" nor "personable_type" — a notification person id +// can be a string without any "personable_type" appearing in the body, so the +// personable_type-only guard would skip the very payloads this exists to fix. +func normalizeNotificationsJSON(data []byte) ([]byte, error) { + if !bytes.Contains(data, []byte(`"personable_type"`)) && + !bytes.Contains(data, []byte(`"creator"`)) && + !bytes.Contains(data, []byte(`"participants"`)) { + return data, nil + } + + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + + var raw any + if err := dec.Decode(&raw); err != nil { + return data, err + } + normalizePersonIds(raw) + normalizeNotificationPeople(raw) + return json.Marshal(raw) +} diff --git a/go/pkg/basecamp/todos.go b/go/pkg/basecamp/todos.go index 78156cccc..e96499f7c 100644 --- a/go/pkg/basecamp/todos.go +++ b/go/pkg/basecamp/todos.go @@ -60,9 +60,12 @@ type Todo struct { // Person represents a Basecamp user or system actor. // For system actors (personable_type "LocalPerson"), ID is 0. SystemLabel // holds the original non-numeric identifier (e.g. "basecamp") when the -// response is processed through normalizeJSON (currently notifications). -// Endpoints that decode through the generated parser lose the label — -// use PersonableType == "LocalPerson" as the authoritative discriminator. +// response is processed through the normalize pass (notifications route +// through normalizeNotificationsJSON, which also coerces string-valued +// creator/participants ids that lack personable_type so they decode into +// Person.ID). Endpoints that decode through the generated parser lose the +// label — use PersonableType == "LocalPerson" as the authoritative +// discriminator. type Person struct { ID int64 `json:"id"` SystemLabel string `json:"system_label,omitempty"` From ef4fcced0d12177ef3cca363dd44ecd4f0232df8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 17:52:51 -0700 Subject: [PATCH 14/26] wrappers: preserve flexible gauge person ids; share embedded-person normalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gauge and GaugeNeedle gained Creator *Person on this branch but decode raw via json.Unmarshal, so a string-valued creator.id (BC3 wire format) failed the whole decode against Person.ID (a plain int64) — the same regression just fixed for notifications. Generalize the notification-specific pass into a shared one: normalizeNotificationPeople becomes normalizeEmbeddedPersonIds and normalizeNotificationsJSON becomes normalizeEmbeddedPeopleJSON, coercing string ids on embedded creator/participants people regardless of personable_type. Route the gauge service decode sites through a decodeGaugePayload helper that normalizes first, alongside the existing notifications caller. Add a gauge decode test for a string creator id with and without personable_type. --- go/pkg/basecamp/gauges.go | 28 +++++++++--- go/pkg/basecamp/my_notifications.go | 2 +- go/pkg/basecamp/normalize.go | 49 +++++++++++---------- go/pkg/basecamp/todos.go | 4 +- go/pkg/basecamp/wrapper_propagation_test.go | 41 +++++++++++++++++ 5 files changed, 90 insertions(+), 34 deletions(-) diff --git a/go/pkg/basecamp/gauges.go b/go/pkg/basecamp/gauges.go index 05f04fabb..df6d94ab7 100644 --- a/go/pkg/basecamp/gauges.go +++ b/go/pkg/basecamp/gauges.go @@ -87,6 +87,20 @@ func NewGaugesService(client *AccountClient) *GaugesService { return &GaugesService{client: client} } +// decodeGaugePayload normalizes a raw gauge/needle response body and unmarshals +// it onto v. Gauge and GaugeNeedle embed *Person under "creator", and BC3 may +// serialize a person id as a JSON string; Person.ID is a plain int64, so the +// body is run through normalizeEmbeddedPeopleJSON first to coerce those ids. +// Normalization failures fall back to the raw body so a malformed-but-decodable +// payload is not lost. +func decodeGaugePayload(data []byte, v any) error { + normalized, normalizeErr := normalizeEmbeddedPeopleJSON(data) + if normalizeErr != nil { + normalized = data + } + return json.Unmarshal(normalized, v) +} + // List returns all gauges for the account, following pagination automatically. func (s *GaugesService) List(ctx context.Context) (result []Gauge, err error) { op := OperationInfo{ @@ -111,7 +125,7 @@ func (s *GaugesService) List(ctx context.Context) (result []Gauge, err error) { } var gauges []Gauge - if err = json.Unmarshal(resp.Body, &gauges); err != nil { + if err = decodeGaugePayload(resp.Body, &gauges); err != nil { return nil, fmt.Errorf("failed to parse gauges: %w", err) } @@ -122,7 +136,7 @@ func (s *GaugesService) List(ctx context.Context) (result []Gauge, err error) { } for _, raw := range rawMore { var g Gauge - if err = json.Unmarshal(raw, &g); err != nil { + if err = decodeGaugePayload(raw, &g); err != nil { return nil, fmt.Errorf("failed to parse gauge: %w", err) } gauges = append(gauges, g) @@ -155,7 +169,7 @@ func (s *GaugesService) ListNeedles(ctx context.Context, projectID int64) (resul } var needles []GaugeNeedle - if err = json.Unmarshal(resp.Body, &needles); err != nil { + if err = decodeGaugePayload(resp.Body, &needles); err != nil { return nil, fmt.Errorf("failed to parse gauge needles: %w", err) } @@ -166,7 +180,7 @@ func (s *GaugesService) ListNeedles(ctx context.Context, projectID int64) (resul } for _, raw := range rawMore { var n GaugeNeedle - if err = json.Unmarshal(raw, &n); err != nil { + if err = decodeGaugePayload(raw, &n); err != nil { return nil, fmt.Errorf("failed to parse gauge needle: %w", err) } needles = append(needles, n) @@ -200,7 +214,7 @@ func (s *GaugesService) GetNeedle(ctx context.Context, needleID int64) (result * } var needle GaugeNeedle - if err = json.Unmarshal(resp.Body, &needle); err != nil { + if err = decodeGaugePayload(resp.Body, &needle); err != nil { return nil, fmt.Errorf("failed to parse gauge needle: %w", err) } @@ -248,7 +262,7 @@ func (s *GaugesService) CreateNeedle(ctx context.Context, projectID int64, req * } var needle GaugeNeedle - if err = json.Unmarshal(resp.Body, &needle); err != nil { + if err = decodeGaugePayload(resp.Body, &needle); err != nil { return nil, fmt.Errorf("failed to parse gauge needle: %w", err) } @@ -291,7 +305,7 @@ func (s *GaugesService) UpdateNeedle(ctx context.Context, needleID int64, req *U } var needle GaugeNeedle - if err = json.Unmarshal(resp.Body, &needle); err != nil { + if err = decodeGaugePayload(resp.Body, &needle); err != nil { return nil, fmt.Errorf("failed to parse gauge needle: %w", err) } diff --git a/go/pkg/basecamp/my_notifications.go b/go/pkg/basecamp/my_notifications.go index 3a63967e4..f960a8308 100644 --- a/go/pkg/basecamp/my_notifications.go +++ b/go/pkg/basecamp/my_notifications.go @@ -115,7 +115,7 @@ func (s *MyNotificationsService) Get(ctx context.Context, page int32) (result *N return nil, err } - normalized, normalizeErr := normalizeNotificationsJSON(resp.Body) + normalized, normalizeErr := normalizeEmbeddedPeopleJSON(resp.Body) if normalizeErr != nil { normalized = resp.Body // fallback to raw } diff --git a/go/pkg/basecamp/normalize.go b/go/pkg/basecamp/normalize.go index f4f247746..38d159d56 100644 --- a/go/pkg/basecamp/normalize.go +++ b/go/pkg/basecamp/normalize.go @@ -56,19 +56,20 @@ func coercePersonID(obj map[string]any) { obj["id"] = json.Number("0") } -// normalizeNotificationPeople walks a JSON-decoded notifications payload and -// coerces the string ids of notification "creator" and "participants" people, +// normalizeEmbeddedPersonIds walks a JSON-decoded payload and coerces the string +// ids of people embedded under the well-known "creator" and "participants" keys, // regardless of whether those person objects carry a "personable_type" field. // -// BC3 serializes notification person ids as strings (the wire-format mismatch -// FlexibleInt64 documents). The wrapper Notification.Creator/Participants decode -// into Person.ID (a plain int64), which cannot unmarshal a JSON string, so an -// un-normalized string id fails the whole decode. The generic -// normalizePersonIds pass only fires on objects that have "personable_type"; -// notification people frequently omit it, so this pass targets them by their -// known structural position (the creator object and each participants element) -// and applies the same coercePersonID rule. -func normalizeNotificationPeople(v any) { +// BC3 serializes person ids as strings (the wire-format mismatch FlexibleInt64 +// documents). Wrappers that embed *Person under these keys (Notification, +// Gauge, GaugeNeedle, ...) decode into Person.ID (a plain int64), which cannot +// unmarshal a JSON string, so an un-normalized string id fails the whole decode. +// The generic normalizePersonIds pass only fires on objects that have +// "personable_type"; embedded creator/participants people frequently omit it, +// so this pass targets them by their known structural position (the creator +// object and each participants element) and applies the same coercePersonID +// rule. +func normalizeEmbeddedPersonIds(v any) { switch val := v.(type) { case map[string]any: if creator, ok := val["creator"].(map[string]any); ok { @@ -82,11 +83,11 @@ func normalizeNotificationPeople(v any) { } } for _, child := range val { - normalizeNotificationPeople(child) + normalizeEmbeddedPersonIds(child) } case []any: for _, item := range val { - normalizeNotificationPeople(item) + normalizeEmbeddedPersonIds(item) } } } @@ -110,18 +111,18 @@ func normalizeJSON(data []byte) ([]byte, error) { return json.Marshal(raw) } -// normalizeNotificationsJSON normalizes a /my/readings.json payload before it is -// decoded into NotificationsResult. It applies both the generic -// personable_type-keyed person normalization AND the notification-specific -// creator/participants pass, so notification people with string ids decode into -// Notification.Creator/Participants (Person.ID is a plain int64) even when those -// person objects omit "personable_type". +// normalizeEmbeddedPeopleJSON normalizes a raw response that embeds *Person +// under "creator"/"participants" (notifications, gauges, gauge needles) before +// it is decoded onto the wrapper. It applies both the generic +// personable_type-keyed person normalization AND the embedded creator/participants +// pass, so embedded people with string ids decode into Person.ID (a plain int64) +// even when those person objects omit "personable_type". // -// Unlike normalizeJSON it short-circuits only when the body contains neither -// "creator" nor "participants" nor "personable_type" — a notification person id -// can be a string without any "personable_type" appearing in the body, so the +// Unlike normalizeJSON it short-circuits only when the body contains none of +// "personable_type", "creator", or "participants" — an embedded person id can be +// a string without any "personable_type" appearing in the body, so the // personable_type-only guard would skip the very payloads this exists to fix. -func normalizeNotificationsJSON(data []byte) ([]byte, error) { +func normalizeEmbeddedPeopleJSON(data []byte) ([]byte, error) { if !bytes.Contains(data, []byte(`"personable_type"`)) && !bytes.Contains(data, []byte(`"creator"`)) && !bytes.Contains(data, []byte(`"participants"`)) { @@ -136,6 +137,6 @@ func normalizeNotificationsJSON(data []byte) ([]byte, error) { return data, err } normalizePersonIds(raw) - normalizeNotificationPeople(raw) + normalizeEmbeddedPersonIds(raw) return json.Marshal(raw) } diff --git a/go/pkg/basecamp/todos.go b/go/pkg/basecamp/todos.go index e96499f7c..d32a29442 100644 --- a/go/pkg/basecamp/todos.go +++ b/go/pkg/basecamp/todos.go @@ -60,8 +60,8 @@ type Todo struct { // Person represents a Basecamp user or system actor. // For system actors (personable_type "LocalPerson"), ID is 0. SystemLabel // holds the original non-numeric identifier (e.g. "basecamp") when the -// response is processed through the normalize pass (notifications route -// through normalizeNotificationsJSON, which also coerces string-valued +// response is processed through the normalize pass (notifications and gauges +// route through normalizeEmbeddedPeopleJSON, which also coerces string-valued // creator/participants ids that lack personable_type so they decode into // Person.ID). Endpoints that decode through the generated parser lose the // label — use PersonableType == "LocalPerson" as the authoritative diff --git a/go/pkg/basecamp/wrapper_propagation_test.go b/go/pkg/basecamp/wrapper_propagation_test.go index 13af90ef5..35395fbfd 100644 --- a/go/pkg/basecamp/wrapper_propagation_test.go +++ b/go/pkg/basecamp/wrapper_propagation_test.go @@ -789,3 +789,44 @@ func TestGaugeNeedle_DirectDecode_PropagatesNewFields(t *testing.T) { t.Errorf("Parent: %+v", n.Parent) } } + +// TestGauge_StringCreatorID_NormalizedDecode verifies the gauge service decode +// path (decodeGaugePayload -> normalizeEmbeddedPeopleJSON) coerces a string +// creator id with no personable_type into Gauge.Creator.ID (a plain int64). A +// raw json.Unmarshal would fail here, which is the regression the gauge Creator +// field would otherwise reintroduce. +func TestGauge_StringCreatorID_NormalizedDecode(t *testing.T) { + raw := []byte(`{ + "id": 1, + "title": "G", + "creator": {"id": "1049715914", "name": "Author"}, + "bucket": {"id": 9, "name": "Project", "type": "Project"} + }`) + var g Gauge + if err := decodeGaugePayload(raw, &g); err != nil { + t.Fatalf("decode (string creator.id without personable_type should not crash): %v", err) + } + if g.Creator == nil { + t.Fatal("expected Creator to be populated") + } + if g.Creator.ID != 1049715914 { + t.Errorf("expected creator.id 1049715914, got %d", g.Creator.ID) + } + if g.Creator.Name != "Author" { + t.Errorf("expected creator name Author, got %q", g.Creator.Name) + } + + // Sentinel id collapses to 0 with the label preserved. + rawSentinel := []byte(`{ + "id": 2, + "title": "G2", + "creator": {"id": "basecamp", "name": "Basecamp", "personable_type": "LocalPerson"} + }`) + var g2 Gauge + if err := decodeGaugePayload(rawSentinel, &g2); err != nil { + t.Fatalf("decode (sentinel creator.id should not crash): %v", err) + } + if g2.Creator == nil || g2.Creator.ID != 0 || g2.Creator.SystemLabel != "basecamp" { + t.Errorf("expected sentinel creator normalized to id 0 + system_label basecamp, got %+v", g2.Creator) + } +} From 199795bda6e7e1db6e4eee4c297b09ef0f611beb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 17:52:58 -0700 Subject: [PATCH 15/26] wrappers: drop omitempty from required visible_to_clients/inherits_status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These booleans are required (no omitempty) on generated.Todo, Comment, Message, and MessageBoard, but the wrapper fields added on this branch carried omitempty, which drops a legitimate false value when the public wrapper is marshaled. Match the generated shape so an explicit false stays on the wire — the same fix already applied to TimesheetEntry, Inbox, Forward, and ForwardReply. Todo.inherits_status already lacked omitempty, so only visible_to_clients changes there. (Gauge/GaugeNeedle keep omitempty on these tags because their generated counterparts also declare them with omitempty.) --- go/pkg/basecamp/comments.go | 4 ++-- go/pkg/basecamp/message_boards.go | 4 ++-- go/pkg/basecamp/messages.go | 4 ++-- go/pkg/basecamp/todos.go | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go/pkg/basecamp/comments.go b/go/pkg/basecamp/comments.go index ad303b187..bbe390f5b 100644 --- a/go/pkg/basecamp/comments.go +++ b/go/pkg/basecamp/comments.go @@ -16,11 +16,11 @@ const DefaultCommentLimit = 100 type Comment struct { ID int64 `json:"id"` Status string `json:"status"` - VisibleToClients bool `json:"visible_to_clients,omitempty"` + VisibleToClients bool `json:"visible_to_clients"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Title string `json:"title,omitempty"` - InheritsStatus bool `json:"inherits_status,omitempty"` + InheritsStatus bool `json:"inherits_status"` Content string `json:"content"` Type string `json:"type"` URL string `json:"url"` diff --git a/go/pkg/basecamp/message_boards.go b/go/pkg/basecamp/message_boards.go index fcd80f58a..4038e909d 100644 --- a/go/pkg/basecamp/message_boards.go +++ b/go/pkg/basecamp/message_boards.go @@ -12,9 +12,9 @@ import ( type MessageBoard struct { ID int64 `json:"id"` Status string `json:"status"` - VisibleToClients bool `json:"visible_to_clients,omitempty"` + VisibleToClients bool `json:"visible_to_clients"` Title string `json:"title"` - InheritsStatus bool `json:"inherits_status,omitempty"` + InheritsStatus bool `json:"inherits_status"` Position int `json:"position,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/go/pkg/basecamp/messages.go b/go/pkg/basecamp/messages.go index 8a515ef84..5222cca3b 100644 --- a/go/pkg/basecamp/messages.go +++ b/go/pkg/basecamp/messages.go @@ -16,13 +16,13 @@ const DefaultMessageLimit = 100 type Message struct { ID int64 `json:"id"` Status string `json:"status"` - VisibleToClients bool `json:"visible_to_clients,omitempty"` + VisibleToClients bool `json:"visible_to_clients"` Subject string `json:"subject"` Content string `json:"content"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Title string `json:"title,omitempty"` - InheritsStatus bool `json:"inherits_status,omitempty"` + InheritsStatus bool `json:"inherits_status"` Type string `json:"type"` URL string `json:"url"` AppURL string `json:"app_url"` diff --git a/go/pkg/basecamp/todos.go b/go/pkg/basecamp/todos.go index d32a29442..37636baa9 100644 --- a/go/pkg/basecamp/todos.go +++ b/go/pkg/basecamp/todos.go @@ -18,7 +18,7 @@ type Todo struct { ID int64 `json:"id"` Status string `json:"status"` VisibleTo []int64 `json:"visible_to"` - VisibleToClients bool `json:"visible_to_clients,omitempty"` + VisibleToClients bool `json:"visible_to_clients"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Title string `json:"title"` From 1faff32dfc5ae77c56c6f2e05aeeea6184a38528 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 18:00:20 -0700 Subject: [PATCH 16/26] wrappers: test required-bool marshaling; fix stale normalize name in comment Add TestRequiredBools_FalseMarshalsExplicitly covering the omitempty removal: Todo, Comment, Message, and MessageBoard must serialize visible_to_clients / inherits_status as an explicit false rather than omitting the key. The test fails if omitempty is reintroduced on either tag. Also fix a stale reference in the notification decode test comment (normalizeNotificationsJSON was renamed to normalizeEmbeddedPeopleJSON). --- go/pkg/basecamp/my_notifications_test.go | 2 +- go/pkg/basecamp/wrapper_propagation_test.go | 36 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/go/pkg/basecamp/my_notifications_test.go b/go/pkg/basecamp/my_notifications_test.go index 72f458695..84b22a695 100644 --- a/go/pkg/basecamp/my_notifications_test.go +++ b/go/pkg/basecamp/my_notifications_test.go @@ -119,7 +119,7 @@ func TestMyNotificationsService_Get_StringCreatorIDWithoutPersonableType(t *test // id as a JSON string ("1049715914") on an object that has no // personable_type. Notification.Creator/Participants decode into Person.ID // (a plain int64), which cannot unmarshal a JSON string, so the whole Get - // would fail. normalizeNotificationsJSON coerces these ids by their + // would fail. normalizeEmbeddedPeopleJSON coerces these ids by their // creator/participants position even without personable_type. svc := testMyNotificationsServer(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/go/pkg/basecamp/wrapper_propagation_test.go b/go/pkg/basecamp/wrapper_propagation_test.go index 35395fbfd..36939c851 100644 --- a/go/pkg/basecamp/wrapper_propagation_test.go +++ b/go/pkg/basecamp/wrapper_propagation_test.go @@ -830,3 +830,39 @@ func TestGauge_StringCreatorID_NormalizedDecode(t *testing.T) { t.Errorf("expected sentinel creator normalized to id 0 + system_label basecamp, got %+v", g2.Creator) } } + +// TestRequiredBools_FalseMarshalsExplicitly pins the behavioral change from +// dropping omitempty on visible_to_clients / inherits_status: a false value must +// now serialize the key rather than omit it, matching the generated structs +// where these booleans are required. Marshaling each wrapper with the fields +// left false and asserting the keys are present would fail if omitempty crept +// back onto either tag. +func TestRequiredBools_FalseMarshalsExplicitly(t *testing.T) { + hasKey := func(t *testing.T, v any, keys ...string) { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %T: %v", v, err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal %T back to map: %v", v, err) + } + for _, k := range keys { + raw, ok := m[k] + if !ok { + t.Errorf("%T marshaled without key %q (omitempty dropped a false value): %s", v, k, b) + continue + } + if string(raw) != "false" { + t.Errorf("%T key %q: expected false, got %s", v, k, raw) + } + } + } + + // Each left zero-valued (false) on purpose. + hasKey(t, Todo{}, "visible_to_clients") + hasKey(t, Comment{}, "visible_to_clients", "inherits_status") + hasKey(t, Message{}, "visible_to_clients", "inherits_status") + hasKey(t, MessageBoard{}, "visible_to_clients", "inherits_status") +} From d777bdb6c4c81a03989455358c006b5cd7a03c6e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 18:07:55 -0700 Subject: [PATCH 17/26] wrappers: clear golangci-lint findings in normalize.go The Lint CI job (golangci-lint on go/) flagged two issues the wrapper person-id normalization introduced: an else-after-return in coercePersonID (revive indent-error-flow) and the now-orphaned normalizeJSON (superseded by normalizeEmbeddedPeopleJSON, zero call sites). Lift the err decl out of the if-init and drop the else; remove the dead function and update the two comments that referenced it. golangci-lint reports 0 issues; go build/vet and go test ./pkg/basecamp/... pass. --- go/pkg/basecamp/my_notifications_test.go | 2 +- go/pkg/basecamp/normalize.go | 33 +++++------------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/go/pkg/basecamp/my_notifications_test.go b/go/pkg/basecamp/my_notifications_test.go index 84b22a695..54944790d 100644 --- a/go/pkg/basecamp/my_notifications_test.go +++ b/go/pkg/basecamp/my_notifications_test.go @@ -64,7 +64,7 @@ func TestMyNotificationsService_Get_WithPage(t *testing.T) { func TestMyNotificationsService_Get_SentinelCreatorID(t *testing.T) { // The BC3 API returns system-generated notifications with creator.id: "basecamp" - // and personable_type: "LocalPerson". normalizeJSON walks Person-shaped objects + // and personable_type: "LocalPerson". The normalize pass walks Person-shaped objects // (anything carrying personable_type) and coerces the non-numeric id to 0 while // preserving the original label as system_label. The wrapper then decodes the // resulting numeric payload into Notification.Creator without error. diff --git a/go/pkg/basecamp/normalize.go b/go/pkg/basecamp/normalize.go index 38d159d56..0ba5267e5 100644 --- a/go/pkg/basecamp/normalize.go +++ b/go/pkg/basecamp/normalize.go @@ -42,14 +42,14 @@ func coercePersonID(obj map[string]any) { if !ok { return } - if _, err := strconv.ParseInt(idStr, 10, 64); err == nil { + _, err := strconv.ParseInt(idStr, 10, 64) + if err == nil { obj["id"] = json.Number(idStr) // numeric string — preserve as json.Number return - } else { - var numErr *strconv.NumError - if errors.As(err, &numErr) && numErr.Err == strconv.ErrRange { - return // numeric overflow — leave as string, let decoder reject - } + } + var numErr *strconv.NumError + if errors.As(err, &numErr) && numErr.Err == strconv.ErrRange { + return // numeric overflow — leave as string, let decoder reject } // Non-numeric sentinel (e.g. "basecamp" for system-generated entities). obj["system_label"] = idStr @@ -92,25 +92,6 @@ func normalizeEmbeddedPersonIds(v any) { } } -// normalizeJSON parses raw JSON, normalizes Person-shaped objects, and -// re-serializes. Uses json.Number to preserve integer precision. -func normalizeJSON(data []byte) ([]byte, error) { - // Short-circuit: skip the parse/re-serialize if no Person-shaped objects - if !bytes.Contains(data, []byte(`"personable_type"`)) { - return data, nil - } - - dec := json.NewDecoder(bytes.NewReader(data)) - dec.UseNumber() - - var raw any - if err := dec.Decode(&raw); err != nil { - return data, err - } - normalizePersonIds(raw) - return json.Marshal(raw) -} - // normalizeEmbeddedPeopleJSON normalizes a raw response that embeds *Person // under "creator"/"participants" (notifications, gauges, gauge needles) before // it is decoded onto the wrapper. It applies both the generic @@ -118,7 +99,7 @@ func normalizeJSON(data []byte) ([]byte, error) { // pass, so embedded people with string ids decode into Person.ID (a plain int64) // even when those person objects omit "personable_type". // -// Unlike normalizeJSON it short-circuits only when the body contains none of +// It short-circuits only when the body contains none of // "personable_type", "creator", or "participants" — an embedded person id can be // a string without any "personable_type" appearing in the body, so the // personable_type-only guard would skip the very payloads this exists to fix. From c0eeee79566cd3b801c9a09eab45b604c8855d43 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 20:38:54 -0700 Subject: [PATCH 18/26] scripts: enumerate complete direct-decode pair set Adds 9 (wrapper, generated) pairs to directDecodePairs so the field-level drift check covers every public wrapper struct that is populated by raw json.Unmarshal rather than through a *FromGenerated function: Account -> Account AccountLogo -> AccountLogo (nested in Account.logo) AccountLimits -> AccountLimits (nested in Account.limits) AccountSettings -> AccountSettings (nested in Account.settings) AccountSubscription -> AccountSubscription (nested in Account.subscription) Preferences -> Preferences OutOfOffice -> OutOfOffice OutOfOfficePerson -> OutOfOfficePerson (nested in OutOfOffice.person) MyAssignmentsResult -> GetMyAssignmentsResponseContent Pair count moves from 55 to 64, generated fields verified from 859 to 903. Records the derivation recipe in the directDecodePairs declaration so the next contributor can rederive the set when new endpoints land: grep raw-decode call sites, identify the ones that target a hand- written wrapper (rather than a generated.X local routed through a *FromGenerated function), check the generated package for a matching counterpart, and add it plus any nested wrapper structs decoded with their parent. Webhook-flavored parallel types and non-spec endpoints (launchpad authorization, SDK provenance, error envelopes) are explicitly out of scope and listed as such. Adds TestRun_DirectDecodeRenamedPair driving run() end-to-end on the renamed-pair shape (MyAssignmentsResult vs GetMyAssignmentsResponseContent) so the runtime behavior of the broader-named pair entries is pinned. --- scripts/check-wrapper-drift/main.go | 57 ++++++++++++++++++++---- scripts/check-wrapper-drift/main_test.go | 53 ++++++++++++++++++++++ 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/scripts/check-wrapper-drift/main.go b/scripts/check-wrapper-drift/main.go index 4a6567ca1..a1410d016 100644 --- a/scripts/check-wrapper-drift/main.go +++ b/scripts/check-wrapper-drift/main.go @@ -13,15 +13,18 @@ // from the *FromGenerated convention check below — it is a parallel // mapping for WebhookEventPerson, not a Person wrapper.) // -// 2. By a small explicit `directDecodePairs` map covering wrappers that -// decode straight from JSON bytes (Notification, NotificationsResult, -// MyAssignment, Gauge, GaugeNeedle) plus the nested public wrapper structs -// reachable from them (PreviewableAttachment, MyAssignmentAssignee, -// MyAssignmentBucket, MyAssignmentParent). These do not have *FromGenerated -// declarations; the JSON tags on the wrapper struct fields are what the -// JSON decoder uses to read the wire payload. Listing the nested structs -// explicitly carries the tag-presence check into them rather than stopping -// at the parent field. +// 2. By an explicit `directDecodePairs` map covering wrappers that decode +// straight from JSON bytes (Notification, NotificationsResult, MyAssignment, +// MyAssignmentsResult, Gauge, GaugeNeedle, Account, Preferences, +// OutOfOffice) plus the nested public wrapper structs reachable from them +// (PreviewableAttachment, MyAssignmentAssignee, MyAssignmentBucket, +// MyAssignmentParent, AccountLogo, AccountLimits, AccountSettings, +// AccountSubscription, OutOfOfficePerson). These do not have +// *FromGenerated declarations; the JSON tags on the wrapper struct fields +// are what the JSON decoder uses to read the wire payload. Listing the +// nested structs explicitly carries the tag-presence check into them +// rather than stopping at the parent field. See the directDecodePairs +// declaration below for the derivation recipe that produced the list. // // # Check // @@ -113,17 +116,53 @@ import ( // through. Because they are direct-decode, only the tag-presence check applies // (json.Unmarshal populates them straight from the tags), not the population // check. +// +// # Completeness +// +// This map is intended to be the COMPLETE set of (wrapper, generated) direct- +// decode pairs as of this PR. The derivation recipe used to build it (and the +// recipe future contributors should re-run when adding endpoints): +// +// 1. Grep go/pkg/basecamp/*.go (non-test) for raw-decode call sites: +// `json.Unmarshal(... &)`, `json.NewDecoder(...).Decode(&)`, +// and decode helpers (e.g. `decodeGaugePayload`). +// 2. For each site whose target local is a hand-written WRAPPER struct (not a +// `generated.X` value routed through a `*FromGenerated` function), check +// whether a `generated.` (or close-named) counterpart exists in +// `go/pkg/generated/client.gen.go`. +// 3. If yes, add (wrapper, generated) here. Also add every nested PUBLIC +// wrapper struct reachable from it whose fields are populated by the same +// json.Unmarshal (no *FromGenerated of its own). +// +// Excluded by design: +// - WebhookEvent and its parallel webhook-flavored wrapper types +// (WebhookEventRecording / WebhookEventPerson / ...): these are a separate +// representation, not aligned 1:1 with `generated.WebhookEvent`'s nested +// `Recording` / `Person`. They follow the same precedent as +// `webhookPersonFromGenerated` (see excludedFromGenerated). +// - Local request / response envelope structs used to read upstream API +// errors, the Launchpad authorization endpoint, embedded SDK provenance, +// and the like, which are not driven by the OpenAPI spec. var directDecodePairs = map[string]string{ "Notification": "Notification", "NotificationsResult": "GetMyNotificationsResponseContent", "MyAssignment": "MyAssignment", "Gauge": "Gauge", "GaugeNeedle": "GaugeNeedle", + "Account": "Account", + "Preferences": "Preferences", + "OutOfOffice": "OutOfOffice", + "MyAssignmentsResult": "GetMyAssignmentsResponseContent", // Nested direct-decode structs (no *FromGenerated; decoded with their parent). "PreviewableAttachment": "PreviewableAttachment", // nested in Notification.previewable_attachments "MyAssignmentAssignee": "MyAssignmentAssignee", // nested in MyAssignment.assignees "MyAssignmentBucket": "MyAssignmentBucket", // nested in MyAssignment.bucket "MyAssignmentParent": "MyAssignmentParent", // nested in MyAssignment.parent + "AccountLogo": "AccountLogo", // nested in Account.logo + "AccountLimits": "AccountLimits", // nested in Account.limits + "AccountSettings": "AccountSettings", // nested in Account.settings + "AccountSubscription": "AccountSubscription", // nested in Account.subscription + "OutOfOfficePerson": "OutOfOfficePerson", // nested in OutOfOffice.person } // excludedFromGenerated lists *FromGenerated functions whose argument type diff --git a/scripts/check-wrapper-drift/main_test.go b/scripts/check-wrapper-drift/main_test.go index 4e6ef8ee8..55e2e6cc2 100644 --- a/scripts/check-wrapper-drift/main_test.go +++ b/scripts/check-wrapper-drift/main_test.go @@ -373,6 +373,59 @@ func fooFromGenerated(g generated.Foo) Foo { } } +// TestRun_DirectDecodeRenamedPair drives run() with a direct-decode pair whose +// wrapper name differs from the generated type — the shape used by the +// MyAssignmentsResult ↔ GetMyAssignmentsResponseContent and similar entries in +// the production directDecodePairs map. Two assertions matter: (1) the pair is +// walked via the injected directDecode map even with no *FromGenerated function, +// and (2) the tag-presence check fires on a missing generated tag. +func TestRun_DirectDecodeRenamedPair(t *testing.T) { + genSrc := `package generated + +type GetMyAssignmentsResponseContent struct { + NonPriorities []MyAssignment ` + "`json:\"non_priorities,omitempty\"`" + ` + Priorities []MyAssignment ` + "`json:\"priorities,omitempty\"`" + ` +} +type MyAssignment struct { + Id int64 ` + "`json:\"id\"`" + ` +} +` + // Wrapper has both tags — clean run. + wrapperOK := `package basecamp + +type MyAssignment struct { + ID int64 ` + "`json:\"id\"`" + ` +} +type MyAssignmentsResult struct { + Priorities []MyAssignment ` + "`json:\"priorities,omitempty\"`" + ` + NonPriorities []MyAssignment ` + "`json:\"non_priorities,omitempty\"`" + ` +} +` + pairs := map[string]string{ + "MyAssignmentsResult": "GetMyAssignmentsResponseContent", + "MyAssignment": "MyAssignment", + } + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"my_assignments.go": wrapperOK}) + if err := run(wrapperDir, generatedFile, pairs, false); err != nil { + t.Errorf("run (in-sync renamed direct-decode pair): expected no drift, got %v", err) + } + + // Wrapper drops the non_priorities tag with no marker — drift expected. + wrapperMissing := `package basecamp + +type MyAssignment struct { + ID int64 ` + "`json:\"id\"`" + ` +} +type MyAssignmentsResult struct { + Priorities []MyAssignment ` + "`json:\"priorities,omitempty\"`" + ` +} +` + wrapperDir, generatedFile = writeDriftFixtures(t, genSrc, map[string]string{"my_assignments.go": wrapperMissing}) + if err := run(wrapperDir, generatedFile, pairs, false); err == nil { + t.Error("run (renamed direct-decode pair missing non_priorities): expected drift, got nil") + } +} + // TestCollectAssignedFields verifies the walker collects fields from both the // wrapper composite literal and selector assignments, and does NOT collect keys // from nested helper literals (Parent/Bucket) — the one-level-nesting boundary. From 0f550b5cb7f07966ede8402fe2e322e80b9f9538 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 20:39:02 -0700 Subject: [PATCH 19/26] wrappers: route subscription subscribers through personFromGenerated subscriptionFromGenerated still inlined a 6-field shallow Person copy when building Subscription.Subscribers, so the BC5 Tagline plus the Bio / Location / Title / PersonableType / AttachableSGID / access flags / Company / timestamps that the rest of the wrapper layer standardized on personFromGenerated never reached subscription responses. Routes the conversion through personFromGenerated to match every other nested-Person context, and extends TestPerson_NewFields_NestedPropagation with a subscription.Subscribers subtest so the propagation is pinned the same way it is for recording/comment/message/todo/schedule_entry/card. --- go/pkg/basecamp/subscriptions.go | 12 +----------- go/pkg/basecamp/wrapper_propagation_test.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/go/pkg/basecamp/subscriptions.go b/go/pkg/basecamp/subscriptions.go index fd67a7d00..abfd32abe 100644 --- a/go/pkg/basecamp/subscriptions.go +++ b/go/pkg/basecamp/subscriptions.go @@ -180,17 +180,7 @@ func subscriptionFromGenerated(gs generated.Subscription) Subscription { if len(gs.Subscribers) > 0 { s.Subscribers = make([]Person, 0, len(gs.Subscribers)) for _, gp := range gs.Subscribers { - p := Person{ - Name: gp.Name, - EmailAddress: gp.EmailAddress, - AvatarURL: gp.AvatarUrl, - Admin: gp.Admin, - Owner: gp.Owner, - } - if gp.Id != 0 { - p.ID = int64(gp.Id) - } - s.Subscribers = append(s.Subscribers, p) + s.Subscribers = append(s.Subscribers, personFromGenerated(gp)) } } diff --git a/go/pkg/basecamp/wrapper_propagation_test.go b/go/pkg/basecamp/wrapper_propagation_test.go index 36939c851..704e4e2fa 100644 --- a/go/pkg/basecamp/wrapper_propagation_test.go +++ b/go/pkg/basecamp/wrapper_propagation_test.go @@ -122,6 +122,20 @@ func TestPerson_NewFields_NestedPropagation(t *testing.T) { } assertCreatorFullyPropagated(t, &w.Assignees[0], fullCreator) }) + + t.Run("subscription.Subscribers", func(t *testing.T) { + gs := generated.Subscription{ + Subscribed: true, + Count: 1, + Url: "https://example.com/sub", + Subscribers: []generated.Person{fullCreator}, + } + w := subscriptionFromGenerated(gs) + if len(w.Subscribers) != 1 { + t.Fatalf("expected 1 subscriber, got %d", len(w.Subscribers)) + } + assertCreatorFullyPropagated(t, &w.Subscribers[0], fullCreator) + }) } func assertCreatorFullyPropagated(t *testing.T, p *Person, gp generated.Person) { From a85f5f47de19818d71725d072c30bb8a3db70281 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 27 May 2026 20:39:14 -0700 Subject: [PATCH 20/26] docs(brief): align omit-marker syntax + BubbleUpAt pointer choice Two edits keep the brief consistent with the shipped wrapper API and drift-check implementation: * The omit-marker bullet documented a free-form 'intentionally not surfaced because ' phrasing that the drift check does not recognize. Rewritten to spell out the only syntax the check honors - 'intentionally-omitted: - ' with the JSON tag on the generated struct - and pointing readers at scripts/check- wrapper-drift's markerRe for the canonical regex. * The Notification.BubbleUpAt proposal still said time.Time and pointed at ReadAt/UnreadAt for the zero-value pattern. The shipped wrapper (commit 4ec355ee) is *time.Time so an absent scheduled bubble-up omits the wire key cleanly instead of marshaling as 0001-01-01T00:00:00Z (Go's omitempty does not suppress zero-valued time.Time). Brief now documents the *time.Time choice, names the Card.CompletedAt / Todo.CompletedAt precedent, and tells consumers to nil-check rather than IsZero. --- go/BRIEF-bc5-forward-compat-wrappers.md | 32 +++++++++++++++++-------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/go/BRIEF-bc5-forward-compat-wrappers.md b/go/BRIEF-bc5-forward-compat-wrappers.md index 8043455a5..ca047b512 100644 --- a/go/BRIEF-bc5-forward-compat-wrappers.md +++ b/go/BRIEF-bc5-forward-compat-wrappers.md @@ -78,11 +78,15 @@ For each generated field added in PR 1, do exactly one of: semantic meaning, JSON tag matching the wire format, and propagate it inside the relevant `*FromGenerated` conversion. Default to this for fields the CLI / external consumers will want to read. -2. **Document the omission inline** with a `// intentionally not - surfaced because ` comment on the wrapper struct, if a field - is genuinely not appropriate for the public Go surface (e.g. an - internal echo). Phase 1 fields should not need this — they're all - user-visible. +2. **Document the omission inline** with an `// intentionally-omitted: + - ` comment anywhere inside the wrapper struct's + declaration block, if a field is genuinely not appropriate for the + public Go surface (e.g. an internal echo). `` is the JSON tag of + the omitted field on the GENERATED struct (e.g. `hidden`, not + `Hidden`); `` is required and free-form. The drift check + (`scripts/check-wrapper-drift`) recognises only this exact marker + form — see its `markerRe` regexp for the canonical syntax. Phase 1 + fields should not need this — they're all user-visible. Per-type concrete proposals (signatures, no behavior change for existing fields): @@ -129,8 +133,8 @@ type Person struct { // pkg/basecamp/my_notifications.go type Notification struct { // … existing fields … - BubbleUpURL string `json:"bubble_up_url,omitempty"` // BC5 - BubbleUpAt time.Time `json:"bubble_up_at,omitempty"` // BC5 + BubbleUpURL string `json:"bubble_up_url,omitempty"` // BC5 + BubbleUpAt *time.Time `json:"bubble_up_at,omitempty"` // BC5 } type NotificationsResult struct { @@ -140,9 +144,17 @@ type NotificationsResult struct { } ``` -For `Notification.BubbleUpAt`, follow the same `time.Time` zero-value -pattern the wrapper already uses for `ReadAt` / `UnreadAt` — the CLI -checks `IsZero()` on those today. +For `Notification.BubbleUpAt`, use `*time.Time` rather than the bare +`time.Time` pattern the wrapper uses for `ReadAt` / `UnreadAt`. The +pointer is what shipped (see `my_notifications.go`): a bare `time.Time` +with `omitempty` still marshals the zero time as +`"0001-01-01T00:00:00Z"` (Go's `omitempty` does not suppress structs +that are merely zero-valued), so the absence of a scheduled bubble-up +would surface as a wrong wire value rather than an omitted key. The +`*time.Time` convention mirrors `Card.CompletedAt` and `Todo.CompletedAt`, +where the same omit-cleanly semantics matter. Consumers should +`nil`-check or use the `time.Time` returned by dereferencing rather than +calling `IsZero()`. ## Verification From 7ae201b06c35a8675896a31b5e95f9f84238af9e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 28 May 2026 08:56:19 -0700 Subject: [PATCH 21/26] scripts: extend wrapper-drift map with inline-converted tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directDecodePairs map already covered wrappers populated via raw json.Unmarshal (Notification, MyAssignment, ...). It did not cover a third category: wrappers with a generated counterpart and no *FromGenerated of their own that are populated inline via composite literal inside a parent *FromGenerated body or a service method. Codex named CampfireLineAttachment (built in campfireLineFromGenerated) and EventDetails (built in eventFromGenerated). A wider scan of go/pkg/basecamp/*.go intersected against go/pkg/generated/client.gen.go turned up 12 such pairs. If BC5 adds a field to one of these generated structs, the wrapper would silently drop the new data while the drift check would pass — exactly the regression the check exists to catch. Reorganize the map into three explicitly labeled tiers in one tag- presence check so future contributors see the coverage as a single surface: - Tier 1: *FromGenerated-backed (auto-discovered, both tag-presence and population checks) - Tier 2: direct-decode via json.Unmarshal (tag-presence only; the decoder populates from tags) - Tier 3: inline-converted via composite literal (tag-presence only; population enforced by review of the composite at the call site) Rewrite the leading comment block to document the tier model, restate the derivation recipe (re-scan all wrappers, intersect with generated, subtract tier-1 and design exclusions, classify what is left), and call out the out-of-scope list (webhook-flavored parallel types, non-spec envelopes, outgoing request wrappers with name-clash to generated). Extend the e2e test to cover a tier-3 pair end-to-end via TestRun_InlineConvertedPair: confirms the pair is walked via the injected map and the tag-presence check fires when the inline-converted wrapper drops a tag the generated struct still emits. Total pairs walked: 76 (was 64). Conformance-go: 68/0/0 unchanged. --- scripts/check-wrapper-drift/main.go | 170 ++++++++++++++++------- scripts/check-wrapper-drift/main_test.go | 75 ++++++++++ 2 files changed, 191 insertions(+), 54 deletions(-) diff --git a/scripts/check-wrapper-drift/main.go b/scripts/check-wrapper-drift/main.go index a1410d016..99a70a55f 100644 --- a/scripts/check-wrapper-drift/main.go +++ b/scripts/check-wrapper-drift/main.go @@ -13,18 +13,15 @@ // from the *FromGenerated convention check below — it is a parallel // mapping for WebhookEventPerson, not a Person wrapper.) // -// 2. By an explicit `directDecodePairs` map covering wrappers that decode -// straight from JSON bytes (Notification, NotificationsResult, MyAssignment, -// MyAssignmentsResult, Gauge, GaugeNeedle, Account, Preferences, -// OutOfOffice) plus the nested public wrapper structs reachable from them -// (PreviewableAttachment, MyAssignmentAssignee, MyAssignmentBucket, -// MyAssignmentParent, AccountLogo, AccountLimits, AccountSettings, -// AccountSubscription, OutOfOfficePerson). These do not have -// *FromGenerated declarations; the JSON tags on the wrapper struct fields -// are what the JSON decoder uses to read the wire payload. Listing the -// nested structs explicitly carries the tag-presence check into them -// rather than stopping at the parent field. See the directDecodePairs -// declaration below for the derivation recipe that produced the list. +// 2. By an explicit `directDecodePairs` map covering tag-presence-only +// pairs: wrappers with a `generated.X` counterpart but no *FromGenerated +// function the AST walker can follow. The map organizes these into two +// labeled tiers — tier 2 (direct-decode via json.Unmarshal, including +// nested wrappers reachable from the same Unmarshal pass) and tier 3 +// (inline-converted via composite literal inside a *FromGenerated body +// or service method). Both tiers run the tag-presence check; neither +// runs the population check. See the directDecodePairs declaration +// below for the full tier model, derivation recipe, and exclusion list. // // # Check // @@ -68,9 +65,11 @@ // personFromGenerated(...)`) counts because the parent field is assigned; // the nested Person's own fields are verified through the separate // Person ↔ generated.Person pair. -// - Direct-decode wrappers (the directDecodePairs set) are EXEMPT: they have -// no *FromGenerated body and are populated by json.Unmarshal straight onto -// the struct tags, so tag presence already is population for them. +// - Tier-2 and tier-3 wrappers (the directDecodePairs set) are EXEMPT: tier 2 +// has no *FromGenerated body and is populated by json.Unmarshal straight +// onto the struct tags (tag presence is population); tier 3 is populated by +// a composite literal inside someone else's body and the walker has nothing +// local to verify (population is enforced by review of that composite). // - A field genuinely populated by some mechanism the walker cannot see // (none exist today) should carry an `// intentionally-omitted` marker with // a reason, which suppresses both the tag and population checks for it. @@ -101,49 +100,95 @@ import ( "strings" ) -// directDecodePairs maps the wrapper struct name to the generated struct -// name for §C-2 wrappers that decode via json.Unmarshal on the (sometimes -// pre-normalized) raw body. Their *FromGenerated function does not exist; -// the wrapper struct's JSON tags are the contract. +// directDecodePairs maps the wrapper struct name to the generated struct name +// for tag-presence-only pairs: wrappers that have a `generated.X` counterpart +// but no `*FromGenerated` function the AST walker can follow, so the population +// check is structurally inapplicable and only the tag-presence check runs. // -// The set covers both the top-level raw-body wrappers AND the nested public -// wrapper structs reachable from them that have a generated counterpart but no -// *FromGenerated of their own (they are populated by the same json.Unmarshal -// pass as their parent). Listing the nested structs explicitly extends the -// tag-presence check into them; without these entries the parent check only -// verifies the parent field (e.g. previewable_attachments, assignees, bucket) -// and a future generated-field addition inside the nested struct would slip -// through. Because they are direct-decode, only the tag-presence check applies -// (json.Unmarshal populates them straight from the tags), not the population -// check. +// # Coverage model: three tiers // -// # Completeness +// The drift check operates on a UNION of wrapper↔generated pairs derived from +// three sources. Tiers 1 and 2 differ in HOW the pair is discovered and what +// checks run; tier 3 piggybacks on the same logic as tier 2. All three live in +// one tag-presence check so future contributors see the coverage as a single +// surface. // -// This map is intended to be the COMPLETE set of (wrapper, generated) direct- -// decode pairs as of this PR. The derivation recipe used to build it (and the -// recipe future contributors should re-run when adding endpoints): +// - Tier 1: *FromGenerated-backed pairs. Discovered automatically by walking +// every `FromGenerated(g generated.X) Y` declaration (see +// collectFromGeneratedPairs). These get BOTH the tag-presence check AND +// the population check — the function body is AST-walked to confirm each +// tagged wrapper field is assigned (see collectAssignedFields). This tier +// is NOT in this map; the function signature is the contract. // -// 1. Grep go/pkg/basecamp/*.go (non-test) for raw-decode call sites: -// `json.Unmarshal(... &)`, `json.NewDecoder(...).Decode(&)`, -// and decode helpers (e.g. `decodeGaugePayload`). -// 2. For each site whose target local is a hand-written WRAPPER struct (not a -// `generated.X` value routed through a `*FromGenerated` function), check -// whether a `generated.` (or close-named) counterpart exists in -// `go/pkg/generated/client.gen.go`. -// 3. If yes, add (wrapper, generated) here. Also add every nested PUBLIC -// wrapper struct reachable from it whose fields are populated by the same -// json.Unmarshal (no *FromGenerated of its own). +// - Tier 2: direct-decode pairs (raw json.Unmarshal). Wrappers populated by +// `json.Unmarshal(rawBody, &wrapper)` on a (sometimes pre-normalized) raw +// response body, with no *FromGenerated function. The JSON decoder writes +// each generated field straight onto the matching wrapper tag, so tag +// presence IS population — no body to walk. The wrapper struct's JSON tags +// are the contract. Includes both top-level raw-body wrappers and the +// nested public wrapper structs reachable from them that share the same +// json.Unmarshal pass. +// +// - Tier 3: inline-converted pairs (composite-literal construction). Wrappers +// populated by an explicit `Wrapper{Field: g.Field, ...}` composite literal +// inside a parent `*FromGenerated` body (e.g. CampfireLineAttachment built +// in campfireLineFromGenerated) OR inside a service method that builds the +// wrapper directly from a generated response value (e.g. LineupMarker built +// in LineupService.ListMarkers, SearchMetadata in SearchService.Metadata, +// UpdateProjectAccessResponse in PeopleService.UpdateProjectAccess). They +// have no *FromGenerated of their own. The population guarantee here comes +// from REVIEW of the composite literal's key set, not from the walker — the +// check verifies the wrapper struct's tag set keeps up with the generated +// struct so a future field addition can be detected. Tag-presence-only is +// the correct floor for this tier because the wrapper's job is to expose +// the surface; the conversion code's job (reviewed in the PR diff) is to +// populate it. Adding a tag without populating it would surface in the +// conformance suite, not here. +// +// # Derivation recipe +// +// This map is intended to be the COMPLETE set of (wrapper, generated) tier-2 +// and tier-3 pairs as of this PR. To re-derive when adding endpoints or to +// audit for a suspected 4th category: +// +// 1. Enumerate every `^type struct` declared in go/pkg/basecamp/*.go +// (non-test) AND in go/pkg/generated/client.gen.go. +// 2. Intersect the two type-name sets. +// 3. Subtract pairs already covered by tier 1 (every wrapper with a +// `FromGenerated` function) and the design exclusions below. Each +// remaining shared name is a tier-2 or tier-3 candidate. +// 4. Classify by HOW it is populated: +// - `json.Unmarshal(rawBody, &)` (or a thin decode helper) → +// tier 2; add it here, plus every nested PUBLIC wrapper struct +// reachable from it that shares the same Unmarshal pass. +// - `Wrapper{...}` composite literal in a *FromGenerated body or a +// service method, reading fields off a `generated.X` value → tier 3; +// add it here. +// - Neither → out of scope (likely a request envelope, a non-spec +// endpoint type, or a parallel webhook-flavored shape). +// +// # Excluded by design // -// Excluded by design: // - WebhookEvent and its parallel webhook-flavored wrapper types -// (WebhookEventRecording / WebhookEventPerson / ...): these are a separate -// representation, not aligned 1:1 with `generated.WebhookEvent`'s nested -// `Recording` / `Person`. They follow the same precedent as -// `webhookPersonFromGenerated` (see excludedFromGenerated). +// (WebhookEventRecording / WebhookEventPerson / WebhookCopy / +// WebhookCopyBucket / WebhookDelivery / WebhookDeliveryRequest / +// WebhookDeliveryResponse): a separate representation, not aligned 1:1 +// with `generated.WebhookEvent`'s nested `Recording` / `Person`. Follow +// the same precedent as `webhookPersonFromGenerated` (see +// excludedFromGenerated). // - Local request / response envelope structs used to read upstream API // errors, the Launchpad authorization endpoint, embedded SDK provenance, -// and the like, which are not driven by the OpenAPI spec. +// and similar non-spec wrappers. +// - Outgoing request wrappers whose name happens to match a +// `generated.CreateXRequest` / etc. (e.g. CreatePersonRequest, +// ScheduleAttributes): data flows wrapper→generated, not generated→ +// wrapper. The tag-presence check still works in principle, but the +// semantics (caller-driven vs server-driven payloads) and the failure mode +// (caller cannot supply a new field vs wire data silently dropped) differ +// enough to warrant a separate tier with its own documentation, deferred +// to a follow-up. var directDecodePairs = map[string]string{ + // Tier 2: direct-decode (raw json.Unmarshal on a response body), top-level. "Notification": "Notification", "NotificationsResult": "GetMyNotificationsResponseContent", "MyAssignment": "MyAssignment", @@ -153,7 +198,7 @@ var directDecodePairs = map[string]string{ "Preferences": "Preferences", "OutOfOffice": "OutOfOffice", "MyAssignmentsResult": "GetMyAssignmentsResponseContent", - // Nested direct-decode structs (no *FromGenerated; decoded with their parent). + // Tier 2: direct-decode nested wrappers (no *FromGenerated; decoded with their parent). "PreviewableAttachment": "PreviewableAttachment", // nested in Notification.previewable_attachments "MyAssignmentAssignee": "MyAssignmentAssignee", // nested in MyAssignment.assignees "MyAssignmentBucket": "MyAssignmentBucket", // nested in MyAssignment.bucket @@ -163,6 +208,19 @@ var directDecodePairs = map[string]string{ "AccountSettings": "AccountSettings", // nested in Account.settings "AccountSubscription": "AccountSubscription", // nested in Account.subscription "OutOfOfficePerson": "OutOfOfficePerson", // nested in OutOfOffice.person + // Tier 3: inline-converted (composite literal in *FromGenerated body or service method). + "CampfireLineAttachment": "CampfireLineAttachment", // composite literal in campfireLineFromGenerated (campfires.go) + "CardColumnOnHold": "CardColumnOnHold", // composite literal in cardColumnFromGenerated (cards.go) + "ClientApprovalResponse": "ClientApprovalResponse", // composite literal in clientApprovalFromGenerated (client_approvals.go) + "ClientCompany": "ClientCompany", // composite literal in projectFromGenerated (projects.go) + "EventDetails": "EventDetails", // composite literal in eventFromGenerated (events.go) + "HillChartDot": "HillChartDot", // composite literal in hillChartFromGenerated (hill_charts.go) + "LineupMarker": "LineupMarker", // composite literal in LineupService.ListMarkers (lineup.go) + "PersonCompany": "PersonCompany", // composite literal in personFromGenerated (people.go) + "QuestionSchedule": "QuestionSchedule", // composite literal in questionFromGenerated (checkins.go) + "SearchMetadata": "SearchMetadata", // composite literal in SearchService.Metadata (search.go) + "SearchProject": "SearchProject", // composite literal in SearchService.Metadata (search.go) + "UpdateProjectAccessResponse": "ProjectAccessResult", // composite literal in PeopleService.UpdateProjectAccess (people.go) } // excludedFromGenerated lists *FromGenerated functions whose argument type @@ -327,10 +385,14 @@ func run(wrapperDir, generatedFile string, directDecode map[string]string, verbo continue } - // Direct-decode wrappers (Notification, MyAssignment, ...) have no - // *FromGenerated body: the JSON decoder populates them straight from the - // struct tags, so tag presence IS population. The population check below - // only applies to *FromGenerated-backed pairs. + // Tier-2 and tier-3 wrappers (everything in the directDecode map) have no + // *FromGenerated body for the walker to inspect. Tier 2 (Notification, + // MyAssignment, ...) is populated by json.Unmarshal straight onto struct + // tags, so tag presence IS population. Tier 3 (CampfireLineAttachment, + // EventDetails, ...) is populated by a composite literal inside someone + // else's body — out of scope for the AST walker, enforced by code review. + // Either way, the population check below only applies to *FromGenerated- + // backed pairs (tier 1). _, isDirectDecode := directDecode[wrapName] assigned := assignedFields[wrapName] diff --git a/scripts/check-wrapper-drift/main_test.go b/scripts/check-wrapper-drift/main_test.go index 55e2e6cc2..5b06c8e64 100644 --- a/scripts/check-wrapper-drift/main_test.go +++ b/scripts/check-wrapper-drift/main_test.go @@ -426,6 +426,81 @@ type MyAssignmentsResult struct { } } +// TestRun_InlineConvertedPair drives run() with a tier-3 pair: the wrapper has +// no *FromGenerated of its own and is populated by a composite literal inside a +// parent's *FromGenerated body — the shape the production directDecodePairs +// tier-3 entries (CampfireLineAttachment, EventDetails, etc.) follow. Two +// assertions matter: (1) the pair is walked via the injected directDecode map +// despite no *FromGenerated function for the inline-converted wrapper, and (2) +// the tag-presence check fires on a missing generated tag — exactly the +// regression the tier exists to catch (parent's body silently dropping a new +// generated field). +func TestRun_InlineConvertedPair(t *testing.T) { + genSrc := `package generated + +type Parent struct { + Id int64 ` + "`json:\"id\"`" + ` + Nested Nested ` + "`json:\"nested,omitempty\"`" + ` +} +type Nested struct { + Name string ` + "`json:\"name\"`" + ` + Color string ` + "`json:\"color\"`" + ` +} +` + // In-sync wrapper: Nested carries both tags and the parent's *FromGenerated + // builds it inline. Only Parent has a *FromGenerated; Nested is tier 3. + wrapperOK := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Nested struct { + Name string ` + "`json:\"name\"`" + ` + Color string ` + "`json:\"color\"`" + ` +} +type Parent struct { + ID int64 ` + "`json:\"id\"`" + ` + Nested *Nested ` + "`json:\"nested,omitempty\"`" + ` +} + +func parentFromGenerated(g generated.Parent) Parent { + p := Parent{} + p.ID = g.Id + p.Nested = &Nested{Name: g.Nested.Name, Color: g.Nested.Color} + return p +} +` + pairs := map[string]string{"Nested": "Nested"} + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"parent.go": wrapperOK}) + if err := run(wrapperDir, generatedFile, pairs, false); err != nil { + t.Errorf("run (in-sync inline-converted pair): expected no drift, got %v", err) + } + + // Wrapper drops the `color` tag with no marker — drift expected. + wrapperMissing := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Nested struct { + Name string ` + "`json:\"name\"`" + ` +} +type Parent struct { + ID int64 ` + "`json:\"id\"`" + ` + Nested *Nested ` + "`json:\"nested,omitempty\"`" + ` +} + +func parentFromGenerated(g generated.Parent) Parent { + p := Parent{} + p.ID = g.Id + p.Nested = &Nested{Name: g.Nested.Name} + return p +} +` + wrapperDir, generatedFile = writeDriftFixtures(t, genSrc, map[string]string{"parent.go": wrapperMissing}) + if err := run(wrapperDir, generatedFile, pairs, false); err == nil { + t.Error("run (inline-converted pair missing nested color tag): expected drift, got nil") + } +} + // TestCollectAssignedFields verifies the walker collects fields from both the // wrapper composite literal and selector assignments, and does NOT collect keys // from nested helper literals (Parent/Bucket) — the one-level-nesting boundary. From ebc5e47f745511893b36555691605bb79dd6c9d9 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 28 May 2026 10:50:38 -0700 Subject: [PATCH 22/26] scripts: extend wrapper-drift walker to population-check tier 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier-3 wrappers (inline-converted via composite literal — 12 of the 30 directDecodePairs entries) previously got the tag-presence check but not the population check: the walker treated everything in directDecodePairs as 'no body to walk' and gave the tier a reviewer-enforced floor. A future generated field added with a matching wrapper tag but a missed composite-literal update would have passed drift while the SDK silently returned zero. Add a composite-literal collector that scans every non-test file in go/pkg/basecamp/ for literals of any tier3Wrappers type and feeds the result into the existing population check the same way *FromGenerated bodies feed for tier 1: - Bare and pointer literals (Wrapper{...}, &Wrapper{...}) — both forms contribute the literal's KeyValueExpr keys. - Local-bound writes (resp := Wrapper{...}; resp.X = ...) and selector-chain bindings (q.Schedule = &Wrapper{...}; q.Schedule.X = ...) — subsequent selector writes against any path the literal is bound to are attributed to the wrapper. The selector-chain binding is what makes the QuestionSchedule shape (where six fields are set in the literal and three more by conditional q.Schedule.X writes) pass the population check end-to-end. tier3Wrappers is a sentinel set listing the 12 tier-3 names (kept in sync with the tier-3 section of directDecodePairs). The population-check gate flips from 'skip if directDecode' to 'skip only if tier 2', so the existing tier-2 path (raw json.Unmarshal — tag presence is population) is unchanged. run() gains a tier3 parameter so tests can drive end-to-end fixtures without dragging in the production tier-3 set, mirroring the existing directDecode injection. Pair count stays 76; field-assignments-verified jumps from 757 to 818 (61 new tier-3 assignments — exactly the gap the prior reviewer-floor left open). --- scripts/check-wrapper-drift/main.go | 345 +++++++++++++++++++++------- 1 file changed, 264 insertions(+), 81 deletions(-) diff --git a/scripts/check-wrapper-drift/main.go b/scripts/check-wrapper-drift/main.go index 99a70a55f..2c0caa782 100644 --- a/scripts/check-wrapper-drift/main.go +++ b/scripts/check-wrapper-drift/main.go @@ -13,14 +13,16 @@ // from the *FromGenerated convention check below — it is a parallel // mapping for WebhookEventPerson, not a Person wrapper.) // -// 2. By an explicit `directDecodePairs` map covering tag-presence-only -// pairs: wrappers with a `generated.X` counterpart but no *FromGenerated -// function the AST walker can follow. The map organizes these into two -// labeled tiers — tier 2 (direct-decode via json.Unmarshal, including -// nested wrappers reachable from the same Unmarshal pass) and tier 3 -// (inline-converted via composite literal inside a *FromGenerated body -// or service method). Both tiers run the tag-presence check; neither -// runs the population check. See the directDecodePairs declaration +// 2. By an explicit `directDecodePairs` map covering pairs whose wrappers +// have no *FromGenerated function for the signature walker to find. The +// map organizes these into two labeled tiers — tier 2 (direct-decode via +// json.Unmarshal, including nested wrappers reachable from the same +// Unmarshal pass) and tier 3 (inline-converted via composite literal +// inside a *FromGenerated body or service method). Both tiers run the +// tag-presence check. Tier 3 also runs the population check, sourced from +// collectCompositeLiteralFields rather than a *FromGenerated body; tier 2 +// is the only tier where the JSON decoder is the population guarantee and +// tag presence alone is sufficient. See the directDecodePairs declaration // below for the full tier model, derivation recipe, and exclusion list. // // # Check @@ -41,35 +43,46 @@ // # Population check // // Declaring the tag is necessary but not sufficient: a wrapper field can carry -// the right JSON tag yet never be assigned by its *FromGenerated conversion -// function, so it silently stays zero-valued on the wire while the tag-presence -// check passes. For every *FromGenerated-backed pair, the script therefore also -// confirms the conversion body actually assigns each tagged wrapper field. It -// AST-walks the function body and collects assigned wrapper fields from two -// forms (see collectAssignedFields): the wrapper's own composite literal -// (`c := Card{Status: ...}`) and selector-target assignments (`c.Creator = -// ...`, `c.Steps = append(...)`). A tag-present-but-never-assigned field is -// reported as drift. +// the right JSON tag yet never be assigned by the wrapper's construction site, +// so it silently stays zero-valued on the wire while the tag-presence check +// passes. The check therefore also confirms the construction site actually +// assigns each tagged wrapper field. Two construction shapes are covered: +// +// - Tier 1 (*FromGenerated body): for every `FromGenerated(g +// generated.X) Y` declaration, the body is AST-walked to collect the +// assigned wrapper fields from two forms (see collectAssignedFields): the +// wrapper's own composite literal (`c := Card{Status: ...}`) and +// selector-target assignments (`c.Creator = ...`, `c.Steps = append(...)`). +// - Tier 3 (inline composite literal): the wrapper has no *FromGenerated of +// its own and is built by a `Wrapper{...}` (or `&Wrapper{...}`) composite +// literal inside some other function — a parent *FromGenerated body or a +// service method. For each such literal anywhere in go/pkg/basecamp/, the +// walker collects keys from the literal AND from subsequent selector writes +// to the local path (bare identifier or selector chain like `q.Schedule`) +// the literal is bound to. See collectCompositeLiteralFields. +// +// A tag-present-but-never-assigned field is reported as drift. // // Scope and limitations of the population check (verified against the current -// go/pkg/basecamp/ corpus, where every *FromGenerated follows this shape): +// go/pkg/basecamp/ corpus, where every wrapper follows one of these shapes): // // - It is a *reachability* check, not a value check: it proves the field is -// written somewhere in the body, not that the written value is correct or -// that the assignment is unconditional. A field assigned only inside an -// `if` branch (e.g. nested Creator/Bucket pointers, which are gated on the -// generated value being non-empty) counts as populated — matching the -// wrappers' intentional "leave nil when the source is empty" semantics. +// written somewhere in the construction site, not that the written value +// is correct or that the assignment is unconditional. A field assigned only +// inside an `if` branch (e.g. nested Creator/Bucket pointers, which are +// gated on the generated value being non-empty) counts as populated — +// matching the wrappers' intentional "leave nil when the source is empty" +// semantics. // - One level of nesting only, consistent with the tag check: a parent field // assigned via a nested helper (`c.Creator = &creator` where `creator = // personFromGenerated(...)`) counts because the parent field is assigned; // the nested Person's own fields are verified through the separate // Person ↔ generated.Person pair. -// - Tier-2 and tier-3 wrappers (the directDecodePairs set) are EXEMPT: tier 2 -// has no *FromGenerated body and is populated by json.Unmarshal straight -// onto the struct tags (tag presence is population); tier 3 is populated by -// a composite literal inside someone else's body and the walker has nothing -// local to verify (population is enforced by review of that composite). +// - Tier-2 wrappers (the json.Unmarshal subset of directDecodePairs) are +// EXEMPT: they have no *FromGenerated body and no composite literal — they +// are populated by json.Unmarshal straight onto the struct tags, so tag +// presence IS population. The tier-3 subset of directDecodePairs DOES get +// a population check via the composite-literal walker. // - A field genuinely populated by some mechanism the walker cannot see // (none exist today) should carry an `// intentionally-omitted` marker with // a reason, which suppresses both the tag and population checks for it. @@ -101,17 +114,19 @@ import ( ) // directDecodePairs maps the wrapper struct name to the generated struct name -// for tag-presence-only pairs: wrappers that have a `generated.X` counterpart -// but no `*FromGenerated` function the AST walker can follow, so the population -// check is structurally inapplicable and only the tag-presence check runs. +// for wrappers that have a `generated.X` counterpart but no `*FromGenerated` +// function the tier-1 walker discovers — i.e. wrappers populated either by a +// raw json.Unmarshal (tier 2) or by an inline composite literal inside someone +// else's body (tier 3). Tier-3 entries are also listed in tier3Wrappers so the +// population walker knows to scan composite literals for them. // // # Coverage model: three tiers // // The drift check operates on a UNION of wrapper↔generated pairs derived from -// three sources. Tiers 1 and 2 differ in HOW the pair is discovered and what -// checks run; tier 3 piggybacks on the same logic as tier 2. All three live in -// one tag-presence check so future contributors see the coverage as a single -// surface. +// three sources. Tiers 1 and 3 both get the population check; tier 2 does not +// (it has no in-package literal — the JSON decoder is the population). All +// three live in one tag-presence pass so future contributors see the coverage +// as a single surface. // // - Tier 1: *FromGenerated-backed pairs. Discovered automatically by walking // every `FromGenerated(g generated.X) Y` declaration (see @@ -122,12 +137,12 @@ import ( // // - Tier 2: direct-decode pairs (raw json.Unmarshal). Wrappers populated by // `json.Unmarshal(rawBody, &wrapper)` on a (sometimes pre-normalized) raw -// response body, with no *FromGenerated function. The JSON decoder writes -// each generated field straight onto the matching wrapper tag, so tag -// presence IS population — no body to walk. The wrapper struct's JSON tags -// are the contract. Includes both top-level raw-body wrappers and the -// nested public wrapper structs reachable from them that share the same -// json.Unmarshal pass. +// response body, with no *FromGenerated function and no in-package +// composite literal to walk. The JSON decoder writes each generated field +// straight onto the matching wrapper tag, so tag presence IS population. +// The wrapper struct's JSON tags are the contract. Includes both top-level +// raw-body wrappers and the nested public wrapper structs reachable from +// them that share the same json.Unmarshal pass. // // - Tier 3: inline-converted pairs (composite-literal construction). Wrappers // populated by an explicit `Wrapper{Field: g.Field, ...}` composite literal @@ -136,14 +151,12 @@ import ( // wrapper directly from a generated response value (e.g. LineupMarker built // in LineupService.ListMarkers, SearchMetadata in SearchService.Metadata, // UpdateProjectAccessResponse in PeopleService.UpdateProjectAccess). They -// have no *FromGenerated of their own. The population guarantee here comes -// from REVIEW of the composite literal's key set, not from the walker — the -// check verifies the wrapper struct's tag set keeps up with the generated -// struct so a future field addition can be detected. Tag-presence-only is -// the correct floor for this tier because the wrapper's job is to expose -// the surface; the conversion code's job (reviewed in the PR diff) is to -// populate it. Adding a tag without populating it would surface in the -// conformance suite, not here. +// have no *FromGenerated of their own. These get BOTH checks: the +// tag-presence check (this map) AND the population check, via +// collectCompositeLiteralFields which walks every non-test wrapper file for +// composite literals of any tier3Wrappers type, collecting keys from the +// literal and from subsequent selector writes to the local path the literal +// is bound to (`resp := Wrapper{...}`, `q.Schedule = &Wrapper{...}`). // // # Derivation recipe // @@ -158,14 +171,14 @@ import ( // `FromGenerated` function) and the design exclusions below. Each // remaining shared name is a tier-2 or tier-3 candidate. // 4. Classify by HOW it is populated: -// - `json.Unmarshal(rawBody, &)` (or a thin decode helper) → -// tier 2; add it here, plus every nested PUBLIC wrapper struct -// reachable from it that shares the same Unmarshal pass. -// - `Wrapper{...}` composite literal in a *FromGenerated body or a -// service method, reading fields off a `generated.X` value → tier 3; -// add it here. -// - Neither → out of scope (likely a request envelope, a non-spec -// endpoint type, or a parallel webhook-flavored shape). +// - `json.Unmarshal(rawBody, &)` (or a thin decode helper) → +// tier 2; add it here, plus every nested PUBLIC wrapper struct +// reachable from it that shares the same Unmarshal pass. +// - `Wrapper{...}` composite literal in a *FromGenerated body or a +// service method, reading fields off a `generated.X` value → tier 3; +// add it here. +// - Neither → out of scope (likely a request envelope, a non-spec +// endpoint type, or a parallel webhook-flavored shape). // // # Excluded by design // @@ -223,6 +236,26 @@ var directDecodePairs = map[string]string{ "UpdateProjectAccessResponse": "ProjectAccessResult", // composite literal in PeopleService.UpdateProjectAccess (people.go) } +// tier3Wrappers is the subset of directDecodePairs keys whose wrappers are built +// by inline composite literal (not raw json.Unmarshal). For these, the +// population check is sourced from collectCompositeLiteralFields, which scans +// every non-test wrapper file for composite literals of these types. Keep in +// sync with the tier-3 entries in directDecodePairs. +var tier3Wrappers = map[string]bool{ + "CampfireLineAttachment": true, + "CardColumnOnHold": true, + "ClientApprovalResponse": true, + "ClientCompany": true, + "EventDetails": true, + "HillChartDot": true, + "LineupMarker": true, + "PersonCompany": true, + "QuestionSchedule": true, + "SearchMetadata": true, + "SearchProject": true, + "UpdateProjectAccessResponse": true, +} + // excludedFromGenerated lists *FromGenerated functions whose argument type // is not the structurally-aligned generated struct of their return type // (e.g. webhookPersonFromGenerated maps generated.Person → WebhookEventPerson, @@ -267,7 +300,7 @@ func main() { wrapperDir := filepath.Join(repoRoot, "go", "pkg", "basecamp") generatedFile := filepath.Join(repoRoot, "go", "pkg", "generated", "client.gen.go") - if err := run(wrapperDir, generatedFile, directDecodePairs, *verbose); err != nil { + if err := run(wrapperDir, generatedFile, directDecodePairs, tier3Wrappers, *verbose); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } @@ -297,12 +330,12 @@ func resolveRoot(root string) (string, error) { } } -// run performs the full drift check. directDecode is injected (rather than read -// from the package global) so tests can drive run() end-to-end with their own -// fixtures without dragging in the production direct-decode pair set, whose -// generated structs would otherwise have to exist in every test fixture. -// main() passes the package-level directDecodePairs. -func run(wrapperDir, generatedFile string, directDecode map[string]string, verbose bool) error { +// run performs the full drift check. directDecode and tier3 are injected +// (rather than read from the package globals) so tests can drive run() +// end-to-end with their own fixtures without dragging in the production +// pair set / tier-3 set, whose generated structs would otherwise have to exist +// in every test fixture. main() passes directDecodePairs and tier3Wrappers. +func run(wrapperDir, generatedFile string, directDecode map[string]string, tier3 map[string]bool, verbose bool) error { fset := token.NewFileSet() // Parse the generated client. @@ -319,7 +352,11 @@ func run(wrapperDir, generatedFile string, directDecode map[string]string, verbo } wrapperStructs := map[string]*structFields{} fromGenPairs := map[string]string{} // wrapper name -> generated name (derived from *FromGenerated signatures) - assignedFields := map[string]map[string]bool{} // wrapper name -> set of Go fields its *FromGenerated body assigns + assignedFields := map[string]map[string]bool{} // wrapper name -> set of Go fields written at the wrapper's construction site (tier 1 + tier 3) + // Tier-3 names sourced from the production tier3Wrappers set. Tests can + // inject only tier-2 pairs via the directDecode argument; in that case + // none of them appear in tier3Wrappers and the composite-literal walker + // is a no-op for them, matching the existing tier-2 semantics. for _, entry := range entries { name := entry.Name() if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { @@ -351,6 +388,21 @@ func run(wrapperDir, generatedFile string, directDecode map[string]string, verbo set[fn] = true } } + // Tier-3 wrappers have no *FromGenerated of their own. Collect their + // assigned fields from inline composite literals (and selector writes + // against any local path the literal is bound to) anywhere in the + // non-test wrapper files. Results merge into the same assignedFields + // map so the population check below is uniform for tier 1 and tier 3. + for k, fields := range collectCompositeLiteralFields(f, tier3) { + set := assignedFields[k] + if set == nil { + set = map[string]bool{} + assignedFields[k] = set + } + for fn := range fields { + set[fn] = true + } + } } // Build the final pair list: union of fromGen + directDecode. @@ -385,15 +437,14 @@ func run(wrapperDir, generatedFile string, directDecode map[string]string, verbo continue } - // Tier-2 and tier-3 wrappers (everything in the directDecode map) have no - // *FromGenerated body for the walker to inspect. Tier 2 (Notification, - // MyAssignment, ...) is populated by json.Unmarshal straight onto struct - // tags, so tag presence IS population. Tier 3 (CampfireLineAttachment, - // EventDetails, ...) is populated by a composite literal inside someone - // else's body — out of scope for the AST walker, enforced by code review. - // Either way, the population check below only applies to *FromGenerated- - // backed pairs (tier 1). + // The population check runs for tier 1 (assignedFields sourced from + // the *FromGenerated body) and tier 3 (assignedFields sourced from the + // inline composite literal walker). Tier 2 is the only path that skips + // the population check — its wrappers have no in-package literal; the + // JSON decoder writes straight onto struct tags, so tag presence IS + // population. _, isDirectDecode := directDecode[wrapName] + isTier2 := isDirectDecode && !tier3[wrapName] assigned := assignedFields[wrapName] // Walk every JSON tag declared on the generated struct. @@ -414,11 +465,11 @@ func run(wrapperDir, generatedFile string, directDecode map[string]string, verbo missing = append(missing, tag) continue } - // Tag is declared on the wrapper. For *FromGenerated-backed pairs, - // also confirm the conversion body actually assigns the field — + // Tag is declared on the wrapper. For tier-1 and tier-3 pairs, + // also confirm the construction site actually assigns the field — // otherwise a tag-present-but-unassigned field silently stays // zero-valued while this check would otherwise pass. - if !isDirectDecode { + if !isTier2 { totalFieldsPopChecked++ goField := wrap.tagToGoField[tag] if goField != "" && (assigned == nil || !assigned[goField]) { @@ -430,11 +481,11 @@ func run(wrapperDir, generatedFile string, directDecode map[string]string, verbo drift = append(drift, fmt.Sprintf("%s ↔ generated.%s: missing JSON tags %v (add to wrapper struct or mark with `// intentionally-omitted: - `)", wrapName, genName, missing)) } if len(unpopulated) > 0 { - drift = append(drift, fmt.Sprintf("%s ↔ generated.%s: wrapper declares these tags but %sFromGenerated never assigns them %v (assign the field in the conversion function, or mark with `// intentionally-omitted: - ` if the wrapper field is populated by some other means)", wrapName, genName, lowercaseFirst(wrapName), unpopulated)) + drift = append(drift, fmt.Sprintf("%s ↔ generated.%s: wrapper declares these tags but no %s{...} composite literal or %sFromGenerated body assigns them %v (assign the field at the wrapper's construction site, or mark with `// intentionally-omitted: - ` if the wrapper field is populated by some other means)", wrapName, genName, wrapName, lowercaseFirst(wrapName), unpopulated)) } if verbose { - fmt.Printf(" %s ↔ generated.%s (%d generated tags, %d wrapper tags, %d omitted, %d assigned fields, directDecode=%v)\n", - wrapName, genName, len(gen.tags), len(wrap.tags), len(wrap.omitted), len(assigned), isDirectDecode) + fmt.Printf(" %s ↔ generated.%s (%d generated tags, %d wrapper tags, %d omitted, %d assigned fields, directDecode=%v, tier2=%v)\n", + wrapName, genName, len(gen.tags), len(wrap.tags), len(wrap.omitted), len(assigned), isDirectDecode, isTier2) } } @@ -455,7 +506,7 @@ func run(wrapperDir, generatedFile string, directDecode map[string]string, verbo } } - fmt.Printf("Wrapper drift check: %d pairs walked, %d generated fields verified (%d field assignments verified in *FromGenerated bodies)\n", len(pairNames), totalFieldsChecked, totalFieldsPopChecked) + fmt.Printf("Wrapper drift check: %d pairs walked, %d generated fields verified (%d field assignments verified at tier-1 *FromGenerated bodies + tier-3 composite literals)\n", len(pairNames), totalFieldsChecked, totalFieldsPopChecked) if len(drift) > 0 { fmt.Fprintln(os.Stderr) @@ -464,7 +515,7 @@ func run(wrapperDir, generatedFile string, directDecode map[string]string, verbo fmt.Fprintln(os.Stderr, " -", d) } fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "Fix: either propagate the generated field on the wrapper struct + assign it in the *FromGenerated function, or add a comment of the form") + fmt.Fprintln(os.Stderr, "Fix: either propagate the generated field on the wrapper struct + assign it at the wrapper's construction site (the *FromGenerated function for tier 1, or the inline composite literal for tier 3), or add a comment of the form") fmt.Fprintln(os.Stderr, " `// intentionally-omitted: - ` inside the wrapper struct's declaration.") return fmt.Errorf("wrapper drift: %d issue(s)", len(drift)) } @@ -769,6 +820,138 @@ func selectorBaseAndField(expr ast.Expr) (base, field string) { return ident.Name, sel.Sel.Name } +// exprToPath converts an identifier-rooted selector chain into a dotted path +// string. `q` -> "q", `q.Schedule` -> "q.Schedule", `a.b.c` -> "a.b.c". Returns +// "" for anything not rooted in a bare identifier (index expressions, calls, +// type assertions, ...). Used by collectCompositeLiteralFields to key its local +// bindings so subsequent selector writes can be matched. +func exprToPath(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.SelectorExpr: + base := exprToPath(e.X) + if base == "" { + return "" + } + return base + "." + e.Sel.Name + } + return "" +} + +// pathPrefixAndField decomposes any identifier-rooted selector expression into +// its prefix-path string and final field name. `q.Schedule.WeekInstance` -> +// ("q.Schedule", "WeekInstance"); `resp.ID` -> ("resp", "ID"). Returns "", "" +// for non-selector or non-identifier-rooted expressions. The prefix lets +// callers look up a previously-recorded composite-literal binding to determine +// which wrapper this write targets. +func pathPrefixAndField(expr ast.Expr) (prefix, field string) { + sel, ok := expr.(*ast.SelectorExpr) + if !ok { + return "", "" + } + prefix = exprToPath(sel.X) + if prefix == "" { + return "", "" + } + return prefix, sel.Sel.Name +} + +// collectCompositeLiteralFields walks every function body in f and, for each +// composite literal whose type is in tier3Wrappers, collects assignment field +// names from two sources, mirroring tier-1's collectAssignedFields contract so +// the population check can treat tier 1 and tier 3 uniformly: +// +// 1. The literal's own KeyValueExpr keys (`&Wrapper{ID: ..., Name: ...}` → +// ID, Name). Both bare `Wrapper{...}` and pointer `&Wrapper{...}` forms +// are caught because ast.Inspect descends through the UnaryExpr into the +// inner CompositeLit, and only the CompositeLit's bare-Ident type matters. +// 2. Selector writes against any local path the literal is bound to. The +// binding is the LHS path of an assignment whose RHS is the literal — +// either a bare local (`resp := Wrapper{...}` binds "resp") or a selector +// chain (`q.Schedule = &Wrapper{...}` binds "q.Schedule"). Subsequent +// `resp.X = ...` or `q.Schedule.X = ...` writes are then attributed to +// the wrapper. Selector writes against an unbound path are ignored. +// +// The per-function `bindings` map scopes the binding to one function body, so +// a `resp` local in one function does not contaminate another. This is enough +// for the current corpus, where every tier-3 wrapper is built inside a single +// function. The walker does not require the enclosing function to be a +// *FromGenerated — service methods (LineupService.ListMarkers, +// SearchService.Metadata, PeopleService.UpdateProjectAccess) that build a +// wrapper inline are covered the same way. +// +// Returns wrapper name -> set of assigned Go field names. Wrappers not in +// tier3Wrappers are ignored; if tier3Wrappers is empty the function is a no-op. +func collectCompositeLiteralFields(f *ast.File, tier3 map[string]bool) map[string]map[string]bool { + out := map[string]map[string]bool{} + if len(tier3) == 0 { + return out + } + addField := func(wrapper, field string) { + if !tier3[wrapper] { + return + } + set := out[wrapper] + if set == nil { + set = map[string]bool{} + out[wrapper] = set + } + set[field] = true + } + for _, decl := range f.Decls { + fd, ok := decl.(*ast.FuncDecl) + if !ok || fd.Body == nil { + continue + } + bindings := map[string]string{} // path -> tier-3 wrapper type bound to it + ast.Inspect(fd.Body, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.CompositeLit: + if t := litTypeName(node.Type); tier3[t] { + for _, elt := range node.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + if key, ok := kv.Key.(*ast.Ident); ok { + addField(t, key.Name) + } + } + } + case *ast.AssignStmt: + // Record any LHS-path -> tier-3-wrapper binding so subsequent + // selector writes can be attributed to the wrapper. + if len(node.Lhs) == len(node.Rhs) { + for i, rhs := range node.Rhs { + if t := compositeLitTypeName(rhs); tier3[t] { + if path := exprToPath(node.Lhs[i]); path != "" { + bindings[path] = t + } + } + } + } + // Attribute selector-target writes to any bound path. + for _, lhs := range node.Lhs { + if prefix, field := pathPrefixAndField(lhs); field != "" { + if wrapper, ok := bindings[prefix]; ok { + addField(wrapper, field) + } + } + } + case *ast.IncDecStmt: + if prefix, field := pathPrefixAndField(node.X); field != "" { + if wrapper, ok := bindings[prefix]; ok { + addField(wrapper, field) + } + } + } + return true + }) + } + return out +} + // extractGeneratedTypeName recognizes `generated.X` (SelectorExpr) and returns // X. Returns "" otherwise. func extractGeneratedTypeName(expr ast.Expr) string { From 010cc49c7b4cab7949d201b8170e814c95c03dae Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 28 May 2026 10:50:46 -0700 Subject: [PATCH 23/26] scripts: regression tests for tier-3 composite-literal walker Covers the two construction forms (pointer Field: &Wrapper{...} and bare Wrapper{...}) and the two binding shapes (bare local resp.X = ... and selector chain q.Schedule.X = ...) that the tier-3 corpus uses, plus the dropped-assignment teeth proof for each literal form. Unit tests for the new helpers (collectCompositeLiteralFields, exprToPath, pathPrefixAndField) cover identifier roots, deep selector chains, and non-identifier-rooted expressions (calls, index expressions) that the walker must safely ignore. --- scripts/check-wrapper-drift/main_test.go | 463 ++++++++++++++++++++++- 1 file changed, 453 insertions(+), 10 deletions(-) diff --git a/scripts/check-wrapper-drift/main_test.go b/scripts/check-wrapper-drift/main_test.go index 5b06c8e64..12cfc21ab 100644 --- a/scripts/check-wrapper-drift/main_test.go +++ b/scripts/check-wrapper-drift/main_test.go @@ -193,7 +193,7 @@ func fooFromGenerated(g generated.Foo) Foo { } ` wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"foo.go": wrapperSrc}) - if err := run(wrapperDir, generatedFile, nil, false); err != nil { + if err := run(wrapperDir, generatedFile, nil, nil, false); err != nil { t.Errorf("run: expected no drift, got %v", err) } } @@ -225,7 +225,7 @@ func barFromGenerated(g generated.Bar) Bar { } ` wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"bar.go": wrapperSrc}) - if err := run(wrapperDir, generatedFile, nil, false); err == nil { + if err := run(wrapperDir, generatedFile, nil, nil, false); err == nil { t.Error("run: expected drift on missing tag new_field, got nil") } } @@ -258,7 +258,7 @@ func bazFromGenerated(g generated.Baz) Baz { } ` wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"baz.go": wrapperSrc}) - if err := run(wrapperDir, generatedFile, nil, false); err == nil { + if err := run(wrapperDir, generatedFile, nil, nil, false); err == nil { t.Error("run: expected population drift on unassigned Tagline, got nil") } } @@ -302,7 +302,7 @@ func wrapFromGenerated(g generated.Wrap) Wrap { } ` wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"wrap.go": wrapperSrc}) - if err := run(wrapperDir, generatedFile, nil, false); err == nil { + if err := run(wrapperDir, generatedFile, nil, nil, false); err == nil { t.Error("run: expected population drift on Wrap.Name (only a helper local assigns name), got nil") } } @@ -337,7 +337,7 @@ func quxFromGenerated(g generated.Qux) Qux { } ` wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"qux.go": wrapperSrc}) - if err := run(wrapperDir, generatedFile, nil, false); err != nil { + if err := run(wrapperDir, generatedFile, nil, nil, false); err != nil { t.Errorf("run: expected no drift (all fields assigned), got %v", err) } } @@ -368,7 +368,7 @@ func fooFromGenerated(g generated.Foo) Foo { } ` wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"foo.go": wrapperSrc}) - if err := run(wrapperDir, generatedFile, nil, false); err == nil { + if err := run(wrapperDir, generatedFile, nil, nil, false); err == nil { t.Error("run: expected drift on stale omit marker not_a_real_tag, got nil") } } @@ -406,7 +406,7 @@ type MyAssignmentsResult struct { "MyAssignment": "MyAssignment", } wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"my_assignments.go": wrapperOK}) - if err := run(wrapperDir, generatedFile, pairs, false); err != nil { + if err := run(wrapperDir, generatedFile, pairs, nil, false); err != nil { t.Errorf("run (in-sync renamed direct-decode pair): expected no drift, got %v", err) } @@ -421,7 +421,7 @@ type MyAssignmentsResult struct { } ` wrapperDir, generatedFile = writeDriftFixtures(t, genSrc, map[string]string{"my_assignments.go": wrapperMissing}) - if err := run(wrapperDir, generatedFile, pairs, false); err == nil { + if err := run(wrapperDir, generatedFile, pairs, nil, false); err == nil { t.Error("run (renamed direct-decode pair missing non_priorities): expected drift, got nil") } } @@ -471,7 +471,7 @@ func parentFromGenerated(g generated.Parent) Parent { ` pairs := map[string]string{"Nested": "Nested"} wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"parent.go": wrapperOK}) - if err := run(wrapperDir, generatedFile, pairs, false); err != nil { + if err := run(wrapperDir, generatedFile, pairs, nil, false); err != nil { t.Errorf("run (in-sync inline-converted pair): expected no drift, got %v", err) } @@ -496,7 +496,7 @@ func parentFromGenerated(g generated.Parent) Parent { } ` wrapperDir, generatedFile = writeDriftFixtures(t, genSrc, map[string]string{"parent.go": wrapperMissing}) - if err := run(wrapperDir, generatedFile, pairs, false); err == nil { + if err := run(wrapperDir, generatedFile, pairs, nil, false); err == nil { t.Error("run (inline-converted pair missing nested color tag): expected drift, got nil") } } @@ -626,3 +626,446 @@ func TestExtractJSONTag_DashSentinel(t *testing.T) { t.Error("expected `-` to be captured from `json:\"-,omitempty\"`") } } + +// TestRun_Tier3PointerLiteralInSync drives run() with a tier-3 pair populated +// by the pointer `Field: &Wrapper{...}` form inside a parent *FromGenerated. +// Every generated tag on the tier-3 wrapper is assigned by the composite +// literal, so the population check must pass. +func TestRun_Tier3PointerLiteralInSync(t *testing.T) { + genSrc := `package generated + +type Parent struct { + Id int64 ` + "`json:\"id\"`" + ` + OnHold OnHold ` + "`json:\"on_hold,omitempty\"`" + ` +} +type OnHold struct { + Id int64 ` + "`json:\"id\"`" + ` + Status string ` + "`json:\"status\"`" + ` + Title string ` + "`json:\"title\"`" + ` +} +` + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type OnHold struct { + ID int64 ` + "`json:\"id\"`" + ` + Status string ` + "`json:\"status\"`" + ` + Title string ` + "`json:\"title\"`" + ` +} +type Parent struct { + ID int64 ` + "`json:\"id\"`" + ` + OnHold *OnHold ` + "`json:\"on_hold,omitempty\"`" + ` +} + +func parentFromGenerated(g generated.Parent) Parent { + p := Parent{} + p.ID = g.Id + if g.OnHold.Id != 0 { + p.OnHold = &OnHold{ + ID: g.OnHold.Id, + Status: g.OnHold.Status, + Title: g.OnHold.Title, + } + } + return p +} +` + pairs := map[string]string{"OnHold": "OnHold"} + tier3 := map[string]bool{"OnHold": true} + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"parent.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, pairs, tier3, false); err != nil { + t.Errorf("run (tier-3 pointer literal in sync): expected no drift, got %v", err) + } +} + +// TestRun_Tier3PointerLiteralDroppedAssignment is the teeth proof for the +// composite-literal walker: the wrapper declares the right tags, but the +// inline `&OnHold{...}` in the parent's body silently drops one assignment. +// The new tier-3 population check must catch it. Before this change the same +// fixture would have passed (tier-3 was tag-presence-only / reviewer-enforced). +func TestRun_Tier3PointerLiteralDroppedAssignment(t *testing.T) { + genSrc := `package generated + +type Parent struct { + Id int64 ` + "`json:\"id\"`" + ` + OnHold OnHold ` + "`json:\"on_hold,omitempty\"`" + ` +} +type OnHold struct { + Id int64 ` + "`json:\"id\"`" + ` + Status string ` + "`json:\"status\"`" + ` + Title string ` + "`json:\"title\"`" + ` +} +` + // Title tag is declared on the wrapper but the composite literal omits it. + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type OnHold struct { + ID int64 ` + "`json:\"id\"`" + ` + Status string ` + "`json:\"status\"`" + ` + Title string ` + "`json:\"title\"`" + ` +} +type Parent struct { + ID int64 ` + "`json:\"id\"`" + ` + OnHold *OnHold ` + "`json:\"on_hold,omitempty\"`" + ` +} + +func parentFromGenerated(g generated.Parent) Parent { + p := Parent{} + p.ID = g.Id + if g.OnHold.Id != 0 { + p.OnHold = &OnHold{ + ID: g.OnHold.Id, + Status: g.OnHold.Status, + } + } + return p +} +` + pairs := map[string]string{"OnHold": "OnHold"} + tier3 := map[string]bool{"OnHold": true} + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"parent.go": wrapperSrc}) + err := run(wrapperDir, generatedFile, pairs, tier3, false) + if err == nil { + t.Fatal("run (tier-3 dropped assignment): expected population drift on Title, got nil") + } + if !strings.Contains(err.Error(), "wrapper drift") { + t.Errorf("run: expected wrapper drift error, got %v", err) + } +} + +// TestRun_Tier3BareLiteralInSync covers the bare `Wrapper{...}` (non-pointer) +// construction form — the shape LineupMarker, HillChartDot, SearchProject, and +// CampfireLineAttachment take inside an append/index-assign. Every generated +// tag is assigned by the literal, so the population check must pass. +func TestRun_Tier3BareLiteralInSync(t *testing.T) { + genSrc := `package generated + +type ParentList struct { + Markers []Marker ` + "`json:\"markers,omitempty\"`" + ` +} +type Marker struct { + Id int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` +} +` + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Marker struct { + ID int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` +} +type ParentList struct { + Markers []Marker ` + "`json:\"markers,omitempty\"`" + ` +} + +func parentListFromGenerated(g generated.ParentList) ParentList { + pl := ParentList{} + for _, gm := range g.Markers { + pl.Markers = append(pl.Markers, Marker{ + ID: gm.Id, + Name: gm.Name, + }) + } + return pl +} +` + pairs := map[string]string{"Marker": "Marker"} + tier3 := map[string]bool{"Marker": true} + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"parent.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, pairs, tier3, false); err != nil { + t.Errorf("run (tier-3 bare literal in sync): expected no drift, got %v", err) + } +} + +// TestRun_Tier3BareLiteralDroppedAssignment proves the bare-literal form also +// catches dropped assignments — a regression check independent of the pointer +// form. The wrapper declares both tags but the inline literal in the for-loop +// silently drops Name. +func TestRun_Tier3BareLiteralDroppedAssignment(t *testing.T) { + genSrc := `package generated + +type ParentList struct { + Markers []Marker ` + "`json:\"markers,omitempty\"`" + ` +} +type Marker struct { + Id int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` +} +` + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Marker struct { + ID int64 ` + "`json:\"id\"`" + ` + Name string ` + "`json:\"name\"`" + ` +} +type ParentList struct { + Markers []Marker ` + "`json:\"markers,omitempty\"`" + ` +} + +func parentListFromGenerated(g generated.ParentList) ParentList { + pl := ParentList{} + for _, gm := range g.Markers { + pl.Markers = append(pl.Markers, Marker{ + ID: gm.Id, + }) + } + return pl +} +` + pairs := map[string]string{"Marker": "Marker"} + tier3 := map[string]bool{"Marker": true} + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"parent.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, pairs, tier3, false); err == nil { + t.Error("run (tier-3 bare literal missing Name): expected population drift, got nil") + } +} + +// TestRun_Tier3LocalBoundSelectorWrites covers the shape that +// ClientApprovalResponse and UpdateProjectAccessResponse take in the real +// corpus: the wrapper is bound to a local via `resp := Wrapper{...}` and then +// fields are written by subsequent `resp.X = ...` selector statements. The +// walker must attribute those writes to the wrapper, so every generated tag +// counts as populated. +func TestRun_Tier3LocalBoundSelectorWrites(t *testing.T) { + genSrc := `package generated + +type ParentList struct { + Items []Item ` + "`json:\"items,omitempty\"`" + ` +} +type Item struct { + Id int64 ` + "`json:\"id\"`" + ` + Status string ` + "`json:\"status\"`" + ` + Title string ` + "`json:\"title\"`" + ` +} +` + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Item struct { + ID int64 ` + "`json:\"id\"`" + ` + Status string ` + "`json:\"status\"`" + ` + Title string ` + "`json:\"title\"`" + ` +} +type ParentList struct { + Items []Item ` + "`json:\"items,omitempty\"`" + ` +} + +func parentListFromGenerated(g generated.ParentList) ParentList { + pl := ParentList{} + for _, gi := range g.Items { + resp := Item{Status: gi.Status} + resp.ID = gi.Id + resp.Title = gi.Title + pl.Items = append(pl.Items, resp) + } + return pl +} +` + pairs := map[string]string{"Item": "Item"} + tier3 := map[string]bool{"Item": true} + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"parent.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, pairs, tier3, false); err != nil { + t.Errorf("run (tier-3 local-bound + selector writes): expected no drift, got %v", err) + } +} + +// TestRun_Tier3SelectorChainBoundWrites covers the shape QuestionSchedule +// takes: the wrapper is bound to a selector chain (`q.Schedule = +// &Wrapper{...}`) and conditional `q.Schedule.X = ...` writes set the +// remaining fields. The walker must track selector-chain bindings, not just +// bare-identifier locals. +func TestRun_Tier3SelectorChainBoundWrites(t *testing.T) { + genSrc := `package generated + +type Parent struct { + Schedule Schedule ` + "`json:\"schedule,omitempty\"`" + ` +} +type Schedule struct { + Frequency string ` + "`json:\"frequency\"`" + ` + WeekInstance int32 ` + "`json:\"week_instance,omitempty\"`" + ` +} +` + wrapperSrc := `package basecamp + +import "github.com/basecamp/basecamp-sdk/go/pkg/generated" + +type Schedule struct { + Frequency string ` + "`json:\"frequency\"`" + ` + WeekInstance *int ` + "`json:\"week_instance,omitempty\"`" + ` +} +type Parent struct { + Schedule *Schedule ` + "`json:\"schedule,omitempty\"`" + ` +} + +func parentFromGenerated(g generated.Parent) Parent { + q := Parent{} + if g.Schedule.Frequency != "" { + q.Schedule = &Schedule{ + Frequency: g.Schedule.Frequency, + } + if g.Schedule.WeekInstance != 0 { + wi := int(g.Schedule.WeekInstance) + q.Schedule.WeekInstance = &wi + } + } + return q +} +` + pairs := map[string]string{"Schedule": "Schedule"} + tier3 := map[string]bool{"Schedule": true} + wrapperDir, generatedFile := writeDriftFixtures(t, genSrc, map[string]string{"parent.go": wrapperSrc}) + if err := run(wrapperDir, generatedFile, pairs, tier3, false); err != nil { + t.Errorf("run (tier-3 selector-chain binding): expected no drift, got %v", err) + } +} + +// TestCollectCompositeLiteralFields verifies the walker collects keys from +// both bare and pointer composite literals, attributes subsequent selector +// writes against bound locals (`resp.X`) and bound selector chains +// (`q.Schedule.X`), and ignores composite literals of types not in tier3. +func TestCollectCompositeLiteralFields(t *testing.T) { + src := `package basecamp + +type Marker struct{ ID int64; Name string } +type Item struct{ ID int64; Status string; Title string } +type Schedule struct{ Frequency string; WeekInstance *int } +type Other struct{ X string } // not in tier3; must be ignored +type wrap struct{ M *Marker } +type parent struct{ Schedule *Schedule } + +func _build() { + // Bare literal inside a slice — keys must be collected. + _ = []Marker{{ID: 1, Name: "n"}} + + // Pointer literal as a field assignment. + w := wrap{} + w.M = &Marker{ID: 2, Name: "n2"} + + // Local-bound + selector writes. + resp := Item{Status: "active"} + resp.ID = 3 + resp.Title = "t" + _ = resp + + // Selector-chain binding + chain selector writes. + q := parent{} + q.Schedule = &Schedule{Frequency: "daily"} + wi := 1 + q.Schedule.WeekInstance = &wi + + // Other type — must not appear in output. + _ = Other{X: "ignored"} +} +` + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "wrapper.go", src, parser.ParseComments) + if err != nil { + t.Fatalf("parse: %v", err) + } + tier3 := map[string]bool{"Marker": true, "Item": true, "Schedule": true} + got := collectCompositeLiteralFields(f, tier3) + for _, want := range []string{"ID", "Name"} { + if !got["Marker"][want] { + t.Errorf("Marker: expected %q in assigned set, got %v", want, got["Marker"]) + } + } + for _, want := range []string{"ID", "Status", "Title"} { + if !got["Item"][want] { + t.Errorf("Item: expected %q in assigned set, got %v", want, got["Item"]) + } + } + for _, want := range []string{"Frequency", "WeekInstance"} { + if !got["Schedule"][want] { + t.Errorf("Schedule: expected %q in assigned set, got %v", want, got["Schedule"]) + } + } + if _, ok := got["Other"]; ok { + t.Errorf("Other is not in tier3 and must not be in the output: %v", got["Other"]) + } +} + +// TestCollectCompositeLiteralFields_EmptyTier3 confirms the walker is a no-op +// when tier3 is empty — preserves the tier-2-only semantics callers rely on +// when they don't want any composite-literal sourcing. +func TestCollectCompositeLiteralFields_EmptyTier3(t *testing.T) { + src := `package basecamp + +type Marker struct{ ID int64 } + +func _f() { _ = []Marker{{ID: 1}} } +` + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "wrapper.go", src, parser.ParseComments) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got := collectCompositeLiteralFields(f, nil); len(got) != 0 { + t.Errorf("expected empty output with nil tier3, got %v", got) + } + if got := collectCompositeLiteralFields(f, map[string]bool{}); len(got) != 0 { + t.Errorf("expected empty output with empty tier3, got %v", got) + } +} + +// TestExprToPath verifies the dotted-path conversion the composite-literal +// walker uses to key its bindings. Identifier roots are preserved, deeper +// chains are joined with dots, and anything not identifier-rooted returns "". +func TestExprToPath(t *testing.T) { + cases := []struct { + src string + want string + }{ + {"x", "x"}, + {"x.y", "x.y"}, + {"x.y.z", "x.y.z"}, + {"f()", ""}, // call — not identifier-rooted + {"a[0]", ""}, // index — not identifier-rooted + {"a[0].b", ""}, // index inside a chain + } + for _, c := range cases { + expr, err := parser.ParseExpr(c.src) + if err != nil { + t.Fatalf("parse %q: %v", c.src, err) + } + if got := exprToPath(expr); got != c.want { + t.Errorf("exprToPath(%q) = %q, want %q", c.src, got, c.want) + } + } +} + +// TestPathPrefixAndField verifies the decomposition the walker uses to +// attribute selector writes (`q.Schedule.WeekInstance`) to a previously +// recorded binding (`q.Schedule`). +func TestPathPrefixAndField(t *testing.T) { + cases := []struct { + src string + wantPrefix string + wantField string + }{ + {"x.Y", "x", "Y"}, + {"x.Y.Z", "x.Y", "Z"}, + {"a.b.c.d", "a.b.c", "d"}, + {"x", "", ""}, // bare ident — no selector + {"f().Y", "", ""}, // call-rooted — no path + {"a[0].Y", "", ""}, // index-rooted — no path + } + for _, c := range cases { + expr, err := parser.ParseExpr(c.src) + if err != nil { + t.Fatalf("parse %q: %v", c.src, err) + } + prefix, field := pathPrefixAndField(expr) + if prefix != c.wantPrefix || field != c.wantField { + t.Errorf("pathPrefixAndField(%q) = (%q, %q), want (%q, %q)", + c.src, prefix, field, c.wantPrefix, c.wantField) + } + } +} From a25995e1bb72722bb7be2ea999cde01404c5efd2 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 28 May 2026 10:57:29 -0700 Subject: [PATCH 24/26] scripts: run wrapper-drift tests as part of the make target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The go-check-wrapper-drift target only invoked `go run` on the checker, so `scripts/check-wrapper-drift/main_test.go` (incl. the new tier-3 walker regression tests) was never exercised by `make check` or CI's gate — the walker could silently regress while drift kept reporting clean. Run `go test ./scripts/check-wrapper-drift/` as the first step of the target so test failures surface before the drift check itself runs. --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index e2acf4fde..ef7dcb31c 100644 --- a/Makefile +++ b/Makefile @@ -227,6 +227,8 @@ go-check-drift: # wrappers in go/pkg/basecamp/. Sibling of go-check-drift; that check is # operation-level, this one is field-level. go-check-wrapper-drift: + @echo "==> Running wrapper-drift checker tests..." + @go test ./scripts/check-wrapper-drift/ @echo "==> Checking wrapper field-level drift..." @go run ./scripts/check-wrapper-drift/ From 7116bd5c936a41e0265428f022fb626c94dc0ad5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 28 May 2026 11:03:16 -0700 Subject: [PATCH 25/26] wrappers: extend required-bool serialization test to Inbox/Forward/ForwardReply/TimesheetEntry The PR dropped omitempty from non-omitempty bool response fields on these four wrappers alongside Todo/Comment/Message/MessageBoard, but the regression test only pinned the latter four. Symmetric coverage: add the four missing cases so a future accidental re-introduction of omitempty on any of these wrappers fails the test instead of silently dropping false on the wire again. --- go/pkg/basecamp/wrapper_propagation_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/go/pkg/basecamp/wrapper_propagation_test.go b/go/pkg/basecamp/wrapper_propagation_test.go index 704e4e2fa..d5bb43a34 100644 --- a/go/pkg/basecamp/wrapper_propagation_test.go +++ b/go/pkg/basecamp/wrapper_propagation_test.go @@ -879,4 +879,8 @@ func TestRequiredBools_FalseMarshalsExplicitly(t *testing.T) { hasKey(t, Comment{}, "visible_to_clients", "inherits_status") hasKey(t, Message{}, "visible_to_clients", "inherits_status") hasKey(t, MessageBoard{}, "visible_to_clients", "inherits_status") + hasKey(t, Inbox{}, "visible_to_clients", "inherits_status") + hasKey(t, Forward{}, "visible_to_clients", "inherits_status") + hasKey(t, ForwardReply{}, "visible_to_clients", "inherits_status") + hasKey(t, TimesheetEntry{}, "visible_to_clients", "inherits_status") } From d4fe3158253b677d2ac4a4109af07118af45cfea Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 21 Jul 2026 20:31:22 -0700 Subject: [PATCH 26/26] wrappers: delegate ProjectConstruction.Project to projectFromGenerated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline `&Project{...}` literal in projectConstructionFromGenerated predated the audit and silently dropped StartDate, EndDate, ClientsEnabled, BookmarkURL, Dock, Bookmarked, ClientCompany, and Clientside — fields the helper already converts. Composite-literal population checking only runs for tier-3 wrappers, so a literal for a type that also has a tier-1 helper escaped the walker. Delegating to the helper closes the gap the same way nested Person sites were standardized on personFromGenerated; a scan for other inline wrapper literals outside their helpers found no other sites. --- go/pkg/basecamp/templates.go | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/go/pkg/basecamp/templates.go b/go/pkg/basecamp/templates.go index 4d55fc982..26deceda8 100644 --- a/go/pkg/basecamp/templates.go +++ b/go/pkg/basecamp/templates.go @@ -416,17 +416,8 @@ func projectConstructionFromGenerated(gc generated.ProjectConstruction) ProjectC } if gc.Project.Id != 0 || gc.Project.Name != "" { - c.Project = &Project{ - Name: gc.Project.Name, - Description: gc.Project.Description, - Purpose: gc.Project.Purpose, - CreatedAt: gc.Project.CreatedAt, - UpdatedAt: gc.Project.UpdatedAt, - Status: gc.Project.Status, - URL: gc.Project.Url, - AppURL: gc.Project.AppUrl, - } - c.Project.ID = gc.Project.Id + project := projectFromGenerated(gc.Project) + c.Project = &project } return c