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
4 changes: 2 additions & 2 deletions .github/workflows/pullfrog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ jobs:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}

# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_REGION: us-east-1
# BEDROCK_MODEL_ID: <bedrock-model-id>

# for Google Vertex AI (https://docs.pullfrog.com/vertex)
# VERTEX_SERVICE_ACCOUNT_JSON: >-
# ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
Expand Down
56 changes: 25 additions & 31 deletions packages/downgrader/src/shared.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { UnknownRecord } from './shared'
import { dig } from '../tests/helpers'
import {
convertRecord,
deepClone,
DROP,
getRef,
HTTP_METHODS,
HTTP_METHODS_UP_TO_V31,
isRecord,
mapArray,
mapRecord,
Expand All @@ -16,10 +16,6 @@ function identity<T>(value: T): T {
return value
}

function asRecord(value: unknown): UnknownRecord {
return value as UnknownRecord
}

/** A converter that recurses into `self`, so a self-referencing node re-enters convertRecord. */
function convertNode(value: unknown): unknown {
return convertRecord(value, {
Expand Down Expand Up @@ -95,31 +91,31 @@ describe('deepClone', () => {

it('copies a hostile __proto__ own key as a plain own data property without prototype pollution', () => {
const input: unknown = JSON.parse('{"__proto__": {"polluted": true}}')
const clone = asRecord(deepClone(input))
const clone = deepClone(input)
expect(Object.getOwnPropertyNames(clone)).toContain('__proto__')
expect(Object.getOwnPropertyDescriptor(clone, '__proto__')?.value).toEqual({
polluted: true,
})
expect(Object.getPrototypeOf(clone)).toBe(Object.prototype)
expect(asRecord({}).polluted).toBeUndefined()
expect('polluted' in {}).toBe(false)
})

it('preserves key order', () => {
const input: UnknownRecord = {}
const input: Record<string, unknown> = {}
input.zebra = 1
input.apple = 2
input.mango = 3
expect(Object.keys(deepClone(input))).toEqual(['zebra', 'apple', 'mango'])
})

it('preserves object cycles instead of recursing forever', () => {
const child: UnknownRecord = {}
const node: UnknownRecord = { child, name: 'root' }
const child: Record<string, unknown> = {}
const node: Record<string, unknown> = { child, name: 'root' }
child.parent = node
const clone = deepClone(node)
expect(clone).not.toBe(node)
expect(clone.name).toBe('root')
expect(asRecord(clone.child).parent).toBe(clone)
expect(dig(clone, 'child', 'parent')).toBe(clone)
})

it('preserves array cycles', () => {
Expand All @@ -143,12 +139,10 @@ describe('deepClone', () => {
describe('convertRecord', () => {
it('routes listed fields through their converters and deep-clones the rest', () => {
const extra = { deep: true }
const result = asRecord(
convertRecord(
{ a: 1, b: 2, extra },
{ a: item => [item], b: () => 'converted' },
),
)
const result = convertRecord(
{ a: 1, b: 2, extra },
{ a: item => [item], b: () => 'converted' },
) as Record<string, unknown>
expect(result).toEqual({ a: [1], b: 'converted', extra: { deep: true } })
expect(result.extra).not.toBe(extra)
})
Expand Down Expand Up @@ -176,11 +170,11 @@ describe('convertRecord', () => {
})

it('preserves key order', () => {
const input: UnknownRecord = {}
const input: Record<string, unknown> = {}
input.zebra = 1
input.apple = 2
input.mango = 3
const result = asRecord(convertRecord(input, { apple: identity }))
const result = convertRecord(input, { apple: identity }) as Record<string, unknown>
expect(Object.keys(result)).toEqual(['zebra', 'apple', 'mango'])
})

Expand All @@ -199,7 +193,7 @@ describe('convertRecord', () => {
const input: unknown = JSON.parse(
'{"constructor": 1, "toString": 2, "__proto__": {"polluted": true}}',
)
const result = asRecord(convertRecord(input, {}))
const result = convertRecord(input, {})
expect(Object.getOwnPropertyDescriptor(result, 'constructor')?.value).toBe(
1,
)
Expand All @@ -208,15 +202,15 @@ describe('convertRecord', () => {
{ polluted: true },
)
expect(Object.getPrototypeOf(result)).toBe(Object.prototype)
expect(asRecord({}).polluted).toBeUndefined()
expect('polluted' in {}).toBe(false)
})

it('falls back to a cycle-preserving clone when re-entered for the same object', () => {
const node: UnknownRecord = { name: 'root' }
const node: Record<string, unknown> = { name: 'root' }
node.self = node
const result = asRecord(convertNode(node))
const result = convertNode(node) as Record<string, unknown>
expect(result.name).toBe('converted')
const inner = asRecord(result.self)
const inner = result.self as Record<string, unknown>
expect(inner).not.toBe(node)
expect(inner.name).toBe('root')
expect(inner.self).toBe(inner)
Expand Down Expand Up @@ -250,7 +244,7 @@ describe('convertRecord', () => {
describe('operationFields', () => {
it('routes every HTTP method of a path item to the converter', () => {
const fields = operationFields(identity)
expect(Object.keys(fields)).toEqual([...HTTP_METHODS])
expect(Object.keys(fields)).toEqual([...HTTP_METHODS_UP_TO_V31])
expect(Object.values(fields).every(entry => entry === identity)).toBe(
true,
)
Expand Down Expand Up @@ -279,10 +273,10 @@ describe('mapRecord', () => {
})

it('preserves key order', () => {
const input: UnknownRecord = {}
const input: Record<string, unknown> = {}
input.zebra = 1
input.apple = 2
const result = asRecord(mapRecord(input, identity))
const result = mapRecord(input, identity) as Record<string, unknown>
expect(Object.keys(result)).toEqual(['zebra', 'apple'])
})

Expand Down Expand Up @@ -351,7 +345,7 @@ describe('getRef', () => {

describe('setKey', () => {
it('defines an enumerable, writable, configurable own property', () => {
const target: UnknownRecord = {}
const target: Record<string, unknown> = {}
setKey(target, 'name', 'value')
expect(Object.getOwnPropertyDescriptor(target, 'name')).toEqual({
configurable: true,
Expand All @@ -362,12 +356,12 @@ describe('setKey', () => {
})

it('sets a __proto__ key as a plain own property without prototype pollution', () => {
const target: UnknownRecord = {}
const target: Record<string, unknown> = {}
setKey(target, '__proto__', { polluted: true })
const descriptor = Object.getOwnPropertyDescriptor(target, '__proto__')
expect(descriptor?.value).toEqual({ polluted: true })
expect(descriptor?.enumerable).toBe(true)
expect(Object.getPrototypeOf(target)).toBe(Object.prototype)
expect(asRecord({}).polluted).toBeUndefined()
expect('polluted' in {}).toBe(false)
})
})
35 changes: 10 additions & 25 deletions packages/downgrader/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
* through unchanged.
*/

export type UnknownRecord = Record<string, unknown>

/**
* Returned by a converter to remove its entry from the surrounding object or
* array: the single signal for constructs the target version cannot express.
Expand All @@ -16,7 +14,7 @@ export const DROP = Symbol('drop')
* Converts one field of a record. The whole source record is passed along
* for decisions that depend on sibling fields.
*/
export type FieldConverter = (item: unknown, source: UnknownRecord) => unknown
export type FieldConverter = (item: unknown, source: Record<string, unknown>) => unknown

/**
* What happens to each known field of a record: a converter, or `DROP` to
Expand All @@ -25,7 +23,8 @@ export type FieldConverter = (item: unknown, source: UnknownRecord) => unknown
*/
export type FieldTable = Readonly<Record<string, FieldConverter | typeof DROP>>

export const HTTP_METHODS = [
/** The Path Item operation fields of OpenAPI 3.0 and 3.1; 3.2 adds `query`. */
export const HTTP_METHODS_UP_TO_V31 = [
'delete',
'get',
'head',
Expand All @@ -36,17 +35,16 @@ export const HTTP_METHODS = [
'trace',
] as const

/** Field-table entries routing every Operation Object of a Path Item to `convert`. */
export function operationFields(convert: FieldConverter): FieldTable {
return Object.fromEntries(HTTP_METHODS.map(method => [method, convert]))
return Object.fromEntries(HTTP_METHODS_UP_TO_V31.map(method => [method, convert]))
}

/**
* Whether the value is a plain object (the only shape the converters walk
* into). Arrays, class instances, and primitives are handled by reference or
* by dedicated array helpers.
*/
export function isRecord(value: unknown): value is UnknownRecord {
export function isRecord(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
return false
}
Expand All @@ -58,7 +56,7 @@ export function isRecord(value: unknown): value is UnknownRecord {
* Sets a key on the output record with define-property semantics, so hostile
* key names like `__proto__` become plain own properties.
*/
export function setKey(target: UnknownRecord, key: string, value: unknown): void {
export function setKey(target: Record<string, unknown>, key: string, value: unknown): void {
Object.defineProperty(target, key, {
configurable: true,
enumerable: true,
Expand All @@ -83,7 +81,7 @@ function cloneValue(value: unknown, seen: WeakMap<object, unknown>): unknown {
}
return out
}
const out: UnknownRecord = {}
const out: Record<string, unknown> = {}
seen.set(value, out)
for (const [key, item] of Object.entries(value)) {
setKey(out, key, cloneValue(item, seen))
Expand Down Expand Up @@ -118,13 +116,13 @@ const converting = new WeakSet<object>()
* higher up the call stack: a cyclic reference, which would otherwise recurse
* forever.
*/
export function convertRecord(value: unknown, fields: FieldTable, finish?: (out: UnknownRecord, source: UnknownRecord) => unknown): unknown {
export function convertRecord(value: unknown, fields: FieldTable, finish?: (out: Record<string, unknown>, source: Record<string, unknown>) => unknown): unknown {
if (!isRecord(value) || converting.has(value)) {
return deepClone(value)
}
converting.add(value)
try {
const out: UnknownRecord = {}
const out: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
const convert = Object.hasOwn(fields, key) ? fields[key] : undefined
if (convert === DROP) {
Expand All @@ -143,16 +141,11 @@ export function convertRecord(value: unknown, fields: FieldTable, finish?: (out:
}
}

/**
* Applies `convert` to every value of a plain object, preserving key order
* and leaving out entries it turns into `DROP`. Non-object input is
* deep-cloned unchanged.
*/
export function mapRecord(value: unknown, convert: (item: unknown, key: string) => unknown): unknown {
if (!isRecord(value)) {
return deepClone(value)
}
const out: UnknownRecord = {}
const out: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
const converted = convert(item, key)
if (converted !== DROP) {
Expand All @@ -162,21 +155,13 @@ export function mapRecord(value: unknown, convert: (item: unknown, key: string)
return out
}

/**
* Applies `convert` to every element of an array, leaving out elements it
* turns into `DROP`. Non-array input is deep-cloned unchanged.
*/
export function mapArray(value: unknown, convert: (item: unknown) => unknown): unknown {
if (!Array.isArray(value)) {
return deepClone(value)
}
return value.map(item => convert(item)).filter(item => item !== DROP)
}

/**
* Returns the `$ref` string of a Reference-Object-shaped value, or
* `undefined` when the value is not one.
*/
export function getRef(value: unknown): string | undefined {
if (isRecord(value) && typeof value.$ref === 'string') {
return value.$ref
Expand Down
Loading