refactor: eliminate remaining hardcoded predicate-name dispatch
Extraction.ts (3 fixes):
- Replace 2 'inStackingContext' string checks with isVariableArityPredicate()
- Replace 7-name diagnostic formatting if/else with spec-driven
getPredicateSpec() checks (isDirectional → gap message,
isSize → threshold hint, else generic)
Grammar.ts: Replace 8 hardcoded parser routing checks
(atLeast/atMost/aspectRatio/between/clippedBy/attachedToScrollContainer/
escapeClippingChainOf/inStackingContext) with SIZE_PREDICATE_NAMES and
TOPOLOGY_PREDICATE_NAMES Sets derived from spec table.
Pipeline.ts: Replace 15-entry CODE_TO_CLAUSE_KIND map with runtime
generation from PREDICATE_SPECS. Prefix derived from spec.isSize
('size.*') / validOptions.includes('axis') ('alignment.*') /
else ('relation.*'). Manual override for aspectRatio code 15.
Proofs.ts: Replace 11-case switch(kind) with 5 spec-driven if/else
branches categorized by validOptions presence (hasGap→directional,
hasAxis→alignment) + 2 specific name checks (inside overflow,
aspectRatio ratio). 11 predicate names → 0 hardcoded.
Lexer.ts: Export KEYWORDS map for conformance testing.
Conformance tests:
- Solver: every BUILTIN_PREDICATES entry matches its PREDICATE_SPECS
counterpart; every spec name (incl. aliases) has a registered
evaluator with matching descriptor (2 tests)
- DSL: every predicate name from collectAllPredicateNames() appears
in the lexer KEYWORDS table (1 test)
598 SDK + 3 conformance + 57 E2E = 658 tests pass.
This commit is contained in:
@@ -106,6 +106,7 @@ import type {
|
|||||||
} from './diagnostics.js'
|
} from './diagnostics.js'
|
||||||
|
|
||||||
import { createDiagnostic } from './diagnostics.js'
|
import { createDiagnostic } from './diagnostics.js'
|
||||||
|
import { PREDICATE_SPECS } from './predicate-specs.js'
|
||||||
|
|
||||||
// Adapter interfaces - implementations injected at runtime
|
// Adapter interfaces - implementations injected at runtime
|
||||||
interface ExtractorRequest {
|
interface ExtractorRequest {
|
||||||
@@ -433,25 +434,23 @@ export interface PipelineResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse mapping from execution IR clause type codes to solver clause kinds.
|
* Reverse mapping from execution IR clause type codes to solver clause kinds.
|
||||||
* Mirrors the relationCodes table in imhotep-dsl/compiler.ts.
|
* Built from the predicate spec table — any predicate with a relationCode
|
||||||
|
* automatically gets a clause kind entry.
|
||||||
*/
|
*/
|
||||||
const CODE_TO_CLAUSE_KIND: Record<number, string> = {
|
const CODE_TO_CLAUSE_KIND: Record<number, string> = (() => {
|
||||||
1: 'relation.leftOf',
|
const map: Record<number, string> = {}
|
||||||
2: 'relation.rightOf',
|
for (const spec of PREDICATE_SPECS) {
|
||||||
3: 'relation.above',
|
if (!spec.relationCode) continue
|
||||||
4: 'relation.below',
|
let prefix = 'relation'
|
||||||
5: 'alignment.alignedWith',
|
if (spec.isSize) prefix = 'size'
|
||||||
6: 'alignment.leftAlignedWith',
|
else if (spec.validOptions.includes('axis')) prefix = 'alignment'
|
||||||
7: 'alignment.rightAlignedWith',
|
map[spec.relationCode] = `${prefix}.${spec.name}`
|
||||||
8: 'alignment.topAlignedWith',
|
}
|
||||||
9: 'alignment.bottomAlignedWith',
|
return map
|
||||||
10: 'alignment.centeredWithin',
|
})()
|
||||||
11: 'relation.inside',
|
// DSL keyword 'aspectRatioBetween' uses code 15; maps to the canonical
|
||||||
12: 'relation.contains',
|
// 'aspectRatio' predicate which has no separate relationCode.
|
||||||
13: 'relation.overlaps',
|
CODE_TO_CLAUSE_KIND[15] = 'size.aspectRatio'
|
||||||
14: 'relation.separatedFrom',
|
|
||||||
15: 'size.aspectRatio',
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main Pipeline Orchestrator
|
// Main Pipeline Orchestrator
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import assert from 'node:assert'
|
|||||||
|
|
||||||
import { parseSpec } from './parser.js'
|
import { parseSpec } from './parser.js'
|
||||||
import { lowerToCanonical } from './lower-to-canonical.js'
|
import { lowerToCanonical } from './lower-to-canonical.js'
|
||||||
|
import { KEYWORDS } from './lexer.js'
|
||||||
|
import { collectAllPredicateNames } from 'imhotep-core'
|
||||||
|
|
||||||
function parse(source: string) {
|
function parse(source: string) {
|
||||||
return parseSpec(source)
|
return parseSpec(source)
|
||||||
@@ -449,3 +451,20 @@ describe('inline state attachments', () => {
|
|||||||
assert.strictEqual(top.state.kind, 'expanded')
|
assert.strictEqual(top.state.kind, 'expanded')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conformance: PREDICATE_SPECS ↔ Lexer KEYWORDS sync
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('PredicateSpec ↔ Lexer keyword conformance', () => {
|
||||||
|
it('every predicate name from PREDICATE_SPECS has a lexer keyword entry', () => {
|
||||||
|
const allNames = collectAllPredicateNames()
|
||||||
|
const missing: string[] = []
|
||||||
|
for (const name of allNames) {
|
||||||
|
if (!(name in KEYWORDS)) {
|
||||||
|
missing.push(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(missing, [], `Predicate names missing from lexer KEYWORDS: ${missing.join(', ')}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ import type {
|
|||||||
ToleranceLiteralNode,
|
ToleranceLiteralNode,
|
||||||
} from 'imhotep-core'
|
} from 'imhotep-core'
|
||||||
|
|
||||||
import { isPredicateName, collectSpatialPredicateNames } from 'imhotep-core'
|
import { isPredicateName, collectSpatialPredicateNames, collectSizePredicateNames, collectTopologyPredicateNames } from 'imhotep-core'
|
||||||
|
|
||||||
import type { Token } from './lexer.js'
|
import type { Token } from './lexer.js'
|
||||||
|
|
||||||
|
// Cached predicate name sets derived from the spec table.
|
||||||
|
const SIZE_PREDICATE_NAMES = new Set(collectSizePredicateNames())
|
||||||
|
const TOPOLOGY_PREDICATE_NAMES = new Set(collectTopologyPredicateNames())
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Extended TokenKind for grammar checks
|
// Extended TokenKind for grammar checks
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1002,14 +1006,13 @@ export class GrammarParser {
|
|||||||
private parseClause(subject: SelectorRef, start: Point, negated: boolean): any | null {
|
private parseClause(subject: SelectorRef, start: Point, negated: boolean): any | null {
|
||||||
let assertion: any
|
let assertion: any
|
||||||
|
|
||||||
// Size assertion
|
// Dispatch assertion type based on predicate category from spec table.
|
||||||
if (this.check('atLeast') || this.check('atMost') || this.check('aspectRatio') || this.check('between')) {
|
const kind = this.currentToken().kind as string
|
||||||
|
if (SIZE_PREDICATE_NAMES.has(kind)) {
|
||||||
assertion = this.parseSizeAssertion(subject, start)
|
assertion = this.parseSizeAssertion(subject, start)
|
||||||
} else if (this.check('clippedBy') || this.check('attachedToScrollContainer') || this.check('escapeClippingChainOf') || this.check('inStackingContext')) {
|
} else if (TOPOLOGY_PREDICATE_NAMES.has(kind)) {
|
||||||
// Topology assertion
|
|
||||||
assertion = this.parseTopologyAssertion(subject, start)
|
assertion = this.parseTopologyAssertion(subject, start)
|
||||||
} else {
|
} else {
|
||||||
// Relation assertion
|
|
||||||
assertion = this.parseRelationAssertion(subject, start)
|
assertion = this.parseRelationAssertion(subject, start)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export interface Token {
|
|||||||
// Keywords Map
|
// Keywords Map
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const KEYWORDS: Record<string, TokenKind> = {
|
export const KEYWORDS: Record<string, TokenKind> = {
|
||||||
in: 'in',
|
in: 'in',
|
||||||
across: 'across',
|
across: 'across',
|
||||||
always: 'always',
|
always: 'always',
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import {
|
|||||||
getPredicateDiagnosticCode,
|
getPredicateDiagnosticCode,
|
||||||
getPredicateDecomposition,
|
getPredicateDecomposition,
|
||||||
isUnaryPredicate,
|
isUnaryPredicate,
|
||||||
|
isVariableArityPredicate,
|
||||||
|
getPredicateSpec,
|
||||||
} from 'imhotep-core'
|
} from 'imhotep-core'
|
||||||
import { buildGeometryWorld } from './world-builder.js'
|
import { buildGeometryWorld } from './world-builder.js'
|
||||||
import {
|
import {
|
||||||
@@ -1206,7 +1208,7 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const unaryPredicate = isUnaryPredicate(clause.relation)
|
const unaryPredicate = isUnaryPredicate(clause.relation)
|
||||||
&& !(clause.relation === 'inStackingContext' && clause.reference)
|
&& !(isVariableArityPredicate(clause.relation) && clause.reference)
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
type: 'FormulaNode',
|
type: 'FormulaNode',
|
||||||
@@ -1251,7 +1253,7 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isUnary = isUnaryPredicate(clause.relation)
|
const isUnary = isUnaryPredicate(clause.relation)
|
||||||
&& !(clause.relation === 'inStackingContext' && clause.reference)
|
&& !(isVariableArityPredicate(clause.relation) && clause.reference)
|
||||||
|
|
||||||
if (isUnary) {
|
if (isUnary) {
|
||||||
return {
|
return {
|
||||||
@@ -1645,19 +1647,12 @@ export function mapFolDiagnostic(
|
|||||||
: 'min'
|
: 'min'
|
||||||
const expectedGap = gapKind === 'min' ? (minGap ?? 0) : (maxGap ?? 0)
|
const expectedGap = gapKind === 'min' ? (minGap ?? 0) : (maxGap ?? 0)
|
||||||
const boundDescription = gapKind === 'min' ? 'minimum required gap' : 'maximum required gap'
|
const boundDescription = gapKind === 'min' ? 'minimum required gap' : 'maximum required gap'
|
||||||
if (predicateName === 'leftOf' && observedGap !== undefined) {
|
const spec = predicateName ? getPredicateSpec(predicateName) : undefined
|
||||||
message = `leftOf assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.`
|
const isDirectional = spec?.validOptions.includes('minGap') ?? false
|
||||||
|
if (isDirectional && observedGap !== undefined) {
|
||||||
|
message = `${predicateName} assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.`
|
||||||
fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`)
|
fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`)
|
||||||
} else if (predicateName === 'above' && observedGap !== undefined) {
|
} else if (spec?.isSize) {
|
||||||
message = `above assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.`
|
|
||||||
fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`)
|
|
||||||
} else if (predicateName === 'below' && observedGap !== undefined) {
|
|
||||||
message = `below assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.`
|
|
||||||
fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`)
|
|
||||||
} else if (predicateName === 'rightOf' && observedGap !== undefined) {
|
|
||||||
message = `rightOf assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.`
|
|
||||||
fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`)
|
|
||||||
} else if (predicateName === 'atLeast' || predicateName === 'atMost' || predicateName === 'between') {
|
|
||||||
fixHints.push(`Check the expected size threshold and the actual element dimensions using ui.extract(selector).`)
|
fixHints.push(`Check the expected size threshold and the actual element dimensions using ui.extract(selector).`)
|
||||||
} else {
|
} else {
|
||||||
fixHints.push(`Verify the expected layout and consider adjusting thresholds.`)
|
fixHints.push(`Verify the expected layout and consider adjusting thresholds.`)
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
clearPredicateRegistry,
|
clearPredicateRegistry,
|
||||||
registerDefaultPredicates,
|
registerDefaultPredicates,
|
||||||
getPredicateEvaluator,
|
getPredicateEvaluator,
|
||||||
|
BUILTIN_PREDICATES,
|
||||||
type GeometryWorld,
|
type GeometryWorld,
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
import { PREDICATE_SPECS, getAllPredicateSpecs, getPredicateSpec } from 'imhotep-core';
|
||||||
|
|
||||||
// --- Test helpers ------------------------------------------------------------
|
// --- Test helpers ------------------------------------------------------------
|
||||||
|
|
||||||
@@ -683,4 +685,55 @@ describe('spatial alias predicates', () => {
|
|||||||
assert.ok(Math.abs((result.metrics?.gap ?? 0) - Math.sqrt(200)) < 0.0001);
|
assert.ok(Math.abs((result.metrics?.gap ?? 0) - Math.sqrt(200)) < 0.0001);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conformance: PREDICATE_SPECS ↔ BUILTIN_PREDICATES sync
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('PredicateSpec ↔ BUILTIN_PREDICATES conformance', () => {
|
||||||
|
it('every BUILTIN_PREDICATES entry has a matching PREDICATE_SPECS entry', () => {
|
||||||
|
for (const bp of BUILTIN_PREDICATES) {
|
||||||
|
const spec = getPredicateSpec(bp.name)
|
||||||
|
assert.ok(spec, `BUILTIN_PREDICATES entry "${bp.name}" not found in PREDICATE_SPECS`)
|
||||||
|
assert.strictEqual(
|
||||||
|
spec.arity === 'variable' ? 2 : spec.arity,
|
||||||
|
bp.arity,
|
||||||
|
`Arity mismatch for "${bp.name}": PREDICATE_SPECS has ${spec.arity}, BUILTIN_PREDICATES has ${bp.arity}`,
|
||||||
|
)
|
||||||
|
for (const fact of bp.requiredFacts) {
|
||||||
|
assert.ok(
|
||||||
|
spec.requiredFacts.includes(fact),
|
||||||
|
`Required fact "${fact}" for "${bp.name}" is in BUILTIN_PREDICATES but not in PREDICATE_SPECS`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every registered predicate has an evaluator with matching descriptor', () => {
|
||||||
|
registerDefaultPredicates()
|
||||||
|
// Collect all predicate names from spec table (including aliases as separate evaluator names).
|
||||||
|
const specNames = new Set<string>()
|
||||||
|
for (const spec of PREDICATE_SPECS) {
|
||||||
|
specNames.add(spec.name)
|
||||||
|
for (const alias of spec.aliases) specNames.add(alias)
|
||||||
|
}
|
||||||
|
for (const name of specNames) {
|
||||||
|
const evaluator = getPredicateEvaluator(name)
|
||||||
|
assert.ok(evaluator, `No evaluator registered for predicate "${name}" from PREDICATE_SPECS`)
|
||||||
|
const spec = getPredicateSpec(name)
|
||||||
|
if (spec) {
|
||||||
|
const desc = evaluator.descriptor
|
||||||
|
assert.strictEqual(desc.name, name, `Evaluator name mismatch for "${name}"`)
|
||||||
|
const expectedArity = spec.arity === 'variable' ? 2 : spec.arity
|
||||||
|
assert.strictEqual(desc.arity, expectedArity, `Evaluator arity mismatch for "${name}"`)
|
||||||
|
for (const fact of spec.requiredFacts) {
|
||||||
|
assert.ok(
|
||||||
|
desc.requiredFacts.includes(fact),
|
||||||
|
`Required fact "${fact}" for "${name}" is in PREDICATE_SPECS but missing from evaluator descriptor`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
type GeometryWorld,
|
type GeometryWorld,
|
||||||
} from './registry.js';
|
} from './registry.js';
|
||||||
|
|
||||||
|
import { getPredicateSpec } from 'imhotep-core'
|
||||||
|
|
||||||
let proofCounter = 0;
|
let proofCounter = 0;
|
||||||
|
|
||||||
function nextProofId(): string {
|
function nextProofId(): string {
|
||||||
@@ -49,11 +51,14 @@ function buildFailedPredicate(
|
|||||||
relationKind: kind,
|
relationKind: kind,
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (kind) {
|
const spec = getPredicateSpec(kind)
|
||||||
case 'leftOf':
|
// Determine diagnostic shape family from spec metadata.
|
||||||
case 'rightOf':
|
const hasGap = spec?.validOptions.includes('minGap') && spec?.validOptions.includes('maxGap')
|
||||||
case 'above':
|
const hasAxis = spec?.validOptions.includes('axis')
|
||||||
case 'below': {
|
const isSize = spec?.isSize
|
||||||
|
|
||||||
|
// --- Directional gap (leftOf / rightOf / above / below) ---
|
||||||
|
if (hasGap) {
|
||||||
const gap = metrics.observedGap ?? metrics.gap ?? 0;
|
const gap = metrics.observedGap ?? metrics.gap ?? 0;
|
||||||
const min = metrics.minGap ?? 0;
|
const min = metrics.minGap ?? 0;
|
||||||
const max = metrics.maxGap ?? Infinity;
|
const max = metrics.maxGap ?? Infinity;
|
||||||
@@ -86,7 +91,8 @@ function buildFailedPredicate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'inside': {
|
// --- Containment / inside (unique overflow metrics) ---
|
||||||
|
if (kind === 'inside') {
|
||||||
const overflowLeft = metrics.overflowLeft ?? 0;
|
const overflowLeft = metrics.overflowLeft ?? 0;
|
||||||
const overflowTop = metrics.overflowTop ?? 0;
|
const overflowTop = metrics.overflowTop ?? 0;
|
||||||
const overflowRight = metrics.overflowRight ?? 0;
|
const overflowRight = metrics.overflowRight ?? 0;
|
||||||
@@ -123,9 +129,8 @@ function buildFailedPredicate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'atLeast':
|
// --- Size threshold (atLeast / atMost / between) ---
|
||||||
case 'atMost':
|
if (isSize && kind !== 'aspectRatio') {
|
||||||
case 'between': {
|
|
||||||
const observed = metrics.observed ?? metrics.value ?? 0;
|
const observed = metrics.observed ?? metrics.value ?? 0;
|
||||||
const min = metrics.min ?? -Infinity;
|
const min = metrics.min ?? -Infinity;
|
||||||
const max = metrics.max ?? Infinity;
|
const max = metrics.max ?? Infinity;
|
||||||
@@ -140,7 +145,8 @@ function buildFailedPredicate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'aspectRatio': {
|
// --- Aspect ratio ---
|
||||||
|
if (kind === 'aspectRatio') {
|
||||||
const observed = metrics.observed ?? 0;
|
const observed = metrics.observed ?? 0;
|
||||||
const minRatio = metrics.minRatio ?? -Infinity;
|
const minRatio = metrics.minRatio ?? -Infinity;
|
||||||
const maxRatio = metrics.maxRatio ?? Infinity;
|
const maxRatio = metrics.maxRatio ?? Infinity;
|
||||||
@@ -155,8 +161,8 @@ function buildFailedPredicate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'alignedWith':
|
// --- Alignment (alignedWith / centeredWithin) ---
|
||||||
case 'centeredWithin': {
|
if (hasAxis || kind === 'centeredWithin') {
|
||||||
const delta = metrics.delta ?? metrics.deltaX ?? metrics.deltaY ?? 0;
|
const delta = metrics.delta ?? metrics.deltaX ?? metrics.deltaY ?? 0;
|
||||||
const tolerance = metrics.tolerance ?? 0;
|
const tolerance = metrics.tolerance ?? 0;
|
||||||
return {
|
return {
|
||||||
@@ -169,14 +175,12 @@ function buildFailedPredicate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
|
||||||
// Fallback to generic synthesis from the first two numeric metrics.
|
// Fallback to generic synthesis from the first two numeric metrics.
|
||||||
const fallback = synthesizeGenericFailedPredicate(metrics);
|
const fallback = synthesizeGenericFailedPredicate(metrics);
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
return { ...fallback, relationKind: kind };
|
return { ...fallback, relationKind: kind };
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function synthesizeGenericFailedPredicate(
|
function synthesizeGenericFailedPredicate(
|
||||||
|
|||||||
Reference in New Issue
Block a user