mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-07 08:33:50 +08:00
Lower union member reads the checker narrowed away
Reading a member off a union-typed value is what most pi refusals at
`SC1090` came down to: `credential.type` on `ApiKeyCredential |
OAuthCredential`, `session.role`, `entry.timestamp`, `tool.operations`,
`arms.length`, `renderer.hasOverlayEntries`.
Three read shapes reach the same dispatch and now answer:
- A checker-NARROWED receiver keeps the wider union it stores, because
lowering erases flow narrowing (`stored?.type` after the earlier arm
returned, `(entry as { role: string }).role`). The dispatcher now reads
the member off whichever arm the value holds — an arm that does not
declare it answers undefined, exactly Node's get — and narrows the
answer to the checker's type, which traps on an arm the narrowing
ruled out (types do not lie). This fallback sits after every existing
lowering, so only refusals gain one.
- A member the arms answer through an ACCESSOR (a getter inherited from
a shared base class, `renderer.hasOverlayEntries`) has no field slot
`unionDisc` could read, so the read dispatches on the tag and calls
each arm's own getter. Data-field unions keep their `unionDisc`
lowering.
- `.length` on a union of arrays and strings (`Model[] | ModelRef[]`,
`string[] | Buffer[]`) dispatches on the tag and answers each arm's
own length.
The member read moves out of `lowerUnionProperty` into
`lowerUnionValueRead`, which both entry points share, and
`missingFieldAsUndefined` threads the narrowed-receiver get semantics
through the keyed read.
Corpus 8520/8521/8522 pin the three shapes differentially.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5afe93093f
commit
3328ed2aa7
@@ -1617,6 +1617,19 @@ export function lowerPropertyAccessExpr(
|
||||
}
|
||||
const asserted = assertedMemberRead(lowerer, expr.expression, expr.name.text, recvLowered, loc);
|
||||
if (asserted) return asserted;
|
||||
// Lowering erases flow narrowing, so a checker-NARROWED receiver keeps
|
||||
// the wider union it stores (`stored?.type` after the earlier arm
|
||||
// returned, `(entry as { role: string }).role`): the checker's type
|
||||
// names one record while the value still carries its siblings. Read the
|
||||
// member off whichever arm the value holds — an arm that does not
|
||||
// declare it answers undefined, exactly Node's get — and narrow the
|
||||
// answer to the checker's type, which traps on an arm the checker's
|
||||
// narrowing ruled out (types do not lie). Reaching this point means no
|
||||
// lowering above claimed the read, so only refusals gain a lowering.
|
||||
if (recvLowered.type.kind === "union") {
|
||||
const read = lowerUnionValueRead(lowerer, expr, recvLowered, true);
|
||||
if (read) return lowerer.maybeNarrow(read, expr);
|
||||
}
|
||||
// A checker-`any` receiver whose value is a FRESH typed result of an
|
||||
// earlier step in the chain (`out.split(" ").length`, where split
|
||||
// extracted the string and answered a native string[]): the member read
|
||||
@@ -3642,8 +3655,33 @@ export function lowerUnionProperty(
|
||||
"shared property access without a represented union receiver",
|
||||
);
|
||||
}
|
||||
const def = lowerer.unions.get(value.type.unionId);
|
||||
if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${value.type.unionId}`);
|
||||
const read = lowerUnionValueRead(lowerer, expr, value);
|
||||
if (read) return read;
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
expr,
|
||||
`reading '${expr.name.text}' on a union-typed value (every arm must be an object/record ` +
|
||||
`with a same-typed field '${expr.name.text}'; ` +
|
||||
`${NARROW_FIRST})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The member read itself on a lowered UNION value: `lowerUnionProperty`
|
||||
* after it has produced the value (retagged to the checker's union when
|
||||
* the checker removed arms) and the dispatcher's narrowed-receiver
|
||||
* fallback, which reaches the same read with the value a checker-narrowed
|
||||
* receiver still stores. Returns null when no arm can answer — the caller
|
||||
* owns the diagnostic. */
|
||||
export function lowerUnionValueRead(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.PropertyAccessExpression,
|
||||
value: IrExpr,
|
||||
missingFieldAsUndefined = false,
|
||||
): IrExpr | null {
|
||||
if (value.type.kind !== "union") return null;
|
||||
const unionId = value.type.unionId;
|
||||
const def = lowerer.unions.get(unionId);
|
||||
if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`);
|
||||
const field = expr.name.text;
|
||||
if (field === "length") {
|
||||
const presentArms = def.arms.filter((arm) => !isUnitType(arm));
|
||||
@@ -3675,6 +3713,42 @@ export function lowerUnionProperty(
|
||||
};
|
||||
}
|
||||
}
|
||||
// A union of arrays and strings (`Model[] | ModelRef[]`,
|
||||
// `string[] | Buffer[]`): every arm answers its own length, so the
|
||||
// read dispatches on the tag. Node's `.length` is a number for both,
|
||||
// and no arm can be absent here (a unit arm would have to throw on
|
||||
// this read, which the checker's narrowing already excludes).
|
||||
if (
|
||||
def.arms.length > 1 &&
|
||||
def.arms.every((arm) => arm.kind === "array" || arm.kind === "string")
|
||||
) {
|
||||
return perArmUnionRead(
|
||||
lowerer,
|
||||
expr,
|
||||
unionId,
|
||||
value,
|
||||
F64,
|
||||
"%unionLength",
|
||||
(arm, _tag, receiver) =>
|
||||
arm.kind === "string"
|
||||
? {
|
||||
kind: "strIntrinsic",
|
||||
method: "length",
|
||||
receiver,
|
||||
args: [],
|
||||
type: F64,
|
||||
loc: locOf(expr),
|
||||
}
|
||||
: {
|
||||
kind: "arrIntrinsic",
|
||||
method: "length",
|
||||
receiver,
|
||||
args: [],
|
||||
type: F64,
|
||||
loc: locOf(expr),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
const nonUnitArms = def.arms.filter((arm) => !isUnitType(arm));
|
||||
if (nonUnitArms.length === 1 && nonUnitArms[0]?.kind === "object") {
|
||||
@@ -3688,11 +3762,7 @@ export function lowerUnionProperty(
|
||||
field,
|
||||
);
|
||||
if (receiver === null) {
|
||||
const helper = lowerer.narrowedArmHelper(
|
||||
value.type.unionId,
|
||||
objectType,
|
||||
locOf(expr.expression),
|
||||
);
|
||||
const helper = lowerer.narrowedArmHelper(unionId, objectType, locOf(expr.expression));
|
||||
if (helper !== null)
|
||||
receiver = lowerer.checkedNarrowCall(helper, value, objectType, locOf(expr.expression));
|
||||
}
|
||||
@@ -3708,6 +3778,14 @@ export function lowerUnionProperty(
|
||||
}
|
||||
}
|
||||
}
|
||||
// A member the arms answer through an ACCESSOR (a getter inherited
|
||||
// from a shared base class — `renderer.hasOverlayEntries` on
|
||||
// `FullscreenRenderer | RegularRenderer`): there is no field slot
|
||||
// `unionDisc` could read, so dispatch on the tag and call each arm's
|
||||
// own getter. Fires only when some arm's answer is an accessor, so
|
||||
// data-field unions keep their `unionDisc` lowering.
|
||||
const accessorRead = lowerUnionAccessorRead(lowerer, expr, unionId, value);
|
||||
if (accessorRead) return accessorRead;
|
||||
let common: IrType | null = null;
|
||||
for (const arm of def.arms) {
|
||||
let ft: IrType | undefined;
|
||||
@@ -3736,7 +3814,15 @@ export function lowerUnionProperty(
|
||||
// arms): the JOIN path — `env.PORTLESS_PORT` on `ProcessEnv |
|
||||
// Record<string, string>`, the tail read of `loaded?.config.script`.
|
||||
const key: IrExpr = { kind: "strLit", value: field, type: STRING, loc: locOf(expr.name) };
|
||||
const keyed = lowerUnionKeyedRead(lowerer, expr, value.type.unionId, value, key, field);
|
||||
const keyed = lowerUnionKeyedRead(
|
||||
lowerer,
|
||||
expr,
|
||||
unionId,
|
||||
value,
|
||||
key,
|
||||
field,
|
||||
missingFieldAsUndefined,
|
||||
);
|
||||
if (keyed) return keyed;
|
||||
// Class arms declaring the field with compatible but different types
|
||||
// (`T | undefined` beside `T`): per-arm slot reads widened to one type.
|
||||
@@ -3763,12 +3849,178 @@ export function lowerUnionProperty(
|
||||
const member = lowerIndexedUnionMember(lowerer, value, indexedArms, field, locOf(expr));
|
||||
if (member) return member;
|
||||
}
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Evaluate `value` once into a hidden local and answer the read by
|
||||
* dispatching on the union's tag: arm `tag` (narrowed from the local)
|
||||
* answers `answer(arm, tag, narrowed)`. The first non-unit arm's answer
|
||||
* is the fallback branch, which a representable union never reaches. */
|
||||
function perArmUnionRead(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.Node,
|
||||
unionId: string,
|
||||
value: IrExpr,
|
||||
type: IrType,
|
||||
name: string,
|
||||
answer: (arm: IrType, tag: number, receiver: IrExpr) => IrExpr,
|
||||
): IrExpr {
|
||||
const loc = locOf(expr);
|
||||
const def = lowerer.unions.get(unionId);
|
||||
if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`);
|
||||
const stable = lowerer.declareHiddenLocal(name, value.type);
|
||||
const ref = (): IrExpr => ({ kind: "varRef", localId: stable.id, type: value.type, loc });
|
||||
let result: IrExpr | null = null;
|
||||
def.arms.forEach((arm, tag) => {
|
||||
if (isUnitType(arm)) return;
|
||||
const receiver: IrExpr = { kind: "unionNarrow", unionId, tag, value: ref(), type: arm, loc };
|
||||
const read = answer(arm, tag, receiver);
|
||||
result =
|
||||
result === null
|
||||
? read
|
||||
: {
|
||||
kind: "ternary",
|
||||
cond: {
|
||||
kind: "unionIsTag",
|
||||
unionId,
|
||||
tag,
|
||||
value: ref(),
|
||||
negated: false,
|
||||
type: BOOL,
|
||||
loc,
|
||||
},
|
||||
then: read,
|
||||
else_: result,
|
||||
type,
|
||||
loc,
|
||||
};
|
||||
});
|
||||
if (result === null) throw new InternalCompilerError(`lowerer bug: no arm answers ${name}`);
|
||||
return {
|
||||
kind: "seqExpr",
|
||||
stmts: [{ kind: "varDecl", localId: stable.id, init: value, loc }],
|
||||
result,
|
||||
type,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
|
||||
/** A member read the arms answer through an ACCESSOR — an inherited
|
||||
* getter (`this.renderer.hasOverlayEntries`). `unionDisc` reads record
|
||||
* and class FIELD slots, so a getter arm needs the tag dispatch: each
|
||||
* arm calls its own getter (once per read, as Node's read does). Data
|
||||
* arms in the same union read their slot. Every arm must answer one
|
||||
* type; a unit arm or an arm without the member declines (the caller's
|
||||
* keyed read and fences own those). Null when no arm answers through an
|
||||
* accessor — plain field unions keep `unionDisc`. */
|
||||
function lowerUnionAccessorRead(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.PropertyAccessExpression,
|
||||
unionId: string,
|
||||
value: IrExpr,
|
||||
): IrExpr | null {
|
||||
const def = lowerer.unions.get(unionId);
|
||||
if (!def) return null;
|
||||
const field = expr.name.text;
|
||||
const getterName = `get:${field}`;
|
||||
let type: IrType | null = null;
|
||||
let anyAccessor = false;
|
||||
const arms: { tag: number; read: (receiver: IrExpr) => IrExpr }[] = [];
|
||||
for (let tag = 0; tag < def.arms.length; tag++) {
|
||||
const arm = def.arms[tag]!;
|
||||
if (isUnitType(arm)) return null;
|
||||
let answer: IrType;
|
||||
let read: (receiver: IrExpr) => IrExpr;
|
||||
if (arm.kind === "record") {
|
||||
const shape = lowerer.shapes.get(arm.shapeId);
|
||||
if (!shape || shape.tuple) return null;
|
||||
const data = shape.fields.find((f) => f.name === field);
|
||||
if (data) {
|
||||
answer = data.type;
|
||||
read = (receiver) => ({
|
||||
kind: "recordGet",
|
||||
obj: receiver,
|
||||
shapeId: arm.shapeId,
|
||||
field,
|
||||
type: data.type,
|
||||
loc: locOf(expr),
|
||||
});
|
||||
} else {
|
||||
const getter = shape.fields.find((f) => f.name === getterName);
|
||||
if (!getter || getter.type.kind !== "func") return null;
|
||||
anyAccessor = true;
|
||||
answer = getter.type.ret;
|
||||
read = (receiver) =>
|
||||
lowerer.coerceToExpected(
|
||||
{
|
||||
kind: "callValue",
|
||||
callee: {
|
||||
kind: "recordGet",
|
||||
obj: receiver,
|
||||
shapeId: arm.shapeId,
|
||||
field: getterName,
|
||||
type: getter.type,
|
||||
loc: locOf(expr),
|
||||
},
|
||||
args: [],
|
||||
type: getter.type.kind === "func" ? getter.type.ret : DYN,
|
||||
loc: locOf(expr),
|
||||
},
|
||||
answer,
|
||||
);
|
||||
}
|
||||
} else if (arm.kind === "object") {
|
||||
const info = lowerer.classes.get(arm.className) ?? null;
|
||||
const slot = info?.fields.get(field);
|
||||
if (slot !== undefined) {
|
||||
answer = slot;
|
||||
read = (receiver) => ({
|
||||
kind: "fieldGet",
|
||||
obj: receiver,
|
||||
className: arm.className,
|
||||
field,
|
||||
type: slot,
|
||||
loc: locOf(expr),
|
||||
});
|
||||
} else {
|
||||
const getter = lowerer.findMethodOn(info, getterName);
|
||||
if (!getter || getter.sig.ret.kind === "void") return null;
|
||||
anyAccessor = true;
|
||||
answer = getter.sig.ret;
|
||||
read = (receiver) =>
|
||||
lowerer.fieldGetExpr(
|
||||
{
|
||||
container: "accessor",
|
||||
obj: receiver,
|
||||
className: arm.className,
|
||||
field,
|
||||
fieldType: getter.sig.ret,
|
||||
},
|
||||
locOf(expr),
|
||||
expr,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (answer.kind === "void" || isUnitType(answer)) return null;
|
||||
if (type !== null && !typeEquals(type, answer)) return null;
|
||||
type = answer;
|
||||
arms.push({ tag, read });
|
||||
}
|
||||
if (!anyAccessor || type === null || arms.length < 2) return null;
|
||||
return perArmUnionRead(
|
||||
lowerer,
|
||||
expr,
|
||||
`reading '${field}' on a union-typed value (every arm must be an object/record ` +
|
||||
`with a same-typed field '${field}'; ` +
|
||||
`${NARROW_FIRST})`,
|
||||
unionId,
|
||||
value,
|
||||
type,
|
||||
"%unionMember",
|
||||
(_arm, tag, receiver) => {
|
||||
const entry = arms.find((a) => a.tag === tag);
|
||||
if (!entry) throw new InternalCompilerError("lowerer bug: accessor arm missing");
|
||||
return entry.read(receiver);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8520-union-narrowed-receiver-reads.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8521-union-accessor-member-read.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8522-union-array-length.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Lowering erases flow narrowing, so a checker-NARROWED receiver still
|
||||
// stores its wider union. A member read dispatches on the arm the value
|
||||
// actually holds: an arm that declares the member answers its slot, an arm
|
||||
// that does not answers undefined (Node's get), and an arm the narrowing
|
||||
// ruled out traps in the checked conversion.
|
||||
interface ApiKeyCredential {
|
||||
type: "api_key";
|
||||
key?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
interface OAuthCredential {
|
||||
type: "oauth";
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
}
|
||||
type Credential = ApiKeyCredential | OAuthCredential;
|
||||
|
||||
function pick(stored: Credential | undefined): string | undefined {
|
||||
if (stored?.type === "oauth") return stored.access;
|
||||
const credential = stored?.type === "api_key" ? stored : undefined;
|
||||
return credential?.key;
|
||||
}
|
||||
console.log("key", pick({ type: "api_key", key: "k1" }));
|
||||
console.log("key absent", pick({ type: "api_key" }));
|
||||
console.log("oauth", pick({ type: "oauth", access: "a1", refresh: "r1", expires: 1 }));
|
||||
console.log("none", pick(undefined));
|
||||
|
||||
// An assertion narrows the checker's view of arms that carry different
|
||||
// payloads; every arm declares the asserted member here.
|
||||
interface Message {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
interface ToolResult {
|
||||
role: string;
|
||||
output: string[];
|
||||
}
|
||||
type AgentMessage = Message | ToolResult;
|
||||
function roleOf(message: AgentMessage): string {
|
||||
return (message as { role: string }).role;
|
||||
}
|
||||
console.log("role", roleOf({ role: "user", content: "hi" }));
|
||||
console.log("role", roleOf({ role: "tool", output: ["x"] }));
|
||||
|
||||
// One arm lacks the member the assertion names: Node's read answers
|
||||
// undefined for that arm.
|
||||
interface Stamped {
|
||||
timestamp?: number;
|
||||
text: string;
|
||||
}
|
||||
interface Unstamped {
|
||||
text: string;
|
||||
}
|
||||
function stamp(entry: Stamped | Unstamped): number | undefined {
|
||||
return (entry as { timestamp?: number }).timestamp;
|
||||
}
|
||||
console.log("stamp", stamp({ timestamp: 5, text: "a" }));
|
||||
console.log("stamp", stamp({ text: "b" }));
|
||||
|
||||
// A discriminant read re-narrows the union it came from.
|
||||
interface BashOperations {
|
||||
exec(command: string): string;
|
||||
}
|
||||
interface BashResult {
|
||||
exitCode: number;
|
||||
}
|
||||
type BashEventResult =
|
||||
| { operations: BashOperations; result?: undefined }
|
||||
| { operations?: undefined; result: BashResult };
|
||||
function describe(result: BashEventResult | undefined): string {
|
||||
if (result?.result) return `exit ${result.result.exitCode}`;
|
||||
return `operations ${typeof result?.operations}`;
|
||||
}
|
||||
console.log("event", describe({ result: { exitCode: 3 } }));
|
||||
console.log("event", describe({ operations: { exec: (command) => command } }));
|
||||
console.log("event", describe(undefined));
|
||||
|
||||
// A narrowed array whose stored union still owns an absent arm.
|
||||
function firstLength(models: string[] | undefined): number {
|
||||
if (models) return models.length;
|
||||
return -1;
|
||||
}
|
||||
console.log("first", firstLength(["a", "b"]), firstLength(undefined));
|
||||
@@ -0,0 +1,58 @@
|
||||
// A member the arms answer through an ACCESSOR — an inherited getter, or a
|
||||
// getter a subclass overrides — has no field slot to read, so the read
|
||||
// dispatches on the union's tag and calls the arm's own getter, once per
|
||||
// read, as Node does. A data-field arm beside a getter arm reads its slot.
|
||||
const logs: string[] = [];
|
||||
class Panel {
|
||||
items: string[];
|
||||
constructor(items: string[]) {
|
||||
this.items = items;
|
||||
}
|
||||
get hasOverlayEntries(): boolean {
|
||||
logs.push("has");
|
||||
return this.items.length > 0;
|
||||
}
|
||||
get count(): number {
|
||||
logs.push("count");
|
||||
return this.items.length;
|
||||
}
|
||||
}
|
||||
class MainPanel extends Panel {
|
||||
mode = "main";
|
||||
}
|
||||
class AltPanel extends Panel {
|
||||
mode = "alt";
|
||||
get count(): number {
|
||||
logs.push("alt count");
|
||||
return this.items.length * 10;
|
||||
}
|
||||
}
|
||||
class FixedPanel {
|
||||
mode = "fixed";
|
||||
items: string[];
|
||||
hasOverlayEntries = true;
|
||||
constructor(items: string[]) {
|
||||
this.items = items;
|
||||
}
|
||||
get count(): number {
|
||||
logs.push("fixed count");
|
||||
return this.items.length + 100;
|
||||
}
|
||||
}
|
||||
|
||||
function pickPanel(which: "m" | "a", items: string[]): MainPanel | AltPanel {
|
||||
return which === "m" ? new MainPanel(items) : new AltPanel(items);
|
||||
}
|
||||
for (const which of ["m", "a"] as const) {
|
||||
const panel = pickPanel(which, which === "m" ? ["x"] : []);
|
||||
console.log("panel", which, panel.mode, panel.hasOverlayEntries, panel.count);
|
||||
}
|
||||
|
||||
function pickMixed(which: "a" | "f", items: string[]): AltPanel | FixedPanel {
|
||||
return which === "a" ? new AltPanel(items) : new FixedPanel(items);
|
||||
}
|
||||
for (const which of ["a", "f"] as const) {
|
||||
const panel = pickMixed(which, ["y", "z"]);
|
||||
console.log("mixed", which, panel.mode, panel.hasOverlayEntries, panel.count);
|
||||
}
|
||||
console.log("logs", logs.join(","));
|
||||
@@ -0,0 +1,37 @@
|
||||
// `.length` on a union of arrays (and strings): every arm answers its own
|
||||
// length, so the read dispatches on the tag instead of demanding one
|
||||
// record arm that carries a `length` field.
|
||||
interface Model {
|
||||
id: string;
|
||||
}
|
||||
interface ModelRef {
|
||||
ref: string;
|
||||
}
|
||||
function ids(models: Model[] | ModelRef[]): number {
|
||||
return models.length;
|
||||
}
|
||||
console.log("models", ids([{ id: "a" }, { id: "b" }]));
|
||||
console.log("refs", ids([{ ref: "r" }, { ref: "s" }, { ref: "t" }]));
|
||||
|
||||
function scalars(values: string[] | number[]): number {
|
||||
return values.length;
|
||||
}
|
||||
console.log("strings", scalars(["a", "b", "c"]));
|
||||
console.log("numbers", scalars([1, 2]));
|
||||
|
||||
function textual(part: string | string[]): number {
|
||||
return part.length;
|
||||
}
|
||||
console.log("text", textual("hello"));
|
||||
console.log("parts", textual(["a", "b"]));
|
||||
|
||||
// The dispatch reads the value ONCE, even when the receiver is a call.
|
||||
let calls = 0;
|
||||
function make(fill: string): string[] {
|
||||
calls++;
|
||||
return [fill, fill];
|
||||
}
|
||||
function measured(part: string | string[]): number {
|
||||
return part.length;
|
||||
}
|
||||
console.log("call", measured(make("q")), "calls", calls);
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 1600, "san": 2900 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 2200, "san": 2500 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 1400, "san": 2400 }
|
||||
Reference in New Issue
Block a user