feat(setup): verify installed skill catalog identity

This commit is contained in:
morluto 2026-07-16 18:51:05 +08:00
parent 8de25d0230
commit b2d1f2affd
15 changed files with 203 additions and 78 deletions

View File

@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![89 MCP tools](https://img.shields.io/badge/MCP_tools-89-5c4ee5?style=flat-square)](#89-tools-for-investigation)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](#tool-catalog-for-investigation)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [89 tools](#89-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [Tool catalog](#tool-catalog-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
<br />
@ -320,7 +320,7 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
- Analyze Swift and Objective-C metadata without manually untangling every mangled symbol.
- Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other.
## 89 tools for investigation
## Tool catalog for investigation
| Tool family | Count | Examples |
| ------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |

View File

@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![89 MCP tools](https://img.shields.io/badge/MCP_tools-89-5c4ee5?style=flat-square)](#منصة-من-89-أداة)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](#منصة-أدوات-التحقيق)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [89 أداة](#منصة-من-89-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [دليل الأدوات](#منصة-أدوات-التحقيق) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
<br />
@ -157,7 +157,7 @@ rea uninstall --purge-data
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
## منصة من 89 أداة
## منصة أدوات التحقيق
| عائلة الأدوات | العدد | أمثلة |
| --------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |

View File

@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![89 MCP tools](https://img.shields.io/badge/MCP_tools-89-5c4ee5?style=flat-square)](#89-ツールのワークベンチ)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](#調査ツールカタログ)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [89 ツール](#89-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [ツールカタログ](#調査ツールカタログ) · [仕組み](#仕組み) · [FAQ](#faq)
<br />
@ -147,7 +147,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
- Swift / Objective-C のメタデータを解析する。
- Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。
## 89 ツールのワークベンチ
## 調査ツールカタログ
| ツール群 | 数 | 例 |
| ---------------------- | --: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![89 MCP tools](https://img.shields.io/badge/MCP_tools-89-5c4ee5?style=flat-square)](#89개-도구로-구성된-워크벤치)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](#조사-도구-카탈로그)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [89개 도구](#89개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [도구 카탈로그](#조사-도구-카탈로그) · [작동 방식](#작동-방식) · [FAQ](#faq)
<br />
@ -147,7 +147,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
- Swift 및 Objective-C 메타데이터를 분석합니다.
- Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다.
## 89개 도구로 구성된 워크벤치
## 조사 도구 카탈로그
| 도구 그룹 | 수 | 예시 |
| ------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![89 MCP tools](https://img.shields.io/badge/MCP_tools-89-5c4ee5?style=flat-square)](#89-个工具组成的工作台)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](#调查工具目录)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [89 个工具](#89-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [工具目录](#调查工具目录) · [工作原理](#工作原理) · [常见问题](#常见问题)
<br />
@ -147,7 +147,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
- 分析 Swift 和 Objective-C 元数据。
- 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。
## 89 个工具组成的工作台
## 调查工具目录
| 工具类别 | 数量 | 示例 |
| -------------- | ---: | ------------------------------------------------------------------------------------------------------------------------- |

View File

@ -33,7 +33,7 @@ graph TD
end
subgraph "Contracts Layer"
CONTRACTS["src/contracts/<br/>Caller-visible tool schemas<br/>86 analysis, managed, browser, Electron, application, and session tools"]
CONTRACTS["src/contracts/<br/>Caller-visible schemas for analysis, managed, browser, Electron, application, and session tools"]
end
subgraph "Domain Layer"

View File

@ -1,7 +1,7 @@
# Guided MCP prompts and completion
REA exposes six provider-neutral investigation workflows through the MCP
`prompts` capability. They complement the 89 tools; they do not add, remove, or
`prompts` capability. They complement the generated tool catalog; they do not add, remove, or
invoke tools by themselves.
## Prompt inventory

View File

@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ensureGeneratedFile } from "./lib/generated-file.mjs";
const arguments_ = new Set(process.argv.slice(2));
for (const argument of arguments_)
if (argument !== "--check")
throw new Error(`Unknown skill metadata option: ${argument}`);
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const path = join(root, "skills/rea-analysis/SKILL.md");
const current = await readFile(path, "utf8");
const { CATALOG_IDENTITY } = await import(
`${pathToFileURL(join(root, "dist/catalogIdentity.js")).href}?${String(Date.now())}`
);
const withCount = current.replace(
/^\s{2}tool_count:\s*\d+\s*$/mu,
` tool_count: ${String(CATALOG_IDENTITY.counts.mcp_tools)}`,
);
const digestLine = ` catalog_digest: "${CATALOG_IDENTITY.digests.combined_sha256}"`;
const source = /^\s{2}catalog_digest:/mu.test(withCount)
? withCount.replace(
/^\s{2}catalog_digest:\s*"[a-f0-9]{64}"\s*$/mu,
digestLine,
)
: withCount.replace(/^(\s{2}tool_count:\s*\d+\s*)$/mu, `$1\n${digestLine}`);
await ensureGeneratedFile({
path,
source,
check: arguments_.has("--check"),
generateCommand: "npm run docs:generate",
});

View File

@ -10,26 +10,20 @@ const README_PATHS = [
];
const familyLabel = (id) => (id === "electron" ? "Electron" : id);
const tableCounts = (content, path, total) => {
const tableCounts = (content, path, expectedCounts) => {
const lines = content.split(/\r?\n/u);
const headingIndex = lines.findIndex(
(line) => line.startsWith("## ") && line.includes(String(total)),
);
if (headingIndex === -1) {
throw new Error(`Missing ${total}-tool heading in ${path}`);
}
const counts = [];
for (const line of lines.slice(headingIndex + 1)) {
if (line.startsWith("## ")) break;
if (!line.trim().startsWith("|") || /^\|\s*-/u.test(line)) continue;
const count = Number(line.split("|")[2]?.trim());
if (!Number.isInteger(count)) {
if (counts.length === 0) continue;
throw new Error(`Non-numeric tool count in ${path}`);
for (const [index, line] of lines.entries()) {
if (!/^\|\s*-/u.test(line)) continue;
const counts = [];
for (const row of lines.slice(index + 1)) {
if (!row.trim().startsWith("|")) break;
const count = Number(row.split("|")[2]?.trim());
if (!Number.isInteger(count)) break;
counts.push(count);
}
counts.push(count);
if (counts.length === expectedCounts.length) return counts;
}
return counts;
throw new Error(`Missing tool-family inventory table in ${path}`);
};
const requireText = (issues, path, content, expected) => {
@ -42,16 +36,11 @@ export const documentationFactIssues = async (root, catalog) => {
const expectedCounts = catalog.tools.families.map(({ count }) => count);
for (const path of README_PATHS) {
const content = await readFile(join(root, path), "utf8");
requireText(
issues,
path,
content,
`MCP_tools-${String(catalog.tools.total)}`,
);
requireText(issues, path, content, "MCP-tool_catalog");
for (const client of catalog.setup_clients)
requireText(issues, path, content, client.display_name);
try {
const actualCounts = tableCounts(content, path, catalog.tools.total);
const actualCounts = tableCounts(content, path, expectedCounts);
if (JSON.stringify(actualCounts) !== JSON.stringify(expectedCounts))
issues.push(
`${path}: tool family counts ${JSON.stringify(actualCounts)} do not match ${JSON.stringify(expectedCounts)}`,

View File

@ -4,6 +4,7 @@ description: Reverse engineer native, Electron/JavaScript, and web applications
metadata:
version: "20"
tool_count: 89
catalog_digest: "59253c96793f162725d5dc08e611c1e0c19938b23de1e9490dcc1cc800dde955"
---
# REA

View File

@ -77,10 +77,18 @@ export interface DoctorHost {
providerInspections?(): Promise<readonly DoctorProviderInspection[]>;
installationPaths?(): Promise<readonly string[]>;
installedSkillVersion?(): Promise<string | undefined>;
installedSkillIdentity?(): Promise<InstalledSkillIdentity | undefined>;
clientRegistrations?(): Promise<readonly ClientRegistrationStatus[]>;
javascriptReplayCheck?(): Promise<DoctorCheck>;
}
/** Parsed identity committed by an installed REA skill. */
interface InstalledSkillIdentity {
readonly version: string | null;
readonly toolCount: number | null;
readonly catalogDigest: string | null;
}
/**
* Check requirements and an optional target without mutating the host.
* Every failed check includes remediation suitable for either the one-shot CLI
@ -109,7 +117,9 @@ export const runDoctor = async (
};
readonly skill: {
readonly installed_version: string | null;
readonly state: "aligned" | "stale" | "unknown";
readonly installed_tool_count: number | null;
readonly installed_catalog_digest: string | null;
readonly state: "aligned" | "stale" | "missing";
readonly remediation: string | null;
};
readonly registrations: readonly ClientRegistrationStatus[];
@ -216,7 +226,37 @@ export const runDoctor = async (
}),
);
const installationPaths = (await host.installationPaths?.()) ?? [];
const installedSkillVersion = await host.installedSkillVersion?.();
const observedSkillIdentity = await host.installedSkillIdentity?.();
const legacySkillVersion =
observedSkillIdentity === undefined
? await host.installedSkillVersion?.()
: undefined;
const installedSkillIdentity =
observedSkillIdentity ??
(legacySkillVersion === undefined
? undefined
: {
version: legacySkillVersion,
toolCount: null,
catalogDigest: null,
});
const installedSkillVersion = installedSkillIdentity?.version ?? undefined;
const skillAligned =
installedSkillIdentity?.version === PRODUCT_IDENTITY.skillVersion &&
installedSkillIdentity.toolCount === CATALOG_IDENTITY.counts.mcp_tools &&
installedSkillIdentity.catalogDigest ===
CATALOG_IDENTITY.digests.combined_sha256;
if (!skillAligned)
checks.push({
name: "skill:identity",
ok: false,
classification: "config_drift",
detail:
installedSkillIdentity === undefined
? "Installed REA skill identity is missing."
: "Installed REA skill identity is stale.",
remediation: "Run rea setup to update the installed REA skill.",
});
const registrations = (await host.clientRegistrations?.()) ?? [];
for (const registration of registrations)
if (registration.state !== "aligned")
@ -263,17 +303,17 @@ export const runDoctor = async (
},
skill: {
installed_version: installedSkillVersion ?? null,
installed_tool_count: installedSkillIdentity?.toolCount ?? null,
installed_catalog_digest: installedSkillIdentity?.catalogDigest ?? null,
state:
installedSkillVersion === undefined
? "unknown"
: installedSkillVersion === PRODUCT_IDENTITY.skillVersion
installedSkillIdentity === undefined
? "missing"
: skillAligned
? "aligned"
: "stale",
remediation:
installedSkillVersion === undefined ||
installedSkillVersion === PRODUCT_IDENTITY.skillVersion
? null
: "Run rea setup to update the installed REA skill.",
remediation: skillAligned
? null
: "Run rea setup to update the installed REA skill.",
},
registrations,
},
@ -407,6 +447,33 @@ export const systemDoctorHost = (
return undefined;
}
},
async installedSkillIdentity() {
try {
const content = await readFile(
join(
homedir(),
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
),
"utf8",
);
const version =
/^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null;
const countText = /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1];
const catalogDigest =
/^\s{2}catalog_digest:\s*"([a-f0-9]{64})"\s*$/mu.exec(content)?.[1] ??
null;
return {
version,
toolCount:
countText === undefined ? null : Number.parseInt(countText, 10),
catalogDigest,
};
} catch {
return undefined;
}
},
clientRegistrations: () => readClientRegistrationStatuses(homedir()),
});

View File

@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { runDoctor, type DoctorHost } from "../src/application/Doctor.js";
import { CATALOG_IDENTITY } from "../src/catalogIdentity.js";
import { PRODUCT_IDENTITY } from "../src/identity.js";
const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
platform: "darwin",
@ -12,6 +14,12 @@ const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
linuxDemoRuntimeReady: () => Promise.resolve(true),
brewHopperPath: () => Promise.resolve(undefined),
manualHopperPaths: () => Promise.resolve([]),
installedSkillIdentity: () =>
Promise.resolve({
version: PRODUCT_IDENTITY.skillVersion,
toolCount: CATALOG_IDENTITY.counts.mcp_tools,
catalogDigest: CATALOG_IDENTITY.digests.combined_sha256,
}),
...overrides,
});
@ -85,7 +93,12 @@ describe("doctor", () => {
host({
installationPaths: () =>
Promise.resolve(["/usr/local/bin/rea", "/home/user/bin/rea"]),
installedSkillVersion: () => Promise.resolve("10"),
installedSkillIdentity: () =>
Promise.resolve({
version: "10",
toolCount: null,
catalogDigest: null,
}),
}),
);
@ -99,6 +112,25 @@ describe("doctor", () => {
},
});
});
it("reports a missing installed skill as unhealthy", async () => {
const result = await runDoctor(
undefined,
host({ installedSkillIdentity: () => Promise.resolve(undefined) }),
);
expect(result.healthy).toBe(false);
expect(result.identity?.skill).toMatchObject({
state: "missing",
remediation: "Run rea setup to update the installed REA skill.",
});
expect(result.checks).toContainEqual({
name: "skill:identity",
ok: false,
classification: "config_drift",
detail: "Installed REA skill identity is missing.",
remediation: "Run rea setup to update the installed REA skill.",
});
});
it("reports stale client registration paths without claiming live state", async () => {
const result = await runDoctor(
undefined,

View File

@ -7,6 +7,8 @@ import {
type GhidraInstallationInspection,
} from "../src/ghidra/GhidraInstallation.js";
import { projectGhidraDoctorInspection } from "../src/ghidra/GhidraDoctor.js";
import { CATALOG_IDENTITY } from "../src/catalogIdentity.js";
import { PRODUCT_IDENTITY } from "../src/identity.js";
const INSTALL = "/opt/ghidra_12.1.2_PUBLIC";
const installationHost = (
@ -65,6 +67,12 @@ const doctorHost = (
manualHopperPaths: () => Promise.resolve([]),
providerInspections: () =>
Promise.resolve([projectGhidraDoctorInspection(ghidra)]),
installedSkillIdentity: () =>
Promise.resolve({
version: PRODUCT_IDENTITY.skillVersion,
toolCount: CATALOG_IDENTITY.counts.mcp_tools,
catalogDigest: CATALOG_IDENTITY.digests.combined_sha256,
}),
});
describe("Ghidra doctor integration", () => {

View File

@ -14,7 +14,8 @@ describe("package identity", () => {
scripts: z.object({
start: z.string(),
prepare: z.string(),
prepack: z.string().optional(),
prepack: z.string(),
prepublishOnly: z.string(),
}),
})
.parse(
@ -30,6 +31,11 @@ describe("package identity", () => {
expect(PRODUCT_IDENTITY.mcpCommand).toBe(`npx -y ${packageJson.name} mcp`);
expect(packageJson.scripts.start).toBe("node scripts/rea.mjs mcp");
expect(packageJson.scripts.prepare).toBe("npm run build && husky");
expect(packageJson.scripts.prepack).toBeUndefined();
expect(packageJson.scripts.prepack).toBe(
"npm run deps:check && node scripts/generate-skill-metadata.mjs --check",
);
expect(packageJson.scripts.prepublishOnly).toBe(
"npm run deps:check && node scripts/generate-skill-metadata.mjs --check",
);
});
});

View File

@ -6,7 +6,6 @@ import {
ENHANCED_TOOL_CONTRACTS,
OFFICIAL_TOOL_CONTRACTS,
SESSION_TOOL_CONTRACTS,
TOOL_CONTRACTS,
} from "../src/contracts/toolContracts.js";
import { NATIVE_TOOL_CONTRACTS } from "../src/contracts/nativeToolContracts.js";
import { ARTIFACT_TOOL_CONTRACTS } from "../src/contracts/artifactToolContracts.js";
@ -39,30 +38,18 @@ const expectedToolCounts = [
const toolCountsFromReadme = (content: string, path: string): number[] => {
const lines = content.split(/\r?\n/u);
const headingIndex = lines.findIndex((line) =>
new RegExp(`^## .*${String(TOOL_CONTRACTS.length)}`, "u").test(line),
);
if (headingIndex === -1)
throw new Error(
`Missing ${String(TOOL_CONTRACTS.length)}-tool heading in ${path}`,
);
const counts: number[] = [];
for (const line of lines.slice(headingIndex + 1)) {
if (line.startsWith("## ")) break;
if (!line.trim().startsWith("|") || /^\|\s*-/.test(line)) continue;
const cells = line.split("|").map((cell) => cell.trim());
const countCell = cells[2];
if (countCell === undefined)
throw new Error(`Malformed table row in ${path}`);
const count = Number(countCell);
if (!Number.isInteger(count)) {
if (counts.length === 0) continue;
throw new Error(`Non-numeric tool count in ${path}`);
for (const [index, line] of lines.entries()) {
if (!/^\|\s*-/u.test(line)) continue;
const counts: number[] = [];
for (const row of lines.slice(index + 1)) {
if (!row.trim().startsWith("|")) break;
const count = Number(row.split("|")[2]?.trim());
if (!Number.isInteger(count)) break;
counts.push(count);
}
counts.push(count);
if (counts.length === expectedToolCounts.length) return counts;
}
return counts;
throw new Error(`Missing tool-family inventory table in ${path}`);
};
describe("localized README product facts", () => {
@ -87,7 +74,7 @@ describe("localized README product facts", () => {
expect(content).toContain(client.displayName);
if (path === "README_ar.md")
expect(content).toContain("Windows غير مدعوم حاليًا");
expect(content).toContain(`MCP_tools-${String(TOOL_CONTRACTS.length)}`);
expect(content).toContain("MCP-tool_catalog");
expect(toolCountsFromReadme(content, path)).toEqual(expectedToolCounts);
},
);