^0.0.3 matches >=0.0.3 <0.0.4, which in practice is version 0.0.3 and nothing else. The caret keeps the leftmost non-zero number fixed, and in 0.0.3 that’s the patch, so there’s no room left to move.
Against real versions
0.0.2 no
0.0.3 yes
0.0.3+build.7 yes build metadata is ignored: this is 0.0.3
0.0.3-rc.1 no a prerelease sorts below 0.0.3
0.0.4 no
0.1.0 no
The upper bound compiles to <0.0.4-0, below every prerelease of 0.0.4, so nothing between 0.0.3 and 0.0.4 can match either.
Why npm pins it
node-semver, npm’s range engine, assumes any 0.0.x release may break the one before it, since SemVer 2.0.0 promises nothing before 1.0.0. The caret allows only updates it considers non-breaking, so here it allows none. npm install still saves such a package with the default save-prefix, as ^0.0.3: it reads like a range but acts like the exact version.
The tilde is looser here
^0.0.3 >=0.0.3 <0.0.4
~0.0.3 >=0.0.3 <0.1.0
^0.0 <0.1.0
~0.0.3 keeps the major and minor and lets the patch rise, so it accepts 0.0.4, 0.0.9 and every other 0.0.x from 0.0.3 on. That’s the range to write if you trust a 0.0.x package’s patch releases. ^0.0 reaches as far, but it would also accept 0.0.1 and 0.0.2.
Gotchas
npm updatedoes nothing for it. Every newer release is outside the range, so updating means a new install:npm install pkg@latest.- Several copies. Two packages that want
^0.0.3and^0.0.4of the same dependency can’t share one version, so npm installs both.
Elsewhere
Cargo reads ^0.0.3, and the plain "0.0.3", as >=0.0.3, <0.0.4 too, and Composer’s docs give ^0.0.3 the same bounds. Both treat a 0.0.x dependency like npm does: it only moves when you move it.