Skip to content

Commit a209afc

Browse files
committed
fix: improve field name escaping for dots and slashes
1 parent f72b287 commit a209afc

File tree

4 files changed

+59
-25
lines changed

4 files changed

+59
-25
lines changed

.changeset/fix-field-escaping.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"git-json-resolver": patch
3+
---
4+
5+
Fix field name escaping for dots and slashes in object keys
6+
7+
Improves handling of complex field names containing dots and slashes (e.g., scoped package names like `@scope/package` or URLs) by introducing proper escape sequences and updating the matcher logic to handle these cases consistently across all matcher implementations.

lib/src/matcher.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { basicMatcher, loadMatcher } from "./matcher";
2+
import { basicMatcher, ESCAPED_DOT, loadMatcher, ESCAPED_SLASH } from "./matcher";
33

44
for (const matcherName of ["basicMatcher", "micromatch", "picomatch"] as const) {
55
describe(matcherName, async () => {
@@ -68,6 +68,20 @@ for (const matcherName of ["basicMatcher", "micromatch", "picomatch"] as const)
6868
expect(matcher.isMatch("src", ["!src"])).toBe(false);
6969
expect(matcher.isMatch("other", ["!src"])).toBe(true);
7070
});
71+
72+
it("handles object fields with dots and slashes", () => {
73+
// Test scoped package names like @m2d/core
74+
expect(matcher.isMatch(`deps.@m2d${ESCAPED_SLASH}core`, ["deps.*"])).toBe(true);
75+
expect(matcher.isMatch(`deps.@m2d${ESCAPED_SLASH}core`, ["deps.@m2d\\/core"])).toBe(true);
76+
77+
// Test field names with both dots and slashes
78+
expect(
79+
matcher.isMatch(
80+
`urls.https:${ESCAPED_SLASH}${ESCAPED_SLASH}api${ESCAPED_DOT}example${ESCAPED_DOT}com${ESCAPED_SLASH}v1`,
81+
["urls.*"],
82+
),
83+
).toBe(true);
84+
});
7185
});
7286
}
7387

@@ -87,4 +101,25 @@ describe("loadMatcher", () => {
87101
it("throws on unknown matcher", async () => {
88102
await expect(() => loadMatcher("invalid" as any)).rejects.toThrow(/Unknown matcher/);
89103
});
104+
105+
it("handles complex field names consistently across matchers", async () => {
106+
const testCases = [
107+
{ field: `deps.@m2d${ESCAPED_SLASH}core`, pattern: "deps.*", expected: true },
108+
{
109+
field: `urls.https:${ESCAPED_SLASH}${ESCAPED_SLASH}api${ESCAPED_DOT}example${ESCAPED_DOT}com${ESCAPED_SLASH}v1`,
110+
pattern: "urls.*",
111+
expected: true,
112+
},
113+
];
114+
115+
for (const { field, pattern, expected } of testCases) {
116+
expect(basicMatcher.isMatch(field, [pattern])).toBe(expected);
117+
118+
const mm = await loadMatcher("micromatch");
119+
expect(mm.isMatch(field, [pattern])).toBe(expected);
120+
121+
const pm = await loadMatcher("picomatch");
122+
expect(pm.isMatch(field, [pattern])).toBe(expected);
123+
}
124+
});
90125
});

lib/src/matcher.ts

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
/** Escape sequence for literal dots in field names */
2+
export const ESCAPED_DOT = "\u0000";
3+
/** Escape sequence for literal slashes in field names */
4+
export const ESCAPED_SLASH = "\u0001";
5+
16
/**
27
* Matcher abstraction + adapters for micromatch/picomatch (optional).
38
*
@@ -9,7 +14,6 @@
914
*
1015
* Micromatch/Picomatch can be loaded at runtime as optional peers.
1116
*/
12-
1317
export interface Matcher {
1418
/**
1519
* Returns true if `str` matches at least one of the provided glob `patterns`.
@@ -40,7 +44,7 @@ export const basicMatcher: Matcher = {
4044

4145
export const loadMatcher = async (name: "micromatch" | "picomatch"): Promise<Matcher> => {
4246
if (name === "micromatch") {
43-
let micromatch: any;
47+
let micromatch;
4448
try {
4549
micromatch = await import("micromatch");
4650
} catch {
@@ -63,7 +67,7 @@ export const loadMatcher = async (name: "micromatch" | "picomatch"): Promise<Mat
6367
}
6468

6569
if (name === "picomatch") {
66-
let picomatch: any;
70+
let picomatch;
6771
try {
6872
picomatch = (await import("picomatch")).default;
6973
} catch {
@@ -95,27 +99,13 @@ export const loadMatcher = async (name: "micromatch" | "picomatch"): Promise<Mat
9599
* Convert a pattern/field path into internal form (`/` separated).
96100
* - Unescaped `.` → `/`
97101
* - Unescaped `/` → `/`
98-
* - Escaped `\.` → `.`
99-
* - Escaped `\/` → `/`
102+
* - Escaped `\.` → `ESCAPED_DOT`
103+
* - Escaped `\/` → `ESCAPED_SLASH`
100104
*/
101-
const toInternal = (input: string): string => {
102-
let out = "";
103-
let escaped = false;
104-
for (const ch of input) {
105-
if (escaped) {
106-
out += ch; // take literally
107-
escaped = false;
108-
} else if (ch === "\\") {
109-
escaped = true;
110-
out += "\\"; // preserve backslash for downstream escape handling
111-
} else if (ch === ".") {
112-
out += "/"; // dot becomes slash
113-
} else {
114-
out += ch;
115-
}
116-
}
117-
return out;
118-
};
105+
const toInternal = (input: string): string =>
106+
input.replace(/\\[./]|\./g, match =>
107+
match === "\\." ? ESCAPED_DOT : match === "\\/" ? ESCAPED_SLASH : "/",
108+
);
119109

120110
/**
121111
* Splits a glob pattern into dot-separated segments, honoring backslash escaping.

lib/src/merger.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from "./logger";
2+
import { ESCAPED_DOT, ESCAPED_SLASH } from "./matcher";
23
import { NormalizedConfig } from "./normalizer";
34
import { resolveStrategies } from "./strategy-resolver";
45
import { StrategyFn, StrategyResult, StrategyStatus } from "./types";
@@ -114,11 +115,12 @@ export const BuiltInStrategies = {
114115
const allKeys = new Set([...Object.keys(ours), ...Object.keys(theirs)]);
115116
const result: Record<string, unknown> = {};
116117
for (const key of allKeys) {
118+
const escapedKey = key.replace(/\./g, ESCAPED_DOT).replace(/\\/g, ESCAPED_SLASH);
117119
result[key] = await mergeObject({
118120
ours: (ours as Record<string, unknown>)[key],
119121
theirs: (theirs as Record<string, unknown>)[key],
120122
base: isPlainObject(base) ? (base as Record<string, unknown>)[key] : undefined,
121-
path: path ? `${path}.${key}` : key,
123+
path: path ? `${path}.${escapedKey}` : escapedKey,
122124
filePath,
123125
ctx,
124126
conflicts,

0 commit comments

Comments
 (0)