Support new.target in class constructors (#1917)

This commit is contained in:
Chris Tate
2026-10-22 21:52:42 -05:00
committed by GitHub
28 changed files with 644 additions and 3 deletions
@@ -238,7 +238,7 @@ export function returnsOnlyThisValue(member: ts.MethodDeclaration): boolean {
return valid;
}
function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean {
export function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean {
for (
let parent: ts.Node | undefined = decl.parent;
parent && !ts.isSourceFile(parent);
@@ -1,4 +1,10 @@
import { keyedHelperName } from "./stable-names.js";
import {
isNewTarget,
lowerNewTarget,
lowerNewTargetComparison,
lowerNewTargetName,
} from "./new-target.js";
import { lowerWebAssemblyValue, refuseWebAssemblyUse } from "./builtins/webassembly.js";
import { lowerAwaitedUniformPromiseAll } from "./builtins/promises.js";
import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js";
@@ -713,7 +719,8 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
if (ts.isPrefixUnaryExpression(expr)) return lowerer.lowerPrefixUnary(expr);
// `x++` / `x--` in expression position: yields the OLD value.
if (ts.isPostfixUnaryExpression(expr)) return lowerIncDec(lowerer, expr, false);
if (ts.isBinaryExpression(expr)) return lowerer.lowerBinary(expr);
if (ts.isBinaryExpression(expr))
return lowerNewTargetComparison(lowerer, expr) ?? lowerer.lowerBinary(expr);
if (ts.isCallExpression(expr)) {
// `h.return?.()` on a helper object: the method exists, so the
// optional call is the call.
@@ -729,13 +736,15 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
if (ts.isElementAccessExpression(expr)) return lowerer.lowerElementAccess(expr);
if (ts.isConditionalExpression(expr)) return lowerTernary(lowerer, expr);
if (ts.isPropertyAccessExpression(expr)) return lowerPropertyAccessExpr(lowerer, expr, loc);
if (ts.isPropertyAccessExpression(expr))
return lowerNewTargetName(lowerer, expr) ?? lowerPropertyAccessExpr(lowerer, expr, loc);
// Meta-properties, named: `new.target` reflects HOW a function was
// invoked (compiled functions are never constructors of themselves —
// no runtime invocation record exists), and `import.meta`/
// `import.defer` are module-loader surface a native binary does not
// carry.
if (isNewTarget(expr)) return lowerNewTarget(lowerer, expr);
if (ts.isMetaProperty(expr)) {
const name =
expr.keywordToken === ts.SyntaxKind.NewKeyword ? "new.target" : `import.${expr.name.text}`;
@@ -2188,6 +2188,9 @@ export class Lowerer {
* and erased instances). */
erasingDepth = 0;
localClassInstantiations: { owner: ts.FunctionLikeDeclaration; name: string }[] = [];
/** The function-local classes of the program with an `extends` clause, whether lowering collected
* them yet or not (new-target.ts); found on first use. */
functionLocalSubclasses: ts.ClassLikeDeclaration[] | undefined = undefined;
/** Instance types (by checker type id) of the local classes generic
* factory calls return, and the per-instance class each names. */
readonly localClassInstanceTypes = new Map<number, string>();
@@ -0,0 +1,357 @@
/* `new.target`. Compiled code has no invocation record, but inside a class
* constructor `new.target` is always the class of the object under
* construction: a compiled `new` allocates the instance with its concrete
* class before any constructor of the chain runs, and every constructor of
* the chain receives that instance as `this`. The program's class
* hierarchy is closed, so the value is a static dispatch over the
* constructor's class and its descendants: the deepest class the instance
* is an instance of is its exact class.
*
* The checker rejects `new.target` in methods, accessors, static blocks and
* field initializers. Plain functions (constructible in JavaScript) keep
* the refusal. */
import * as ts from "../ts7/adapter.js";
import { BOOL, STRING, type IrExpr, type IrType, type SrcLoc } from "../../ir/ir.js";
import { varRef } from "../../ir/build.js";
import { locOf } from "../program.js";
import type { Lowerer } from "./lowerer.js";
import type { ClassInfo } from "./lower-classes.js";
import { classValueRef, functionLocalClass } from "./lower-classes.js";
export function isNewTarget(node: ts.Node): node is ts.MetaProperty {
return (
ts.isMetaProperty(node) &&
node.keywordToken === ts.SyntaxKind.NewKeyword &&
node.name.text === "target"
);
}
/** Where `new.target` is bound: a class constructor, or a plain function
* (refused). */
type Binding =
| { kind: "constructor"; declaration: ts.ConstructorDeclaration }
| { kind: "function" };
function bindingOf(node: ts.Node): Binding {
for (let p = node.parent; p !== undefined; p = p.parent) {
if (ts.isArrowFunction(p)) continue;
if (ts.isConstructorDeclaration(p)) return { kind: "constructor", declaration: p };
if (ts.isFunctionLike(p) || ts.isSourceFile(p)) return { kind: "function" };
}
return { kind: "function" };
}
interface Dispatch {
info: ClassInfo;
/** The constructor's descendants, deepest first, then its class. A
* generic family stands for its instantiations, also those that later
* bodies instantiate: its interval holds them all. */
classes: ClassInfo[];
self: IrExpr | null;
}
function depth(info: ClassInfo): number {
let d = 0;
for (let c = info.base; c; c = c.base) d++;
return d;
}
/** The function-local classes of the program that extend a class. */
function functionLocalSubclasses(lowerer: Lowerer): ts.ClassLikeDeclaration[] {
let found = lowerer.functionLocalSubclasses;
if (found === undefined) {
const classes: ts.ClassLikeDeclaration[] = [];
for (const sf of lowerer.moduleOrder)
ts.walkPreorder(sf, (node) => {
if (
(ts.isClassDeclaration(node) || ts.isClassExpression(node)) &&
baseExpressionOf(node) !== undefined &&
functionLocalClass(node)
)
classes.push(node);
});
lowerer.functionLocalSubclasses = found = classes;
}
return found;
}
function baseExpressionOf(node: ts.ClassLikeDeclaration): ts.Expression | undefined {
let base = node.heritageClauses?.nodes.find(
(clause) => clause.token === ts.SyntaxKind.ExtendsKeyword,
)?.types?.nodes[0]?.expression;
while (base !== undefined && ts.isParenthesizedExpression(base)) base = base.expression;
return base;
}
/** Whether the class `node` extends the class of `info` (for an
* instantiation, its family), directly or through other classes, as far as
* its `extends` clauses name classes. */
function derivesFrom(
lowerer: Lowerer,
node: ts.ClassLikeDeclaration,
info: ClassInfo,
seen: Set<ts.Node>,
): boolean {
if (seen.has(node)) return false;
seen.add(node);
const base = baseExpressionOf(node);
const name =
base === undefined
? undefined
: ts.isIdentifier(base)
? base
: ts.isPropertyAccessExpression(base)
? base.name
: undefined;
let symbol = name === undefined ? undefined : lowerer.checker.getSymbolAtLocation(name);
if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0)
symbol = lowerer.checker.getAliasedSymbol(symbol);
if (symbol === undefined) return false;
const named = lowerer.classBySymbol.get(symbol);
if (named !== undefined)
return (
named === info ||
named === info.genericInstance?.family ||
lowerer.isSubclassOf(named.def.name, info.def.name)
);
// A class lowering has not collected: through its declaration.
for (const declaration of symbol.declarations) {
let cls: ts.Node | undefined = declaration;
if (ts.isVariableDeclaration(cls)) {
cls = cls.initializer;
while (cls !== undefined && ts.isParenthesizedExpression(cls)) cls = cls.expression;
}
if (
cls !== undefined &&
(ts.isClassDeclaration(cls) || ts.isClassExpression(cls)) &&
derivesFrom(lowerer, cls, info, seen)
)
return true;
}
return false;
}
/** `node`'s name, quoted, for diagnostics. */
function localClassName(node: ts.ClassLikeDeclaration): string {
if (node.name) return `'${node.name.text}'`;
const parent = node.parent;
return parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)
? `'${parent.name.text}'`
: "a class expression";
}
/** The classes `new.target` can denote in the constructor being lowered,
* or null after a diagnostic. */
function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null {
const binding = bindingOf(expr);
if (binding.kind !== "constructor") return null;
const info = lowerer.currentClass;
const cls = binding.declaration.parent;
if (!info || (info.decl !== null && info.decl !== cls)) {
lowerer.unsupported(
"SC1090",
expr,
"'new.target' in this constructor (its class has no lowering here)",
);
return null;
}
// An instantiation's subclasses can also be instantiations of generic
// subclasses of its family (`class G<T> extends Box<T>` under Box<number>).
const family = info.genericInstance?.family;
const descendants: ClassInfo[] = [];
for (const other of lowerer.classes.values()) {
if (other === info) continue;
if (
lowerer.isSubclassOf(other.def.name, info.def.name) ||
(family !== undefined &&
other.generic !== undefined &&
other !== family &&
lowerer.isSubclassOf(other.def.name, family.def.name))
)
descendants.push(other);
}
descendants.sort((a, b) => depth(b) - depth(a));
const classes = [...descendants, info];
for (const c of classes) {
if (c.localClass) {
lowerer.unsupported(
"SC1090",
expr,
`'new.target' where '${c.def.jsName ?? c.def.name}' is a function-local class (each evaluation creates a new class)`,
);
return null;
}
}
// Lowering collects a function-local class with the body declaring it,
// which may come after this constructor.
for (const local of functionLocalSubclasses(lowerer)) {
if (!derivesFrom(lowerer, local, info, new Set())) continue;
lowerer.unsupported(
"SC1090",
expr,
`'new.target' where ${localClassName(local)} is a function-local class (each evaluation creates a new class)`,
);
}
let self: IrExpr | null = null;
if (classes.length > 1) {
const thisLocal = lowerer.resolveThis(expr);
if (!thisLocal || thisLocal.type.kind !== "object") {
lowerer.unsupported(
"SC1090",
expr,
"'new.target' where the constructor's 'this' has no class representation",
);
return null;
}
self = varRef(thisLocal.id, thisLocal.type, locOf(expr));
}
return { info, classes, self };
}
/** A conditional chain selecting `arm(D)` for the exact class D of the
* instance under construction. */
function select(
dispatch: Dispatch,
type: IrType,
loc: SrcLoc,
arm: (info: ClassInfo) => IrExpr,
): IrExpr {
const { classes, self } = dispatch;
let result = arm(classes[classes.length - 1]!);
for (let i = classes.length - 2; i >= 0; i--) {
const c = classes[i]!;
result = {
kind: "ternary",
cond: { kind: "instanceOf", value: self!, className: c.def.name, type: BOOL, loc },
then: arm(c),
else_: result,
type,
loc,
};
}
return result;
}
function refuseFunction(lowerer: Lowerer, expr: ts.MetaProperty): never {
return lowerer.unsupported(
"SC1090",
expr,
"'new.target' in a plain function (a constructible function's invocation record does not exist in compiled code; use a class constructor)",
);
}
/** `new.target` as a value. */
export function lowerNewTarget(lowerer: Lowerer, expr: ts.MetaProperty): IrExpr {
const loc = locOf(expr);
if (bindingOf(expr).kind === "function") refuseFunction(lowerer, expr);
const dispatch = dispatchOf(lowerer, expr)!;
const type: IrType = { kind: "classval", className: dispatch.info.def.name };
for (const c of dispatch.classes) {
if (c.generic) {
lowerer.unsupported(
"SC1090",
expr,
`'new.target' as a value where '${c.def.jsName ?? c.def.name}' is a generic subclass (its instantiations have no common class value; read 'new.target.name' or compare 'new.target' with a class instead)`,
);
}
if (c !== dispatch.info && !lowerer.ctorAbiEquals(c, dispatch.info)) {
lowerer.unsupported(
"SC1090",
expr,
`'new.target' as a value where '${c.def.jsName ?? c.def.name}' constructs with a different signature than '${dispatch.info.def.jsName ?? dispatch.info.def.name}' (read 'new.target.name' or compare 'new.target' with a class instead)`,
);
}
}
return select(dispatch, type, loc, (c) =>
lowerer.coerceToExpected(classValueRef(lowerer, c, expr), type),
);
}
/** `new.target.name`: the exact class's name, without materializing a
* class value (subclasses may construct with other signatures). */
export function lowerNewTargetName(
lowerer: Lowerer,
access: ts.PropertyAccessExpression,
): IrExpr | null {
if (access.questionDotToken || access.name.text !== "name") return null;
let target: ts.Expression = access.expression;
while (ts.isParenthesizedExpression(target)) target = target.expression;
if (!isNewTarget(target) || bindingOf(target).kind !== "constructor") return null;
const dispatch = dispatchOf(lowerer, target);
if (!dispatch) return null;
const loc = locOf(access);
return select(dispatch, STRING, loc, (c) => {
// An own static `name` field replaces the class's name; a subclass has
// its own name. An instantiation's statics are its family's.
const owner = c.genericInstance?.family ?? c;
const field = owner.staticFields.find((f) => f.name === "name");
if (field !== undefined) {
if (field.type.kind !== "string")
lowerer.unsupported(
"SC1090",
access,
`'new.target.name' where '${c.def.jsName ?? c.def.name}' declares a static 'name' that is not a string`,
);
return { kind: "varRef", localId: field.globalId, type: field.type, loc };
}
if (owner.staticMethods?.has("name") || owner.genericStatics?.has("name"))
lowerer.unsupported(
"SC1090",
access,
`'new.target.name' where '${c.def.jsName ?? c.def.name}' declares a static method 'name'`,
);
// A generic family's instantiations share its name.
if (c.generic)
return { kind: "strLit", value: c.def.jsName ?? c.generic.baseName, type: STRING, loc };
return {
kind: "libCall",
fn: "class.name",
args: [classValueRef(lowerer, c, target)],
type: STRING,
loc,
};
});
}
/** `new.target === C` (and `!==`, `==`, `!=`, either operand order) with a
* program class named by the other operand: an exact-class test. */
export function lowerNewTargetComparison(
lowerer: Lowerer,
expr: ts.BinaryExpression,
): IrExpr | null {
const op = expr.operatorToken.kind;
const negated =
op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
op === ts.SyntaxKind.ExclamationEqualsToken;
if (
!negated &&
op !== ts.SyntaxKind.EqualsEqualsEqualsToken &&
op !== ts.SyntaxKind.EqualsEqualsToken
)
return null;
const strip = (e: ts.Expression): ts.Expression => {
while (ts.isParenthesizedExpression(e)) e = e.expression;
return e;
};
const left = strip(expr.left);
const right = strip(expr.right);
const target = isNewTarget(left) ? left : isNewTarget(right) ? right : null;
if (!target || bindingOf(target).kind !== "constructor") return null;
const other = target === left ? right : left;
if (!ts.isIdentifier(other)) return null;
// A constructor's new.target is never undefined.
if (other.text === "undefined" && (lowerer.typeOf(other).flags & ts.TypeFlags.Undefined) !== 0)
return { kind: "boolLit", value: negated, type: BOOL, loc: locOf(expr) };
const symbol = lowerer.resolveValueSymbol(other);
const named = symbol ? lowerer.classBySymbol.get(symbol) : undefined;
if (!named || named.generic || named.localClass) return null;
const dispatch = dispatchOf(lowerer, target);
if (!dispatch) return null;
const loc = locOf(expr);
return select(dispatch, BOOL, loc, (c) => ({
kind: "boolLit",
value: (c === named) !== negated,
type: BOOL,
loc,
}));
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7364-new-target-late-instantiations.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7560-new-target-constructors.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7561-new-target-generic-registry.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-late-local-subclass.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-local-subclass.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-plain-function.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-value-generic-subclass.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-value-signatures.ts"
],
"diags": []
}
@@ -0,0 +1,71 @@
// new.target in a constructor whose subclasses are generic classes
// instantiated only in bodies lowered later, and classes whose own static
// `name` field replaces their name.
class Plain {
readonly label: string;
readonly plain: boolean;
constructor() {
this.label = new.target.name;
this.plain = new.target === Plain;
}
}
class Sub<T> extends Plain {
readonly v: T;
constructor(v: T) {
super();
this.v = v;
}
}
class Box<T> {
readonly label: string;
readonly exact: boolean;
readonly value: T;
constructor(value: T) {
this.value = value;
this.label = new.target.name;
this.exact = new.target === Leaf;
}
}
class Tagged<T> extends Box<T> {}
class Leaf extends Tagged<number> {}
class Named {
static name = "custom";
readonly label: string;
constructor() {
this.label = new.target.name;
}
}
class Child extends Named {}
class Other extends Named {
static name = "other";
}
class Family<T> {
static name = "family";
readonly label: string;
readonly v: T;
constructor(v: T) {
this.label = new.target.name;
this.v = v;
}
}
function later(): void {
const n = new Sub<number>(1);
const p = new Plain();
console.log(n.label, new Sub<string>("s").label, p.label, n.plain, p.plain);
const t = new Tagged<number>(1);
const s = new Tagged<string>("a");
const b = new Box<number>(2);
const l = new Leaf(3);
console.log(t.label, s.label, b.label, l.label, t.exact, l.exact, b.exact);
console.log(new Family<boolean>(true).label);
}
// The bases construct here first; their subclasses only in later().
console.log(new Plain().label, new Box<boolean>(false).label);
console.log(new Named().label, new Child().label, new Other().label);
Named.name = "changed";
console.log(new Named().label, new Child().label, new Other().label);
later();
@@ -0,0 +1,55 @@
// new.target in class constructors: the class under construction, through
// inherited and explicit subclass constructors, Error subclasses, arrows,
// identity comparisons and class values.
abstract class AppError extends Error {
abstract readonly status: number;
constructor(message: string) {
super(message);
this.name = new.target.name;
}
}
class NotFound extends AppError {
readonly status = 404;
}
class Invalid extends AppError {
readonly status = 400;
readonly field: string;
constructor(field: string, detail: number) {
super(`bad ${field} ${detail}`);
this.field = field;
}
}
class Strict extends Invalid {}
const errors: AppError[] = [new NotFound("missing"), new Invalid("isbn", 3), new Strict("title", 1)];
for (const e of errors) console.log(e.name, e.status, e.message, String(e), e instanceof Invalid);
class Shape {
readonly kind: string;
readonly exact: boolean;
constructor() {
this.kind = new.target.name;
this.exact = new.target === Shape;
const probe = () => new.target !== Circle;
console.log("probe", probe(), new.target == Square, new.target === undefined);
}
describe(): string {
return `${this.kind}${this.exact ? " (exact)" : ""}`;
}
}
class Circle extends Shape {}
class Square extends Shape {
constructor() {
super();
console.log("square ctor sees", new.target.name);
}
}
for (const s of [new Shape(), new Circle(), new Square()]) console.log(s.describe());
class Node2 {
static make(): Node2 { return new this(); }
readonly origin: typeof Node2;
constructor() { this.origin = new.target; }
where(): string | undefined { return typeof (() => 1)() === "number" ? String(this.origin.name) : undefined; }
}
class Leaf extends Node2 {}
console.log(new Node2().origin === Node2, new Leaf().origin === Leaf, new Leaf().where(), Node2.make().origin.name);
@@ -0,0 +1,32 @@
// new.target in generic classes and as a construction registry key.
class Box<T> {
readonly label: string;
readonly value: T;
constructor(value: T) {
this.value = value;
this.label = `${new.target.name}<${typeof value}>`;
}
}
class NumBox extends Box<number> {
constructor(n: number) {
super(n * 2);
}
}
const a = new Box("s");
const b = new NumBox(4);
console.log(a.label, b.label, b.value);
class Registry {
static readonly created: string[] = [];
constructor() {
Registry.created.push(new.target.name);
if (new.target === Registry) console.log("plain registry");
}
}
class Users extends Registry {}
class Books extends Registry {}
new Users();
new Books();
new Registry();
new Books();
console.log(Registry.created.join(","));
@@ -0,0 +1,15 @@
// A function-local subclass refuses new.target in its base's constructor
// also when lowering collects it after that constructor: make() is lowered
// after the top-level code that constructs Base.
class Base {
readonly label: string;
constructor() {
this.label = new.target.name;
}
}
console.log(new Base().label);
function make(): Base {
const Local = class extends Base {};
return new Local();
}
console.log(make().label);
@@ -0,0 +1,5 @@
// A function-local subclass makes a new class per evaluation, so the
// base constructor's new.target has no static dispatch.
class A { constructor() { console.log(new.target.name); } }
function make() { class Local extends A {} return new Local(); }
make();
@@ -0,0 +1,3 @@
// new.target in a plain function has no invocation record to read.
function F(this: unknown) { console.log(new.target === undefined); }
F.call(null);
@@ -0,0 +1,5 @@
// new.target as a class value where a subclass is generic: its
// instantiations have no common class value.
class A { readonly t: typeof A; constructor() { this.t = new.target; } }
class B<T> extends A { readonly v: T | undefined = undefined; }
console.log(new B<number>().t.name);
@@ -0,0 +1,5 @@
// new.target as a class value needs every subclass to construct with the
// base's signature; B's constructor takes a parameter A's does not.
class A { readonly t: typeof A; constructor() { this.t = new.target; } }
class B extends A { constructor(readonly n: number) { super(); } }
console.log(new B(1).t.name);
@@ -0,0 +1,6 @@
new-target-late-local-subclass.ts:7:18 - error SC1090: 'new.target' where 'Local' is a function-local class (each evaluation creates a new class) is not supported yet
6 | constructor() {
7 | this.label = new.target.name;
| ^~~~~~~~~~
8 | }
@@ -0,0 +1,6 @@
new-target-local-subclass.ts:3:39 - error SC1090: 'new.target' where 'Local' is a function-local class (each evaluation creates a new class) is not supported yet
2 | // base constructor's new.target has no static dispatch.
3 | class A { constructor() { console.log(new.target.name); } }
| ^~~~~~~~~~
4 | function make() { class Local extends A {} return new Local(); }
@@ -0,0 +1,6 @@
new-target-plain-function.ts:2:41 - error SC1090: 'new.target' in a plain function (a constructible function's invocation record does not exist in compiled code; use a class constructor) is not supported yet
1 | // new.target in a plain function has no invocation record to read.
2 | function F(this: unknown) { console.log(new.target === undefined); }
| ^~~~~~~~~~
3 | F.call(null);
@@ -0,0 +1,6 @@
new-target-value-generic-subclass.ts:3:58 - error SC1090: 'new.target' as a value where 'B' is a generic subclass (its instantiations have no common class value; read 'new.target.name' or compare 'new.target' with a class instead) is not supported yet
2 | // instantiations have no common class value.
3 | class A { readonly t: typeof A; constructor() { this.t = new.target; } }
| ^~~~~~~~~~
4 | class B<T> extends A { readonly v: T | undefined = undefined; }
@@ -0,0 +1,6 @@
new-target-value-signatures.ts:3:58 - error SC1090: 'new.target' as a value where 'B' constructs with a different signature than 'A' (read 'new.target.name' or compare 'new.target' with a class instead) is not supported yet
2 | // base's signature; B's constructor takes a parameter A's does not.
3 | class A { readonly t: typeof A; constructor() { this.t = new.target; } }
| ^~~~~~~~~~
4 | class B extends A { constructor(readonly n: number) { super(); } }
@@ -0,0 +1 @@
{ "plain": 4300, "san": 17500 }
@@ -0,0 +1 @@
{ "plain": 6000, "san": 26000 }
@@ -0,0 +1 @@
{ "plain": 4500, "san": 23500 }