🔎 Search Terms
normalizePath, getNormalizedAbsolutePath, path normalization, relative path, leading "..", #60812, regression
🕗 Version & Regression Information
⏯ Playground Link
No response
💻 Code
getNormalizedAbsolutePath / normalizePath are @internal, so this is shown as assertions in the style of src/testRunner/unittests/paths.ts:
// getNormalizedAbsolutePath(path, /*currentDirectory*/ "")
getNormalizedAbsolutePath("a/../b", ""); // actual: "a/b" expected: "b" (the ".." is ignored entirely)
getNormalizedAbsolutePath("a/..", ""); // actual: "a" expected: ""
getNormalizedAbsolutePath("a/../..", ""); // actual: "" expected: ".."
getNormalizedAbsolutePath("foo/../../bar", ""); // actual: "bar" expected: "../bar"
getNormalizedAbsolutePath("x/../../y", ""); // actual: "y" expected: "../y"
// normalizePath() shares the same code path:
normalizePath("foo/../../bar"); // actual: "bar" expected: "../bar"
normalizePath("a/../b"); // actual: "a/b" expected: "b"
🙁 Actual behavior
For a relative path where the number of .. segments exceeds the preceding "normal" segments — i.e. removing the first segment, or generating a new leading .. — the rewritten state machine either keeps a segment that should be removed (a/../b → a/b) or drops a .. that should be preserved (foo/../../bar → bar).
🙂 Expected behavior
Same as the pre-#60812 implementation and as getPathFromPathComponents(reducePathComponents(getPathComponents(path))): a/../b → b, a/.. → "", a/../.. → "..", foo/../../bar → ../bar.
This is consistent with the assertions #60812 itself added, e.g. getNormalizedAbsolutePath("../../a/..", "") === "../.." and reducePathComponents(["", "..", "a"]) === ["", "..", "a"]. Cases where a leading .. was already present are preserved; only the subclass where a leading .. must be generated regressed — which the new tests don't cover.
Additional information about the issue
Root cause (FWIW)
In the ".." handling of getNormalizedAbsolutePath:
- the
normalized === undefined branch derives the popped prefix from normalizedUpTo - 2, and when that is < 0 falls back to path.substring(0, normalizedUpTo) — keeping the first segment instead of removing it; and
- neither that branch nor the
normalized !== undefined branch resets seenNonDotDotSegment when the pop collapses the result back to the root (or to a ..-tail at a relative root), so the next .. is treated as poppable and dropped instead of being emitted as a leading ...
Impact
This does not appear reachable through normal compilation today (#60812 changed no baselines), so it's latent. But it's a behavior change for an input class the previous implementation handled correctly, and these are general path utilities with no documented rooted-only precondition. A 540k-input fuzz of the rewrite vs the prior reducePathComponents-based implementation diverges only on this class. Filing so you can decide between restoring equivalence and documenting a rooted-only contract — happy to send a PR.
🔎 Search Terms
normalizePath, getNormalizedAbsolutePath, path normalization, relative path, leading "..", #60812, regression
🕗 Version & Regression Information
main. The".."-segment handling ingetNormalizedAbsolutePath(thenormalizedUpTo - 2fallback and theseenNonDotDotSegmentreset) is unchanged and still produces the values below../followed by a slash #63588 / Fix normalizePath rooting a relative path that begins with ./ followed by a slash #63587 fixednormalizePath(".//a")being wrongly rooted to/a(thesimpleNormalizePath./+separator fast path). This report is a distinct, still-unfixed Write path normalization without array allocations #60812 regression in the".."-ascending case, not addressed by that fix. (Aside: Add tests forgetNormalizedAbsolutePath. #60802 addedgetNormalizedAbsolutePathtests for trailing dots but not this class.)⏯ Playground Link
No response
💻 Code
getNormalizedAbsolutePath/normalizePathare@internal, so this is shown as assertions in the style ofsrc/testRunner/unittests/paths.ts:🙁 Actual behavior
For a relative path where the number of
..segments exceeds the preceding "normal" segments — i.e. removing the first segment, or generating a new leading..— the rewritten state machine either keeps a segment that should be removed (a/../b → a/b) or drops a..that should be preserved (foo/../../bar → bar).🙂 Expected behavior
Same as the pre-#60812 implementation and as
getPathFromPathComponents(reducePathComponents(getPathComponents(path))):a/../b → b,a/.. → "",a/../.. → "..",foo/../../bar → ../bar.This is consistent with the assertions #60812 itself added, e.g.
getNormalizedAbsolutePath("../../a/..", "") === "../.."andreducePathComponents(["", "..", "a"]) === ["", "..", "a"]. Cases where a leading..was already present are preserved; only the subclass where a leading..must be generated regressed — which the new tests don't cover.Additional information about the issue
Root cause (FWIW)
In the
".."handling ofgetNormalizedAbsolutePath:normalized === undefinedbranch derives the popped prefix fromnormalizedUpTo - 2, and when that is< 0falls back topath.substring(0, normalizedUpTo)— keeping the first segment instead of removing it; andnormalized !== undefinedbranch resetsseenNonDotDotSegmentwhen the pop collapses the result back to the root (or to a..-tail at a relative root), so the next..is treated as poppable and dropped instead of being emitted as a leading...Impact
This does not appear reachable through normal compilation today (#60812 changed no baselines), so it's latent. But it's a behavior change for an input class the previous implementation handled correctly, and these are general path utilities with no documented rooted-only precondition. A 540k-input fuzz of the rewrite vs the prior
reducePathComponents-based implementation diverges only on this class. Filing so you can decide between restoring equivalence and documenting a rooted-only contract — happy to send a PR.