Compare commits
2 Commits
v0.5.3
..
3d069319b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d069319b5 | |||
| b33431c1a6 |
+163
-12
@@ -545,21 +545,172 @@ export class CiFiles {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parseRequirementsMd(content: string): RequirementsMd {
|
private parseRequirementsMd(content: string): RequirementsMd {
|
||||||
return {
|
const v1: RequirementsMd["v1"] = [];
|
||||||
v1: [],
|
const v2: RequirementsMd["v2"] = [];
|
||||||
v2: [],
|
|
||||||
outOfScope: [],
|
const v1Section = this.extractSection(content, "## v1 Requirements");
|
||||||
traceability: [],
|
if (v1Section) {
|
||||||
};
|
const categoryBlocks = v1Section.split(/\n### /).filter(Boolean);
|
||||||
|
for (const block of categoryBlocks) {
|
||||||
|
const lines = block.split("\n");
|
||||||
|
const category = lines[0].trim();
|
||||||
|
const items: Array<{ id: string; description: string }> = [];
|
||||||
|
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
const tableMatch = line.match(/^\|\s*([A-Z]+-\d+)\s*\|\s*(.+?)\s*\|/);
|
||||||
|
if (tableMatch) {
|
||||||
|
items.push({ id: tableMatch[1], description: tableMatch[2] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const listMatch = line.match(/^\s*-?\s*\*?\s*\[?\s*\*?\s*([A-Z]+-\d+)[\]:\s*]*(.+)/);
|
||||||
|
if (listMatch) {
|
||||||
|
items.push({ id: listMatch[1], description: listMatch[2].trim() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
v1.push({ category, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const v2Section = this.extractSection(content, "## v2 Requirements");
|
||||||
|
if (v2Section) {
|
||||||
|
const categoryBlocks = v2Section.split(/\n### /).filter(Boolean);
|
||||||
|
for (const block of categoryBlocks) {
|
||||||
|
const lines = block.split("\n");
|
||||||
|
const category = lines[0].trim();
|
||||||
|
const items: Array<{ id: string; description: string }> = [];
|
||||||
|
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
const tableMatch = line.match(/^\|\s*([A-Z]+-\d+)\s*\|\s*(.+?)\s*\|/);
|
||||||
|
if (tableMatch) {
|
||||||
|
items.push({ id: tableMatch[1], description: tableMatch[2] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const listMatch = line.match(/^\s*-?\s*\*?\s*\[?\s*\*?\s*([A-Z]+-\d+)[\]:\s*]*(.+)/);
|
||||||
|
if (listMatch) {
|
||||||
|
items.push({ id: listMatch[1], description: listMatch[2].trim() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
v2.push({ category, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outOfScope: RequirementsMd["outOfScope"] = [];
|
||||||
|
const outSection = this.extractSection(content, "## Out of Scope");
|
||||||
|
if (outSection) {
|
||||||
|
const tableRows = outSection.split("\n").filter((line) => /^\|/.test(line) && !line.includes("---") && !line.includes("Feature"));
|
||||||
|
for (const row of tableRows) {
|
||||||
|
const cols = row.split("|").map((c) => c.trim()).filter(Boolean);
|
||||||
|
if (cols.length >= 2) {
|
||||||
|
outOfScope.push({ feature: cols[0], reason: cols[1] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (outOfScope.length === 0) {
|
||||||
|
const listItems = this.extractListItems(content, "## Out of Scope");
|
||||||
|
for (const item of listItems) {
|
||||||
|
outOfScope.push({ feature: item, reason: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const traceability: RequirementsMd["traceability"] = [];
|
||||||
|
const traceSection = this.extractSection(content, "## Traceability");
|
||||||
|
if (traceSection) {
|
||||||
|
const activeHeader = traceSection.includes("Active Milestone")
|
||||||
|
? "## v0.5 Requirements (Active Milestone)"
|
||||||
|
: content.includes("## v1 Requirements")
|
||||||
|
? "## v1 Requirements"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const tableRows = traceSection.split("\n").filter((line) => /^\|/.test(line) && !line.includes("---") && !line.includes("Requirement") && !line.includes("REQ-ID"));
|
||||||
|
for (const row of tableRows) {
|
||||||
|
const cols = row.split("|").map((c) => c.trim()).filter(Boolean);
|
||||||
|
if (cols.length >= 3) {
|
||||||
|
const req = cols[0];
|
||||||
|
const phaseStr = cols[1];
|
||||||
|
const phaseMatch = phaseStr.match(/(\d+)/);
|
||||||
|
const phase = phaseMatch ? parseInt(phaseMatch[1], 10) : 0;
|
||||||
|
const statusStr = cols[2].toLowerCase();
|
||||||
|
const status = ["pending", "in_progress", "complete", "blocked", "covered"].includes(statusStr)
|
||||||
|
? (statusStr === "covered" ? "complete" : statusStr as "pending" | "in_progress" | "complete" | "blocked")
|
||||||
|
: "pending";
|
||||||
|
traceability.push({ requirement: req, phase, status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allReqIds = new Set<string>();
|
||||||
|
for (const cat of [...v1, ...v2]) {
|
||||||
|
for (const item of cat.items) {
|
||||||
|
allReqIds.add(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of traceability) {
|
||||||
|
allReqIds.add(t.requirement);
|
||||||
|
}
|
||||||
|
const coveredInTrace = new Set(traceability.filter((t) => t.status === "complete").map((t) => t.requirement));
|
||||||
|
for (const reqId of allReqIds) {
|
||||||
|
if (!coveredInTrace.has(reqId)) {
|
||||||
|
traceability.push({ requirement: reqId, phase: 0, status: "pending" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { v1, v2, outOfScope, traceability };
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseArchitectureMd(content: string): ArchitectureMd {
|
private parseArchitectureMd(content: string): ArchitectureMd {
|
||||||
return {
|
const overview = this.extractSection(content, "## Overview") || "";
|
||||||
overview: this.extractSection(content, "## Overview") || "",
|
|
||||||
components: [],
|
const components: ArchitectureMd["components"] = [];
|
||||||
dataFlow: this.extractSection(content, "## Data Flow") || "",
|
const section = content;
|
||||||
buildOrder: [],
|
const componentRegex = /###\s+(.+)/g;
|
||||||
};
|
let compMatch;
|
||||||
|
|
||||||
|
const h3Positions: Array<{ name: string; start: number }> = [];
|
||||||
|
while ((compMatch = componentRegex.exec(section)) !== null) {
|
||||||
|
h3Positions.push({ name: compMatch[1].trim(), start: compMatch.index + compMatch[0].length });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < h3Positions.length; i++) {
|
||||||
|
const name = h3Positions[i].name;
|
||||||
|
const start = h3Positions[i].start;
|
||||||
|
const end = i + 1 < h3Positions.length ? h3Positions[i + 1].start - (content.substring(h3Positions[i + 1].start - 4, h3Positions[i + 1].start) === "### " ? 4 : 0) : content.length;
|
||||||
|
const block = content.slice(start, end);
|
||||||
|
|
||||||
|
const descMatch = block.match(/[-*]\s*\*?\*?(?:Description|description)\*?\*?\s*[::]\s*(.+)/);
|
||||||
|
const boundaryMatch = block.match(/[-*]\s*\*?\*?(?:Boundaries|boundaries)\*?\*?\s*[::]\s*(.+)/);
|
||||||
|
const depsMatch = block.match(/[-*]\s*\*?\*?(?:Depends on|depends on|Dependencies)\*?\*?\s*[::]\s*(.+)/);
|
||||||
|
|
||||||
|
components.push({
|
||||||
|
name,
|
||||||
|
description: descMatch ? descMatch[1].trim() : "",
|
||||||
|
boundaries: boundaryMatch ? boundaryMatch[1].trim() : "",
|
||||||
|
dependsOn: depsMatch
|
||||||
|
? depsMatch[1].split(",").map((d: string) => d.trim().replace(/\*\*/g, "")).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataFlow = this.extractSection(content, "## Data Flow")
|
||||||
|
|| this.extractSection(content, "## Data flow")
|
||||||
|
|| "";
|
||||||
|
|
||||||
|
const buildOrder: string[] = [];
|
||||||
|
const buildSection = this.extractSection(content, "## Build Order");
|
||||||
|
if (buildSection) {
|
||||||
|
const listItems = buildSection
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /^\d+\./.test(line.trim()))
|
||||||
|
.map((line) => line.trim().replace(/^\d+\.\s*/, ""));
|
||||||
|
buildOrder.push(...listItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { overview, components, dataFlow, buildOrder };
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractSection(content: string, header: string): string | null {
|
private extractSection(content: string, header: string): string | null {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export class BehavioralVerification extends VerificationLayer {
|
|||||||
checks.push(this.checkSpecificationRequirements(projectPath));
|
checks.push(this.checkSpecificationRequirements(projectPath));
|
||||||
checks.push(this.checkPlanMustHaves(projectPath, phase));
|
checks.push(this.checkPlanMustHaves(projectPath, phase));
|
||||||
checks.push(this.checkCodeHasExports(projectPath));
|
checks.push(this.checkCodeHasExports(projectPath));
|
||||||
|
checks.push(this.checkRequirementTestCoverage(projectPath));
|
||||||
|
|
||||||
const passed = checks.every((c) => c.status !== "fail");
|
const passed = checks.every((c) => c.status !== "fail");
|
||||||
return {
|
return {
|
||||||
@@ -219,6 +220,98 @@ export class BehavioralVerification extends VerificationLayer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private checkRequirementTestCoverage(projectPath: string): VerificationCheck {
|
||||||
|
const isGitRepo = fs.existsSync(path.join(projectPath, ".git"));
|
||||||
|
if (!isGitRepo) {
|
||||||
|
return this.check(
|
||||||
|
"Requirement test coverage via git log",
|
||||||
|
"skipped",
|
||||||
|
"Not a git repository — cannot check requirement coverage from commit history"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = execSync(
|
||||||
|
`git log --all --max-count=100 --format="%B%x01"`,
|
||||||
|
{ cwd: projectPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const coveredReqs = new Set<string>();
|
||||||
|
const ciBlockRegex = /---ci---[\s\S]*?---\/ci---/g;
|
||||||
|
const entries = raw.split("\x01").filter(Boolean);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
let match;
|
||||||
|
while ((match = ciBlockRegex.exec(entry)) !== null) {
|
||||||
|
const reqMatch = match[0].match(/covered:\s*\[([^\]]*)\]/);
|
||||||
|
if (reqMatch) {
|
||||||
|
const reqs = reqMatch[1].split(",").map((r: string) => r.trim().replace(/['"]/g, "")).filter(Boolean);
|
||||||
|
for (const req of reqs) coveredReqs.add(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ciBlockRegex.lastIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqPath = path.join(projectPath, ".ci", "REQUIREMENTS.md");
|
||||||
|
if (!fs.existsSync(reqPath)) {
|
||||||
|
return this.check(
|
||||||
|
"Requirement test coverage via git log",
|
||||||
|
"skipped",
|
||||||
|
"No REQUIREMENTS.md found to check coverage against"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(reqPath, "utf-8");
|
||||||
|
const allReqs = content
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /^\|.*\|.*\|.*\|/.test(line) && !line.includes("REQ-ID") && !line.includes("---"))
|
||||||
|
.map((line) => {
|
||||||
|
const cols = line.split("|").map((c) => c.trim()).filter(Boolean);
|
||||||
|
return cols.length >= 1 ? cols[0] : "";
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (allReqs.length === 0) {
|
||||||
|
return this.check(
|
||||||
|
"Requirement test coverage via git log",
|
||||||
|
"skipped",
|
||||||
|
"No requirements with REQ-IDs found in REQUIREMENTS.md"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const covered = allReqs.filter((r) => coveredReqs.has(r));
|
||||||
|
const coveragePct = Math.round((covered.length / allReqs.length) * 100);
|
||||||
|
|
||||||
|
if (coveragePct >= 80) {
|
||||||
|
return this.check(
|
||||||
|
"Requirement test coverage via git log",
|
||||||
|
"pass",
|
||||||
|
`${covered.length}/${allReqs.length} requirements covered (${coveragePct}%)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coveragePct >= 50) {
|
||||||
|
return this.check(
|
||||||
|
"Requirement test coverage via git log",
|
||||||
|
"warning",
|
||||||
|
`${covered.length}/${allReqs.length} requirements covered (${coveragePct}%) — target ≥80%`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.check(
|
||||||
|
"Requirement test coverage via git log",
|
||||||
|
"warning",
|
||||||
|
`${covered.length}/${allReqs.length} requirements covered (${coveragePct}%) — significant gaps`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return this.check(
|
||||||
|
"Requirement test coverage via git log",
|
||||||
|
"skipped",
|
||||||
|
"Could not read git log for requirement coverage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private checkCodeHasExports(projectPath: string): VerificationCheck {
|
private checkCodeHasExports(projectPath: string): VerificationCheck {
|
||||||
const srcDir = path.join(projectPath, "src");
|
const srcDir = path.join(projectPath, "src");
|
||||||
if (!fs.existsSync(srcDir)) {
|
if (!fs.existsSync(srcDir)) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
||||||
|
|
||||||
interface CodeFinding {
|
interface CodeFinding {
|
||||||
@@ -66,6 +67,7 @@ export class QualityVerification extends VerificationLayer {
|
|||||||
checks.push(this.checkP2P3Findings(p2p3Findings));
|
checks.push(this.checkP2P3Findings(p2p3Findings));
|
||||||
checks.push(this.checkTypeScriptStrictness(projectPath));
|
checks.push(this.checkTypeScriptStrictness(projectPath));
|
||||||
checks.push(this.checkConsistentNaming(projectPath));
|
checks.push(this.checkConsistentNaming(projectPath));
|
||||||
|
checks.push(this.checkTypeScriptCompilation(projectPath));
|
||||||
|
|
||||||
const hasP0Fail = p0Findings.length > 3;
|
const hasP0Fail = p0Findings.length > 3;
|
||||||
const passed = !hasP0Fail;
|
const passed = !hasP0Fail;
|
||||||
@@ -226,6 +228,32 @@ export class QualityVerification extends VerificationLayer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private checkTypeScriptCompilation(projectPath: string): VerificationCheck {
|
||||||
|
const tsconfigPath = path.join(projectPath, "tsconfig.json");
|
||||||
|
if (!fs.existsSync(tsconfigPath)) {
|
||||||
|
return this.check("TypeScript compilation", "skipped", "No tsconfig.json found");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync("npx tsc --noEmit 2>&1", {
|
||||||
|
cwd: projectPath,
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 60000,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
return this.check("TypeScript compilation", "pass", "TypeScript compiles without errors");
|
||||||
|
} catch (err) {
|
||||||
|
const execErr = err as { stdout?: string };
|
||||||
|
const output = execErr.stdout || "";
|
||||||
|
const errorCount = (output.match(/error TS/g) || []).length;
|
||||||
|
return this.check(
|
||||||
|
"TypeScript compilation",
|
||||||
|
errorCount > 5 ? "fail" : "warning",
|
||||||
|
`${errorCount} TypeScript compilation error(s)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private collectFiles(dir: string, files: string[]): void {
|
private collectFiles(dir: string, files: string[]): void {
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
||||||
|
|
||||||
interface ThreatEntry {
|
interface ThreatEntry {
|
||||||
@@ -250,24 +251,80 @@ export class SecurityVerification extends VerificationLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private checkDependencyVulnerabilities(projectPath: string): VerificationCheck {
|
private checkDependencyVulnerabilities(projectPath: string): VerificationCheck {
|
||||||
const packageLockPath = path.join(projectPath, "package-lock.json");
|
|
||||||
if (!fs.existsSync(packageLockPath)) {
|
|
||||||
return this.check(
|
|
||||||
"Dependency audit",
|
|
||||||
"skipped",
|
|
||||||
"No package-lock.json found — cannot audit dependencies"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const packageJsonPath = path.join(projectPath, "package.json");
|
const packageJsonPath = path.join(projectPath, "package.json");
|
||||||
if (!fs.existsSync(packageJsonPath)) {
|
if (!fs.existsSync(packageJsonPath)) {
|
||||||
return this.check("Dependency audit", "skipped", "No package.json found");
|
return this.check("Dependency audit", "skipped", "No package.json found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.check(
|
try {
|
||||||
"Dependency audit",
|
const result = execSync("npm audit --json 2>/dev/null", {
|
||||||
"pass",
|
cwd: projectPath,
|
||||||
"Dependency structure available for audit (run `npm audit` for full check)"
|
encoding: "utf-8",
|
||||||
);
|
timeout: 30000,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const audit = JSON.parse(result);
|
||||||
|
const vulnerabilities = audit.metadata?.vulnerabilities || {};
|
||||||
|
const high = vulnerabilities.high || 0;
|
||||||
|
const critical = vulnerabilities.critical || 0;
|
||||||
|
const medium = vulnerabilities.moderate || 0;
|
||||||
|
const low = vulnerabilities.low || 0;
|
||||||
|
const total = high + critical + medium + low;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return this.check("Dependency audit", "pass", "No known vulnerabilities in dependencies");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (critical > 0 || high > 0) {
|
||||||
|
return this.check(
|
||||||
|
"Dependency audit",
|
||||||
|
"fail",
|
||||||
|
`${total} vulnerabilities (critical: ${critical}, high: ${high}, medium: ${medium}, low: ${low})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.check(
|
||||||
|
"Dependency audit",
|
||||||
|
"warning",
|
||||||
|
`${total} vulnerabilities (medium: ${medium}, low: ${low}) — no critical/high`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const output = (err as { stdout?: string }).stdout;
|
||||||
|
if (output) {
|
||||||
|
try {
|
||||||
|
const audit = JSON.parse(output);
|
||||||
|
const vulnerabilities = audit.metadata?.vulnerabilities || {};
|
||||||
|
const high = vulnerabilities.high || 0;
|
||||||
|
const critical = vulnerabilities.critical || 0;
|
||||||
|
const medium = vulnerabilities.moderate || 0;
|
||||||
|
const low = vulnerabilities.low || 0;
|
||||||
|
const total = high + critical + medium + low;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return this.check("Dependency audit", "pass", "No known vulnerabilities in dependencies");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (critical > 0 || high > 0) {
|
||||||
|
return this.check(
|
||||||
|
"Dependency audit",
|
||||||
|
"fail",
|
||||||
|
`${total} vulnerabilities (critical: ${critical}, high: ${high}, medium: ${medium}, low: ${low})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.check(
|
||||||
|
"Dependency audit",
|
||||||
|
"warning",
|
||||||
|
`${total} vulnerabilities (medium: ${medium}, low: ${low}) — no critical/high`
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.check(
|
||||||
|
"Dependency audit",
|
||||||
|
"skipped",
|
||||||
|
"npm audit not available — run manually for full check"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user