diff --git a/docs/public/google9835a2061220351a.html b/docs/public/google9835a2061220351a.html new file mode 100644 index 0000000..0f20ae6 --- /dev/null +++ b/docs/public/google9835a2061220351a.html @@ -0,0 +1 @@ +google-site-verification: google9835a2061220351a.html \ No newline at end of file diff --git a/src/impl/base-path-impl.ts b/src/impl/base-path-impl.ts index 07e9885..585962a 100644 --- a/src/impl/base-path-impl.ts +++ b/src/impl/base-path-impl.ts @@ -9,7 +9,6 @@ import type { import { hasWildcardSegment, matchesPrefix, - patternMatches, resolveSegments, segmentsEqual, } from "../utils.js"; @@ -58,29 +57,36 @@ export abstract class AbstractPathImpl { 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" }; - // 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; } diff --git a/src/tests/interactions.spec.ts b/src/tests/interactions.spec.ts index bca25be..ab2ba69 100644 --- a/src/tests/interactions.spec.ts +++ b/src/tests/interactions.spec.ts @@ -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( @@ -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; + } + 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((n: any) => n.x); + expect(star.covers(deep)).toBe(false); + expect(deep.covers(star)).toBe(true); + }); }); describe("not matchable cases", () => { diff --git a/src/utils.ts b/src/utils.ts index 01504cc..638da34 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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++; @@ -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 };