feat(npm): S11 validation helpers (Step 7)
What
Step 7 of the npm-local plan (docs/plans/2026-05-11-npm-local.md): the pure
validation helpers in internal/format/npm/validate.go that back the publish,
dist-tag, and download handlers (wired in later steps). Dependency-free pure
functions:
ValidatePackageName/ParseScope- npm name rules (1-214 bytes, lowercase, leading-char, whitespace, URL-safe /encodeURIComponent, forbidden~'!()*, reserved set); scope stored with the@sigil.ParseVersion/ValidateVersion/Version.String- semver 2.0.0, build metadata preserved byte-for-byte.ValidateTagName- rejects empty / over-255-byte names, semver-shaped names (validRange), and NUL/control bytes.ValidateFileName-{plain_name}-{version}.tgzshape + path-traversal reject.
Spec / acceptance
Implements the S11 "Validation rules" section. Covers AC 23 (tag_name_invalid),
AC 39 unit half (package_name_invalid), AC 40 (version_invalid), and the
AC 53 path-traversal unit foundation (integration half lands in Step 9).
Tests
Table-driven units with >=1 positive + negative case per rule, the build-metadata
byte-for-byte round-trip, the uint64-overflow boundary, and NUL/control-byte
cases. Adds FuzzParseVersion (no-panic + round-trip), run in CI via fuzz:npm.
Size note
~1385 LOC, above the 400-LOC target. It is one indivisible unit: the validators
share helpers (splitScoped, isNumeric, ...) and a single table-driven test
file, and ~250 of the impl lines are doc comments. Splitting per-validator would
create artificial seams. History uses the test-first split (test(npm) skeleton
-> feat(npm) impl) plus the review fix-ups.