From 59412f40a99e3a43ac6674cdc1805383f3d2d712 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 14 May 2026 16:30:17 +0500 Subject: [PATCH 1/3] fix: .match() agrees with .covers() for wildcard prefix coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tmplPrefix.covers(deeperConcrete)` returned true (a wildcard prefix covers any path under its domain) but `tmplPrefix.match(deeperConcrete)` returned null — the parent/child branch was gated off when wildcards were present, and patternMatches only handled whole-path matches. Unify the two: when one side has wildcards and matchesPrefix succeeds on the longer side against the wildcard-bearing side as the prefix, return covers / covered-by. This also subsumes the previous patternMatches-based whole-match check (matchesPrefix with wildcards in the prefix is strictly more general), so patternMatches is dropped. Literal parent/child remains gated on both sides being wildcard-free. Tests: - tmpl.each().match(concrete[0].name) → covers (was null) - tmpl.deep().match(deeper.path) → covers (was null) - all prior match() relations unchanged Co-Authored-By: Claude Opus 4.7 (1M context) --- src/impl/base-path-impl.ts | 50 +++++++++++++++++++--------------- src/tests/interactions.spec.ts | 22 +++++++++++++++ src/utils.ts | 41 ++-------------------------- 3 files changed, 53 insertions(+), 60 deletions(-) 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..eea0bca 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( diff --git a/src/utils.ts b/src/utils.ts index 01504cc..ceb4c09 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -86,47 +86,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 }; From 73bad550eac0a494c4fb54bfe5ebb1e2da4768de Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 15 May 2026 12:22:40 +0500 Subject: [PATCH 2/3] chore: google search console verify --- docs/public/google9835a2061220351a.html | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/public/google9835a2061220351a.html 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 From 49b53f3cbc5de4173e053cbd3307245ddcdf5dc1 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 15 May 2026 13:22:45 +0500 Subject: [PATCH 3/3] fix: matchesPrefix rejects single-segment pattern vs ** in full `(*).covers(**)` and `(a.*.x).covers(a.**.x)` previously returned true because `prefix[p] !== WILDCARD` short-circuited the equality check against `full[f] === DEEP_WILDCARD`, silently treating `**` as a single segment. `**` represents 0..N segments, so a single-segment element (literal or `*`) in the prefix cannot cover it. Propagated to `.match()` (reported `"covers"`/`"covered-by"`) and `.startsWith()` for the same shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tests/interactions.spec.ts | 31 +++++++++++++++++++++++++++++++ src/utils.ts | 5 +++++ 2 files changed, 36 insertions(+) diff --git a/src/tests/interactions.spec.ts b/src/tests/interactions.spec.ts index eea0bca..ab2ba69 100644 --- a/src/tests/interactions.spec.ts +++ b/src/tests/interactions.spec.ts @@ -251,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 ceb4c09..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++;