feat(config): NpmConfig proto + config loader (S11 npm local Step 3)

🎉 MR 400 is mine, MINE, MINE! 🎉

📦 Summary

Implements Step 3 of the npm local plan (docs/plans/2026-05-11-npm-local.md) — the NpmConfig proto message plus the npm-specific config-loader extension (S11). It extends the existing internal/config loader (already wired into assembleApp); it does not build a new loader or touch the startup path.

Part of #22 (closed) (S11 npm local). Satisfies AC 62.

What changed

  • Add NpmConfig to proto/artifactregistry/config/v1/config.proto (9 spec-defined fields plus the interim streampub_buffer_size consumed by the Step 13 streampub library) and regenerate the Go bindings. Config tag 3 stays reserved.
  • Surface NpmConfig on the typed Config. The npm block and every field are optional and defaulted at startup in npmFromProto, following docs/dev/configuration.md ("the application is responsible for applying defaults at startup") and the existing logFromProto pattern: an omitted block or field receives its documented default. Per-field protovalidate constraints are therefore intentionally absent (an empty/zero value means "use the default"); provided values are still parsed, and the two cross-field invariants — the 20 KB max_package_json_size ceiling (matching the Step 2 npm_versions CHECK) and max_envelope_dist_tags <= max_tags_per_package — are enforced Go-side with startup errors naming the offending field(s).
  • Extend the shared size parser to accept decimal-prefixed sizes (the 6.7GB max_publish_envelope_size default) with exact integer arithmetic, and harden it against a negative-zero integer part and fractional overflow.
  • Add the npm: block to config.example.yaml (exercised end-to-end by the config.Load guard test).

The generic JSON-safety bounds (max_envelope_depth, max_envelope_object_keys) are intentionally not on NpmConfig: per the S11 spec they are platform-level shared streaming-decoder config (work item #90).

📝 Spec & plan reconciliation (work item #95 drift policy)

Two doc reconciliations ship in this MR (per the #95 "drift corrections land in the implementing MR" policy):

  • Plan: Step 3's scope misread !235 (merged) — it listed the depth/key bounds as NpmConfig fields, but !235 (merged) amended the spec to place them at the platform level (#90). Corrected in a docs(plan): commit, with the Files: entry reconciled to the actual diff.
  • Spec (S11): the Configuration proto block defined every npm field with min_len/gt. To apply defaults per configuration.md, those per-field constraints are dropped and the Go-side defaulting documented — amended here so spec, proto, and code stay in sync.

Spec coverage (AC 62)

Item Behavior Tests
AC 62 Config loads from YAML via LabKit v2; an unparseable value in any npm size or duration field -> startup error naming that field TestLoad_Npm_InvalidSizeFields, TestLoad_Npm_InvalidDurationFields (table-driven over every field)
AC 62 (per-step) Valid YAML round-trips; every NpmConfig field asserted (sizes, durations, counters, envelope fields, streampub_buffer_size) TestLoad_Npm_HappyPath
Defaulting Omitted npm block -> all documented defaults; partial block -> omitted fields defaulted TestLoad_Npm_AbsentBlockDefaults, TestLoad_Npm_PartialBlockDefaultsOmittedFields
Step 3 max_package_json_size > 20KB -> startup error naming the field TestLoad_Npm_PackageJSONSizeCeiling
Step 3 max_envelope_dist_tags > max_tags_per_package -> error naming both fields; equal values accepted TestLoad_Npm_EnvelopeDistTagsInvariant (both subtests)
Size parser Decimal, zero-fraction, non-whole-fraction, malformed, negative-zero, 19-digit-fraction, fractional-sum overflow, numerator*multiplier overflow, negative fractional digits TestParseSize

🧪 Testing

  • Unit: internal/config — round-trip, defaulting (absent + partial block), per-field invalid duration/size (table-driven), the 20 KB ceiling, the cross-field invariant (incl. the inclusive boundary), and decimal/fractional/edge-case size parsing. npm.go and size.go are at 100% statement coverage.
  • config.example.yaml is validated through the real config.Load by the existing guard test.
  • go build ./... clean; go test ./... green.

ℹ️ Notes for review

  • Test-first authorship: the branch opens test(config) -> feat(config) -> refactor(...), then hardening, reconciliation, and a final coverage + comment-tightening pair (test(config) + docs(config)). Exactly one --no-verify commit (the test(config) panic-skeleton, per the documented test-first carve-out); every later commit ran the full hook chain.
  • Step 13's plan scope still references npm.max_envelope_depth / npm.max_envelope_object_keys; correcting those depends on the #90 platform-decoder design and is left to Step 13 / #90.
  • ServerConfig documents per-field defaults in comments but requires the fields (it does not apply defaults); aligning it with configuration.md is out of scope here.
Edited by David Fernandez

Merge request reports

Loading
Loading