diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index 1fcc015..e9ada83 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -207,6 +207,13 @@ priorities.
unchanged. It introduces no new compatibility correction; the
shell-oracle-proved corrections remain the ones documented in the
preceding provenance item.
+- [x] Audit duplicated Bash and PowerShell path-normalization helpers. Share
+ only the identical string-level join and separator-normalization rules;
+ keep root detection, drive-relative handling, full segment
+ normalization, provider/PSDrive behavior, and resolver failure policy in
+ their shell-specific implementations. Direct boundary tests pin the
+ extracted helpers, while the complete resolver and corpus suites prove
+ the refactor leaves both compatibility projections unchanged.
- [ ] Add the structural and command-occurrence projections for the existing
grammar before enabling any control-flow construct.
- [ ] Deliver Bash `for ... in` and PowerShell `foreach` as the first two
diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md
index 8c5bf69..5652f2a 100644
--- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md
+++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md
@@ -17,8 +17,8 @@
- [x] 2.2 Add paired Bash and PowerShell shell-oracle regressions for standalone escapes, adjacent escaped values, all-static mixed quoting, within-token escapes, genuine literal-plus-expandable values, adjacent and wildcard redirect targets, runtime special/positional/numeric/Unicode variables, incomplete and escaped-literal braced interpolation, Bash provider-looking literals, and PowerShell native-versus-cmdlet, Path-versus-LiteralPath, and redirect-context divergence; require exact compatibility path results when every fragment, binding fact, and required resolver fact is exact, otherwise fail closed.
- [x] 2.3 Implement issue #69's shell-neutral native argument-fragment classifier with explicit Bash and PowerShell adapters that preserve the new provenance.
- [x] 2.4 Prove raw spelling, decoded logical values, source spans, and unaffected classifications remain unchanged; document each oracle-proved false exact, `Glob`, `Tilde`, provider, path, or avoidable `DynamicSkip` compatibility correction.
-- [ ] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics.
-- [ ] 2.6 Run Release build, full tests, header verification, and the adversarial security corpus for the completed preparation.
+- [x] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics.
+- [x] 2.6 Run Release build, full tests, header verification, and the adversarial security corpus for the completed preparation.
## 3. Structural and Projection Skeleton
diff --git a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs
index ab0e8f1..ad111b8 100644
--- a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs
+++ b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs
@@ -165,7 +165,7 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
// Drop the leading `~` and join the rest (which starts with
// '/' or '\') to home.
var rest = working.Substring(2); // skip "~/" or "~\\"
- working = JoinPath(home, rest);
+ working = ShellPathNormalization.Join(home, rest);
}
hadTilde = true;
@@ -446,7 +446,7 @@ private static string GetHomeDirectory(BashParserOptions options)
// Lazy fallback. SPEC §2 / §8: defaults to UserProfile. May be the
// empty string in pathological environments — callers tolerate that
- // because JoinPath / Path.GetFullPath fall back accordingly.
+ // because downstream path joining and normalization handle it.
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
}
@@ -600,32 +600,6 @@ private static bool IsIdentifierContinuation(char c) =>
private static bool IsAsciiLetter(char c) =>
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
- ///
- /// Combine a base directory with a relative or rooted sub-path using
- /// bash semantics — forward slashes everywhere, regardless of host OS.
- /// Strips a single leading separator from the sub-path so the combine
- /// doesn't treat the sub-path as rooted.
- ///
- private static string JoinPath(string baseDir, string sub)
- {
- if (string.IsNullOrEmpty(sub))
- {
- return baseDir;
- }
-
- // Sub-paths may be rooted (e.g. `~/rest` produces `/rest` after
- // tilde expansion) — strip exactly one leading separator before
- // combining so we don't lose baseDir.
- var s = sub;
- if (s.Length > 0 && (s[0] == '/' || s[0] == '\\'))
- {
- s = s.Substring(1);
- }
-
- // Always forward-slash, always bash semantics.
- return baseDir.TrimEnd('/', '\\') + "/" + s.Replace('\\', '/');
- }
-
///
/// Resolve to an absolute path against the
/// supplied options. Returns null on resolution failure (SPEC §8 step 6).
@@ -647,7 +621,7 @@ private static string JoinPath(string baseDir, string sub)
string combined;
if (IsRootedPath(token))
{
- combined = NormalizeToForwardSlashes(token);
+ combined = ShellPathNormalization.NormalizeSeparators(token);
}
else if (workingDirectoryUnknown)
{
@@ -667,7 +641,7 @@ private static string JoinPath(string baseDir, string sub)
// rather than guess.
return null;
}
- combined = JoinPath(wd, token);
+ combined = ShellPathNormalization.Join(wd, token);
}
return NormalizePath(combined);
@@ -686,22 +660,6 @@ private static string JoinPath(string baseDir, string sub)
}
}
- ///
- /// Normalize backslashes to forward slashes; preserve bash semantics
- /// for `\\server\share` UNC paths by collapsing the leading `\\` to a
- /// single `//`. (UNC paths are rare in bash but the heuristic preserves
- /// them in a recognizable form for consumers.)
- ///
- private static string NormalizeToForwardSlashes(string token)
- {
- if (token.Length >= 2 && token[0] == '\\' && token[1] == '\\')
- {
- // UNC: \\server\share -> //server/share
- return "//" + token.Substring(2).Replace('\\', '/');
- }
- return token.Replace('\\', '/');
- }
-
///
/// Bash-style path normalization: collapse `.`/`..` segments, deduplicate
/// adjacent slashes, preserve a leading `/` (or `//` for UNC), use
diff --git a/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs b/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs
index 093bd7a..5ae830b 100644
--- a/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs
+++ b/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs
@@ -78,7 +78,9 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
}
var home = GetHomeDirectory(options);
- working = working.Length == 1 ? home : JoinPath(home, working.Substring(2));
+ working = working.Length == 1
+ ? home
+ : ShellPathNormalization.Join(home, working.Substring(2));
hadHomeish = true;
}
@@ -548,22 +550,6 @@ private static bool IsIdentifierContinuation(char c) =>
private static bool IsAsciiLetter(char c) =>
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
- private static string JoinPath(string baseDir, string sub)
- {
- if (string.IsNullOrEmpty(sub))
- {
- return baseDir;
- }
-
- var s = sub;
- if (s.Length > 0 && (s[0] == '/' || s[0] == '\\'))
- {
- s = s.Substring(1);
- }
-
- return baseDir.TrimEnd('/', '\\') + "/" + s.Replace('\\', '/');
- }
-
///
/// Resolve to a normalized absolute path.
/// Returns null on failure (SPEC.POWERSHELL.md §8). Drive-qualified
@@ -583,7 +569,7 @@ private static string JoinPath(string baseDir, string sub)
string combined;
if (IsRootedPath(token))
{
- combined = NormalizeToForwardSlashes(token);
+ combined = ShellPathNormalization.NormalizeSeparators(token);
}
else if (workingDirectoryUnknown)
{
@@ -597,7 +583,7 @@ private static string JoinPath(string baseDir, string sub)
return null;
}
- combined = JoinPath(wd, token);
+ combined = ShellPathNormalization.Join(wd, token);
}
return NormalizePath(combined);
@@ -616,16 +602,6 @@ private static string JoinPath(string baseDir, string sub)
}
}
- private static string NormalizeToForwardSlashes(string token)
- {
- if (token.Length >= 2 && token[0] == '\\' && token[1] == '\\')
- {
- return "//" + token.Substring(2).Replace('\\', '/');
- }
-
- return token.Replace('\\', '/');
- }
-
private static string NormalizePath(string path)
{
if (string.IsNullOrEmpty(path))
diff --git a/src/ShellSyntaxTree/Internal/Resolving/ShellPathNormalization.cs b/src/ShellSyntaxTree/Internal/Resolving/ShellPathNormalization.cs
new file mode 100644
index 0000000..55d9540
--- /dev/null
+++ b/src/ShellSyntaxTree/Internal/Resolving/ShellPathNormalization.cs
@@ -0,0 +1,40 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+namespace ShellSyntaxTree.Internal.Resolving;
+
+///
+/// String-only path operations whose behavior is identical for the Bash and
+/// PowerShell resolvers. Root detection, drive handling, and segment
+/// normalization remain shell-specific.
+///
+internal static class ShellPathNormalization
+{
+ internal static string Join(string baseDirectory, string subpath)
+ {
+ if (string.IsNullOrEmpty(subpath))
+ {
+ return baseDirectory;
+ }
+
+ var relative = subpath;
+ if (relative.Length > 0 && (relative[0] == '/' || relative[0] == '\\'))
+ {
+ relative = relative.Substring(1);
+ }
+
+ return baseDirectory.TrimEnd('/', '\\') + "/" + relative.Replace('\\', '/');
+ }
+
+ internal static string NormalizeSeparators(string path)
+ {
+ if (path.Length >= 2 && path[0] == '\\' && path[1] == '\\')
+ {
+ return "//" + path.Substring(2).Replace('\\', '/');
+ }
+
+ return path.Replace('\\', '/');
+ }
+}
diff --git a/tests/ShellSyntaxTree.Tests/Resolving/ShellPathNormalizationTests.cs b/tests/ShellSyntaxTree.Tests/Resolving/ShellPathNormalizationTests.cs
new file mode 100644
index 0000000..095ba1f
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Resolving/ShellPathNormalizationTests.cs
@@ -0,0 +1,36 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using ShellSyntaxTree.Internal.Resolving;
+using Xunit;
+
+namespace ShellSyntaxTree.Tests.Resolving;
+
+public class ShellPathNormalizationTests
+{
+ [Theory]
+ [InlineData("/base", "", "/base")]
+ [InlineData("/base/", "/child\\file", "/base/child/file")]
+ [InlineData("C:\\base\\", "child\\file", "C:\\base/child/file")]
+ [InlineData("/base", "//child", "/base//child")]
+ public void Join_preserves_the_existing_shared_string_semantics(
+ string baseDirectory,
+ string subpath,
+ string expected)
+ {
+ Assert.Equal(expected, ShellPathNormalization.Join(baseDirectory, subpath));
+ }
+
+ [Theory]
+ [InlineData("\\\\server\\share\\file", "//server/share/file")]
+ [InlineData("\\root\\file", "/root/file")]
+ [InlineData("/root\\file", "/root/file")]
+ public void NormalizeSeparators_preserves_UNC_and_normalizes_backslashes(
+ string path,
+ string expected)
+ {
+ Assert.Equal(expected, ShellPathNormalization.NormalizeSeparators(path));
+ }
+}