From 952e3546bb7bdfd557f95d7876c236880064d0ff Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sat, 20 Jun 2026 00:57:03 +0800 Subject: [PATCH] feat(v11)!: restrict size/first/last magic keys to arrays and strings Replace ownPropertyOnly guards with simpler v11 semantics: magic property resolution applies only to arrays and strings. Plain objects no longer get Object.keys size or first/last fallbacks. Related to #916 Co-authored-by: Cursor --- src/context/context.spec.ts | 31 ++++++++++++++++++++++--------- src/context/context.ts | 24 +++++++++--------------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index de15c6c12..75fba3c14 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -32,7 +32,7 @@ describe('Context', function () { it('should read nested property', async function () { expect(ctx.get(['obj', 'first'])).toEqual('f') expect(ctx.get(['obj', 'last'])).toEqual('l') - expect(ctx.get(['obj', 'size'])).toEqual(2) + expect(ctx.get(['obj', 'size'])).toBeUndefined() }) it('undefined property should yield undefined', async function () { expect(ctx.get(['notdefined'])).toEqual(undefined) @@ -55,7 +55,9 @@ describe('Context', function () { it('should return array length as size', async function () { expect(ctx.get(['bar', 'arr', 'size'])).toEqual(2) }) - it('should return map size as size', async function () { + it('should return map size only via own property access', async function () { + expect(ctx.get(['map', 'size'])).toBeUndefined() + ctx = new Context(scope, { ownPropertyOnly: false } as any) expect(ctx.get(['map', 'size'])).toEqual(1) }) it('should return undefined if not have a size', async function () { @@ -68,6 +70,14 @@ describe('Context', function () { it('should read .last of array', async function () { expect(ctx.get(['bar', 'arr', 'last'])).toEqual('b') }) + it('should read own size property on objects', async function () { + expect(ctx.get(['zoo', 'size'])).toEqual(4) + }) + it('should read string first/last as magic keys', async function () { + ctx.push({ str: 'abc' }) + expect(ctx.getSync(['str', 'first'])).toEqual('a') + expect(ctx.getSync(['str', 'last'])).toEqual('c') + }) it('should read element of array', async function () { expect(ctx.get(['arr', 1])).toEqual('b') }) @@ -167,13 +177,16 @@ describe('Context', function () { ctx.push({ foo: [1, 2] }) return expect(ctx.getSync(['foo', 'size'])).toEqual(2) }) - it('should allow size to access Set.prototype.size', function () { + it('should allow size to access Set.prototype.size via property access', function () { + ctx.push({ foo: new Set([1, 2]) }) + expect(ctx.getSync(['foo', 'size'])).toEqual(undefined) + ctx = new Context({}, { ownPropertyOnly: false } as any) ctx.push({ foo: new Set([1, 2]) }) return expect(ctx.getSync(['foo', 'size'])).toEqual(2) }) - it('should allow size to access Object key count', function () { + it('should not apply size magic to plain objects', function () { ctx.push({ foo: { bar: 'BAR', coo: 'COO' } }) - return expect(ctx.getSync(['foo', 'size'])).toEqual(2) + return expect(ctx.getSync(['foo', 'size'])).toEqual(undefined) }) it('should throw when property is hidden and strictVariables is true', function () { ctx = new Context(ctx, { @@ -183,11 +196,11 @@ describe('Context', function () { ctx.push({ foo: Object.create({ bar: 'BAR' }) }) return expect(() => ctx.getSync(['foo', 'bar'])).toThrow(/undefined variable: foo.bar/) }) - it('should block prototype size/first/last magic keys', function () { + it('should not apply size/first/last magic keys to plain objects', function () { Object.assign(Object.prototype, { size: 123, first: 'FIRST_PROTO', last: 'LAST_PROTO' }) try { ctx.push({ foo: {} }) - expect(ctx.getSync(['foo', 'size'])).toEqual(0) + expect(ctx.getSync(['foo', 'size'])).toEqual(undefined) expect(ctx.getSync(['foo', 'first'])).toEqual(undefined) expect(ctx.getSync(['foo', 'last'])).toEqual(undefined) } finally { @@ -196,13 +209,13 @@ describe('Context', function () { delete (Object.prototype as any).last } }) - it('should allow array first/last/size magic keys with ownPropertyOnly', function () { + it('should allow array first/last/size magic keys', function () { ctx.push({ foo: [1, 2, 3] }) expect(ctx.getSync(['foo', 'first'])).toEqual(1) expect(ctx.getSync(['foo', 'last'])).toEqual(3) expect(ctx.getSync(['foo', 'size'])).toEqual(3) }) - it('should use prototype magic keys when ownPropertyOnly=false', function () { + it('should read prototype size/first/last via property access when ownPropertyOnly=false', function () { Object.assign(Object.prototype, { size: 123, first: 'FIRST_PROTO', last: 'LAST_PROTO' }) try { ctx = new Context({}, { ownPropertyOnly: false } as any) diff --git a/src/context/context.ts b/src/context/context.ts index 850504454..b66d87b8a 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -129,9 +129,9 @@ export class Context { const value = readJSProperty(obj, key, this.ownPropertyOnly) if (value === undefined && obj instanceof Drop) return obj.liquidMethodMissing(key, this) if (isFunction(value)) return value.call(obj) - if (key === 'size') return readSize(obj, this.ownPropertyOnly) - else if (key === 'first') return readFirst(obj, this.ownPropertyOnly) - else if (key === 'last') return readLast(obj, this.ownPropertyOnly) + if (key === 'size' && (isArray(obj) || isString(obj))) return readSize(obj) + else if (key === 'first' && (isArray(obj) || isString(obj))) return readFirst(obj) + else if (key === 'last' && (isArray(obj) || isString(obj))) return readLast(obj) return value } } @@ -141,22 +141,16 @@ export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: b return obj[key] } -function readFirst (obj: Scope, ownPropertyOnly: boolean) { +function readFirst (obj: Scope) { if (isArray(obj)) return obj[0] - if (ownPropertyOnly && !hasOwnProperty.call(obj, 'first') && !(obj instanceof Drop)) return undefined - return obj['first'] + return obj[0] } -function readLast (obj: Scope, ownPropertyOnly: boolean) { +function readLast (obj: Scope) { if (isArray(obj)) return obj[obj.length - 1] - if (ownPropertyOnly && !hasOwnProperty.call(obj, 'last') && !(obj instanceof Drop)) return undefined - return obj['last'] + return obj[obj.length - 1] } -function readSize (obj: Scope, ownPropertyOnly: boolean) { - if (hasOwnProperty.call(obj, 'size')) return obj['size'] - if (!ownPropertyOnly && obj['size'] !== undefined) return obj['size'] - if (isArray(obj) || isString(obj)) return obj.length - if (obj instanceof Set || obj instanceof Map) return obj.size - if (typeof obj === 'object') return Object.keys(obj).length +function readSize (obj: Scope) { + return obj.length }