feat(npm): streampub error taxonomy (S11 Step 13, part 1/8)

📦 What this MR does

First of an 8-MR stack that splits the streampub streaming publish-envelope library (S11 Step 13) into independently reviewable parts. When the top of the stack merges, the resulting tree reproduces the single-MR reference implementation in merge request !420, except for the intentional improvements listed under Deviations below.

This is Part 1/8, targeting main (the bottom of the stack). Parts 2–8 chain on top of it; GitLab retargets each child to main as its parent merges, so the stack merges bottom-up.

This part lands the error taxonomy in isolation:

  • error.go — the typed *ProcessError (Code / Error / Unwrap / newError) plus the two sentinels and spec codes referenced first (bad_request, publish_envelope_invalid). Error() returns only the spec code, never the wrapped cause, so attacker-controlled input (tar entry names, attachment keys) cannot leak through anything that formats the error.
  • docs/plans/2026-05-11-npm-local.md — corrects the Step 8 depguard scope (the format-isolation deny rule prefix-matches internal/format/, so it needs a targeted allow-rule for the streampub subpackage) and the Naming Conventions row.

The remaining sentinels and spec codes land in later parts as their first real caller appears — the unused linter forbids landing an unexported sentinel before something references it.

🚩 Deviations from the reference (!420 (closed))

Review of this isolated part surfaced three error.go API improvements that the reference does not have. They are applied here (and propagated through Parts 2–8), so the final stack tree differs from !420 (closed) by exactly these:

  • Error type renamed ErrorProcessError — descriptive XxxError convention, fixed before the type propagates through the rest of the stack.
  • CodeStr field unexported → code — a single access path through Code(); callers can no longer read or overwrite it directly.
  • Wrapped field unexported → wrapped — reachable only via Unwrap() (errors.Is / errors.As), so Error()'s suppression of attacker-controlled input is structural rather than opt-in: no caller can read the cause as a bare field and format it into a log line or response.

AbortOffset stays exported (the publish handler reads it; it carries an offset, not client input). Aside from these three changes and the Step 13 plan Status-table row (added by the top of the stack), the merged stack equals !420 (closed).

Review follow-up (98122db5) corrected three code comments left stale by the rename above — two in error.go and one in error_test.go that still named the pre-rename *Error / Wrapped, plus one that inaccurately said the wrapped cause is reachable "via %w" (Error() never %w-wraps it; only Unwrap reaches it). Comment-only; completes the rename and adds no new behavioral deviation.

🧪 Tests

error_test.go is throwaway scaffolding, deleted by Part 2/8. error.go's unexported helpers and sentinels have no in-package caller until the tokenizer lands, so this file drives them directly to satisfy the unused and err113 linters. Part 2/8 introduces the walker and a black-box suite that exercises *ProcessError through Process, and removes this file. The file header says so explicitly.

  • golangci-lint (repo-pinned v2.12) → 0 issues
  • go test -race -count=1 ./internal/format/npm/streampub/... → pass
Edited by David Fernandez

Merge request reports

Loading
Loading