Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/public/google9835a2061220351a.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
google-site-verification: google9835a2061220351a.html
50 changes: 28 additions & 22 deletions src/impl/base-path-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type {
import {
hasWildcardSegment,
matchesPrefix,
patternMatches,
resolveSegments,
segmentsEqual,
} from "../utils.js";
Expand Down Expand Up @@ -58,29 +57,36 @@ export abstract class AbstractPathImpl<T = unknown, V = unknown> {
const otherSegs = resolveSegments(other);
if (segmentsEqual(this.segments, otherSegs)) return { relation: "equals" };

// Check wildcard coverage BEFORE literal prefix. patternMatches handles
// `**` collapsing, so a deep template can fully cover a concrete path of
// any length — that's a "covers", not "parent". Order matters: a literal
// prefix check that's wildcard-aware (via matchesPrefix) would otherwise
// misclassify "a.**.b covers a.x.y.b" as "parent".
if (patternMatches(this.segments, otherSegs)) return { relation: "covers" };
if (patternMatches(otherSegs, this.segments))
const thisHasWild = hasWildcardSegment(this.segments);
const otherHasWild = hasWildcardSegment(otherSegs);

// Wildcard coverage: a wildcard-bearing side covers the other when its
// segments form a prefix-pattern of the other's segments. This includes
// both exact matches (template fully expands to the concrete) and prefix
// matches (template covers a region the concrete sits inside).
// `matchesPrefix(full, prefix)` returns true exactly in those cases when
// the prefix carries wildcards, so it unifies what `.covers()` does with
// what `.match()` should report.
if (thisHasWild && matchesPrefix(otherSegs, this.segments))
return { relation: "covers" };
if (otherHasWild && matchesPrefix(this.segments, otherSegs))
return { relation: "covered-by" };
Comment thread
sergeyshmakov marked this conversation as resolved.

// Literal parent/child: the shorter side must be wildcard-free, otherwise
// it's not a literal prefix.
if (
!hasWildcardSegment(otherSegs) &&
matchesPrefix(this.segments, otherSegs) &&
this.segments.length > otherSegs.length
)
return { relation: "child" };
if (
!hasWildcardSegment(this.segments) &&
matchesPrefix(otherSegs, this.segments) &&
otherSegs.length > this.segments.length
)
return { relation: "parent" };
// Literal parent/child: both sides must be wildcard-free. A wildcard
// prefix is not a literal prefix.
if (!thisHasWild && !otherHasWild) {
if (
matchesPrefix(this.segments, otherSegs) &&
this.segments.length > otherSegs.length
)
return { relation: "child" };
if (
matchesPrefix(otherSegs, this.segments) &&
otherSegs.length > this.segments.length
)
return { relation: "parent" };
}

return null;
}

Expand Down
53 changes: 53 additions & 0 deletions src/tests/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,28 @@ describe("Path interactions", () => {
expect(concrete.match(tmpl)?.relation).toBe("covered-by");
});

it("template-prefix vs deeper concrete: .match() agrees with .covers() (returns 'covers')", () => {
// Regression for codex-bot P2: covers() was true but match() returned
// null because patternMatches only handled whole-path matches and the
// literal parent/child branch was gated off when wildcards were
// present. Now match() and covers() agree on wildcard prefix coverage.
const tmplPrefix = path((r: R) => r.items).each();
const deeper = path((r: R) => r.items[0].name);
expect(tmplPrefix.covers(deeper)).toBe(true);
expect(tmplPrefix.match(deeper)?.relation).toBe("covers");
expect(deeper.match(tmplPrefix)?.relation).toBe("covered-by");
});

it("deep template-prefix vs deeper concrete returns 'covers' (with ** flexing)", () => {
interface Tree {
root: { kids: Array<{ value: string }> };
}
const tmplPrefix = path((r: Tree) => r.root).deep();
const deeper = path((r: Tree) => r.root.kids[0].value);
expect(tmplPrefix.covers(deeper)).toBe(true);
expect(tmplPrefix.match(deeper)?.relation).toBe("covers");
});

it("immutability: does not mutate original path", () => {
const concrete = path((p: R) => p.items[0].name);
const template = path((p: R) => p.items).each(
Expand Down Expand Up @@ -229,6 +251,37 @@ describe("Path interactions", () => {
const concrete = path((p: Data) => p.a.b.c);
expect(deep.covers(concrete)).toBe(true);
});

it("(*) does NOT cover (**) — single-segment pattern can't absorb a deep wildcard", () => {
// Pre-fix bug: in matchesPrefix, prefix.WILDCARD short-circuited the
// equality check against full.DEEP_WILDCARD, so `*` was reported as
// covering `**`. Semantically `**` includes paths of depths != 1.
const single = path().each();
const deep = path().deep();
expect(single.covers(deep)).toBe(false);
expect(deep.covers(single)).toBe(true); // control: ** does cover *
// match() agrees: deep still covers single in the reverse direction.
expect(single.match(deep)?.relation).toBe("covered-by");
expect(deep.match(single)?.relation).toBe("covers");
});

it("(a.*.x) does NOT cover (a.**.x) — generalized * vs ** mismatch", () => {
// `a.**.x` includes paths of length >= 2 ending in `x`; `a.*.x` is
// length-3 only, so it can't cover the deeper-tail case.
interface Leaf {
x: string;
}
interface Two {
a: Record<string, Leaf>;
}
const star = path((p: Two) => p.a).each((child: Leaf) => child.x);
interface DeepShape {
a: { [k: string]: { x: string } | DeepShape };
}
const deep = path((p: DeepShape) => p.a).deep<string>((n: any) => n.x);
expect(star.covers(deep)).toBe(false);
expect(deep.covers(star)).toBe(true);
});
});

describe("not matchable cases", () => {
Expand Down
46 changes: 8 additions & 38 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ export function matchesPrefix(
return false;
}
if (f >= full.length) return false;
// `**` on `full`'s side stands for 0..N segments; a single-segment
// element in `prefix` (literal or `*`) can't absorb it. Without this
// guard, `prefix.WILDCARD` short-circuits the next check and silently
// treats `**` as one segment.
if (full[f] === DEEP_WILDCARD && prefix[p] !== DEEP_WILDCARD) return false;
if (prefix[p] !== WILDCARD && prefix[p] !== full[f]) return false;
p++;
f++;
Expand All @@ -86,47 +91,12 @@ export function matchesPrefix(
}

/**
* Returns `true` iff `pattern` matches `concrete` exactly when wildcards
* are expanded:
* - `WILDCARD` (`*`) consumes exactly one segment.
* - `DEEP_WILDCARD` (`**`) consumes zero or more segments.
* - all other segments must be `===` to the corresponding concrete segment.
*
* Lengths need not match: a single `**` lets the pattern collapse to a
* shorter concrete or stretch to a longer one. This is the "covers"
* relation used by `.match()` and is broader than `matchesPrefix`, which
* only requires the pattern to match a leading slice.
* Returns `true` iff `segments` contains a wildcard sentinel
* (`WILDCARD` or `DEEP_WILDCARD`).
*/
export function patternMatches(
pattern: readonly Segment[],
concrete: readonly Segment[],
): boolean {
function walk(pi: number, ci: number): boolean {
if (pi === pattern.length) return ci === concrete.length;
const seg = pattern[pi];
if (seg === DEEP_WILDCARD) {
for (let skip = 0; ci + skip <= concrete.length; skip++) {
if (walk(pi + 1, ci + skip)) return true;
}
return false;
}
if (ci === concrete.length) return false;
if (seg === WILDCARD) return walk(pi + 1, ci + 1);
if (seg !== concrete[ci]) return false;
return walk(pi + 1, ci + 1);
}
return walk(0, 0);
}

function hasWildcardSegment(segments: readonly Segment[]): boolean {
export function hasWildcardSegment(segments: readonly Segment[]): boolean {
for (const s of segments) {
if (s === WILDCARD || s === DEEP_WILDCARD) return true;
}
return false;
}

/**
* Returns `true` iff `segments` contains a wildcard sentinel
* (`WILDCARD` or `DEEP_WILDCARD`).
*/
export { hasWildcardSegment };
Loading