basecamp / basecamp/basecamp-sdk
Thirteen operations report a wrong resourceType, and the drift gate locks them in
- Dominant language
- Go
- Stars
- 49
- Forks
- 12
- Avg merge
- 20h 47m
- Merged PRs (30d)
- 89
Description
## resourceType: ten generated ops emit the generic `"resource"`, and TypeScript mangles three `-ies` plurals its peers get right
### Summary
`extractResourceType` has two unguarded exits that return the literal `"resource"`. On `main@c95d81ceb` this affects **10 of 241 operations**, identically in TypeScript, Kotlin, and Swift. Separately, TypeScript's naive singularizer regex emits **3 values that disagree with Kotlin and Swift** for the same operationId.
`resourceType` is observability-only (OTel span attribute / slog field), so nothing functionally breaks. The cost is dashboard and alert grouping.
### The ten generic operations
`RemoveAccountLogo`, `DestroyGaugeNeedle`, `ToggleGauge`, `PrioritizeAssignment`, `DeprioritizeAssignment`, `ReorderUpNext`, `MarkAsRead`, `Subscribe`, `Unsubscribe`, `Search`
Two causes:
- **Seven** have an operationId whose verb prefix is absent from `VERB_PATTERNS` (`Remove`, `Destroy`, `Toggle`, `Prioritize`, `Deprioritize`, `Reorder`, `MarkAsRead`) and fall through to the final `return "resource"` (`typescript/scripts/generate-services.ts:695`).
- **Three** (`Subscribe`, `Unsubscribe`, `Search`) match a prefix but leave an empty remainder, hitting `if (!remainder) return "resource"` (`:687`).
### Why this is a defect and not a naming judgment call
The repo already adopted the rule, in `RESOURCE_TYPE_OVERRIDES` (`typescript/scripts/generate-services.ts:315-318`):
```
// "Destroy" is not a verb pattern, so inference falls through to the generic
// "resource" and would split this delete away from the get/update siblings
// that report "timesheet_entry".
DestroyTimesheetEntry: "timesheet_entry",
```
`DestroyGaugeNeedle` is the same shape and was not fixed. In `gauges.ts` the split is visible in one file:
```
GetGaugeNeedle -> "gauge_needle"
UpdateGaugeNeedle -> "gauge_needle"
ListGaugeNeedles -> "gauge_needle"
CreateGaugeNeedle -> "gauge_needle"
DestroyGaugeNeedle -> "resource" <-- split from its own family
ToggleGauge -> "resource"
```
Same in `subscriptions.ts` (`Subscribe`/`Unsubscribe` split from `GetSubscription`/`UpdateSubscription` = `"subscription"`).
Worse than the split: the ten also **falsely merge**. A dashboard grouped by `basecamp.resource_type` puts gauge deletes, notification reads, subscription changes, account-logo removal, assignment reordering, and search into a single `"resource"` bucket spanning six unrelated services.
### The cross-SDK divergence (strictly worse)
TypeScript: `snakeCase.replace(/([^s])s$/, "$1")`. Kotlin and Swift implement real `ies -> y` rules.
| operationId | TypeScript | Kotlin | Swift |
|---|---|---|---|
| `ListClientReplies` | `client_replie` | `client_reply` | `client_reply` |
| `ListForwardReplies` | `forward_replie` | `forward_reply` | `forward_reply` |
| `ListScheduleEntries` | `schedule_entrie` | `schedule_entry` | `schedule_entry` |
The Swift generator's docstring names the expected output outright (`swift/Sources/BasecampGenerator/Utilities.swift:89-91`): `"schedule_entries" -> "schedule_entry"`, `"client_replies" -> "client_reply"`. So the TypeScript values are wrong against the project's own stated intent, not a house-style difference.
### Scope
- **Affected:** TypeScript, Kotlin, Swift (all verb-table-derived).
- **Not affected:** Go — `pkg/basecamp` is hand-written and assigns real nouns (`gauges.go:436` `"gauge_needle"`, `subscriptions.go:74` `"subscription"`, `search.go:182` `"search"`).
- **N/A:** Python and Ruby never populate the field at all — `python/src/basecamp/hooks.py:12` defaults it to `None`, `ruby/.../base_service.rb:43` to `nil`, for every operation.
### Why `make check` does not catch it
`git grep -l 'resourceType\|resource_type' HEAD -- scripts/ .github/` returns exit 1 — no matches. None of the targets in `check:` (`Makefile:1147`) inspects the field, and `conformance/` has zero references.
The three regenerate-and-diff freshness gates make this worse, not better: they prove each emitter reproduces its committed bytes, so they **lock the wrong values in**. `scripts/check-typescript-service-drift.sh` passes green today (verified, exit 0) with all thirteen wrong values present. `scripts/check-retry-metadata-parity.py:8-12` draws exactly this distinction in its own docstring.
### Suggested fix
1. Add the missing verbs to `VERB_PATTERNS`, or add the ten to `RESOURCE_TYPE_OVERRIDES` — the same one-line-per-op mechanism already used for `DestroyTimesheetEntry`. Do it in all three generators.
2. Fix the TypeScript singularizer to match Kotlin/Swift (`ies -> y`, `sses`/`ss` guards).
3. Add a value-parity gate modeled on `scripts/check-retry-metadata-parity.py` (~60 lines): parse `(operationId, resourceType)` from the three committed artifact trees and assert (a) all three SDKs agree, (b) no value is the literal `"resource"` outside a commented allowlist. Go cannot join this gate — its `OperationInfo.Operation` uses short names (`"RemoveLogo"`, `"DestroyNeedle"`, `"Metadata"`), not canonical operationIds, so there is no join key.
Note that (1) and (2) change emitted telemetry strings, which will re-bucket existing dashboards — ship in a minor release, not a patch.
Contributor guide
Research direction
Start in typescript/scripts/generate-services.ts at VERB_PATTERNS, RESOURCE_TYPE_OVERRIDES, and the singularizer, then compare the corresponding Kotlin and Swift generators, including swift/Sources/BasecampGenerator/Utilities.swift. Run scripts/check-typescript-service-drift.sh and use scripts/check-retry-metadata-parity.py as the model for the cross-SDK gate. Done means the affected generated artifacts agree across TypeScript, Kotlin, and Swift and generic resource values are absent except for an explicit allowlist.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kotlin, swift, typescript
- Domain
- observability, testing, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100