diff --git a/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts b/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts index eb4af9e2..b7949912 100644 --- a/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts +++ b/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { getTableName } from 'drizzle-orm'; import { Hono } from 'hono'; -import type { Database } from '@lumibase/database'; +import { extensions, type Database } from '@lumibase/database'; import type { AppEnv, AuthPrincipal } from '../../env'; import { AISecureHarness, CORE_SKILLS, isControlPlaneSkill } from '../ai-harness'; +import { ExtensionsService } from '../extensions-service'; +import { ExtensionVerifierService } from '../extension-verifier'; import { McpService, type McpHarnessPort } from '../mcp-service'; import { ToolRegistryService } from '../tool-registry-service'; @@ -678,6 +680,303 @@ describe('G2 repro · the two transports are separate contracts', () => { // về một cấu trúc. }); + /** + * ── R16/R17 — soát ngữ nghĩa 48 mutation chưa map ────────────────────────── + * + * Đây là mảnh audit cuối mà review xác nhận làm được trong grant hiện tại: + * biến "49 candidate chưa khớp tên" thành phân loại có căn cứ, thay vì suy từ + * tên ra "không có skill" (đúng lỗi logic đã bị bắt ở vòng 6). + * + * Con số đổi từ **49 → 48** vì `compile_intent` bị phân loại sai: nó gọi LLM và + * `IntentService.compile` ghi rõ *"Returns the compiled draft for the user to + * confirm — never persists"*, nên nó là **provider-cost preview**, không phải + * mutation. Cùng lớp với `translate_text` ⇒ nhóm provider action: 1 → 2. + */ + it('R16: không tool nào trong 48 mutation chưa map có skill tương đương theo token-set', () => { + const tokens = (s: string) => + s.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).sort().join('|'); + + /** 48 mutation chưa map, nhóm theo prefix REST thật (đo bằng listTools + gọi handler). */ + const UNMAPPED_MUTATIONS = [ + // privilege-affecting (22) + 'assign_role_user', 'remove_role_user', 'attach_role_policy', 'detach_role_policy', 'update_role', + 'add_policy_permission', 'update_policy_permission', 'delete_policy_permission', + 'attach_policy_user', 'detach_policy_user', 'update_policy', + 'attach_api_key_role', 'detach_api_key_role', 'attach_api_key_policy', 'detach_api_key_policy', + 'create_share', 'revoke_share', + 'apply_access_import', 'restore_backup', + 'approve_content', 'reject_content', 'submit_review', + // content/schema/ops (26) + 'apply_schema', 'update_collection', 'upsert_field', + 'create_release', 'update_release', 'delete_release', 'publish_release', + 'register_materialization', 'refresh_materialization', 'drop_materialization', + 'delete_media', + 'upsert_tm', 'update_tm', 'delete_tm', + 'update_cdc_subscription', 'update_flow', 'update_team', + 'pause_intent', 'resume_intent', 'scan_intent', 'update_intent', + 'create_preset', 'update_preset', 'delete_preset', + 'install_marketplace_extension', 'publish_extension', + ]; + expect(UNMAPPED_MUTATIONS).toHaveLength(48); + expect(new Set(UNMAPPED_MUTATIONS).size).toBe(48); + + // Không tên nào khớp token-set với một skill thật ⇒ không có alias thuần. + const skillTokens = new Map(Object.keys(CORE_SKILLS).map((s) => [tokens(s), s])); + const accidental: string[] = []; + for (const tool of UNMAPPED_MUTATIONS) { + const hit = skillTokens.get(tokens(tool)); + if (hit) accidental.push(`${tool} → ${hit}`); + } + expect(accidental).toEqual([]); + + // Kiểm âm: thuật toán VẪN tìm được alias khi có thật (ca đã biết). + expect(skillTokens.get(tokens('cdc_subscription_replay'))).toBe('replayCdcSubscription'); + + // ── PHẠM VI (rút kinh nghiệm vòng 6) ─────────────────────────────────── + // Đây là bằng chứng "không có alias theo tên", KHÔNG phải "không thể có + // skill tương đương". Kết luận support/disabled của từng tool nằm ở §5d của + // PR, dựa trên đọc route + service, không dựa vào test này. + }); + + it('R17: upsert_field KHÔNG được phủ bởi createField — đo projection thật', async () => { + /** + * Hai tool duy nhất mà tên gợi ý đã có skill phủ. Đo thật cho thấy không. + */ + + // ── Ca 1: upsert_field vs skill createField ──────────────────────────── + const captured: Array<[string, Record]> = []; + const schemaService = { + createField: vi.fn((collection: string, input: Record) => { + captured.push([collection, input]); + return Promise.resolve({ id: 'f1' }); + }), + }; + const harness = new AISecureHarness({ + db: {} as Database, + siteId: 'site_1', + schemaService: schemaService as never, + enableAgentHarnessAudit: false, + }); + + /** + * LƯU Ý PHẠM VI (yêu cầu R3 của review vòng 8): đây là args **sau phép rename + * giả định** `field_name → name`. stdio quảng bá `field_name`, còn ở đây tôi + * đưa `name` vào skill — tức đã cho mapping một lợi thế. Ngay cả vậy, + * projection vẫn rụng field. + * + * Và kết luận về **nhánh upsert** đến từ source, không phải từ việc + * `updateField` không tồn tại: `PUT /collections/:c/fields/:f` dùng + * `SchemaService.upsertField` (`routes/collections.ts:236`, + * `schema-service.ts:538`) — update nếu có, create nếu chưa. Skill chỉ gọi + * `createField`. + */ + await harness.runSkill('createField', { + collection: 'posts', + name: 'body', + type: 'text', + required: true, + interface: 'markdown', + note: 'nội dung bài', + }); + + expect(captured).toHaveLength(1); + const [, input] = captured[0]!; + // CURRENT: skill **hardcode** `interface: 'input'` và chỉ đọc 4 arg, nên + // `interface: 'markdown'` và `note` bị rơi âm thầm. + expect(input['interface']).toBe('input'); + expect(input['note']).toBeUndefined(); + expect(Object.keys(input).sort()).toEqual(['interface', 'name', 'required', 'type']); + // Ngữ cảnh (không phải bằng chứng cho nhánh upsert — xem ghi chú trên): + expect(CORE_SKILLS['updateField']).toBeUndefined(); + + // ── Ca 2 chuyển sang R18 ──────────────────────────────────────────────── + // Ca marketplace cần probe cặp mới đo được, nên tách ra `R18` bên dưới. + // R17 giữ đúng phạm vi: chỉ ca `upsert_field`, đo bằng projection thật. + }); + + it('R19: bảng create/update/delete từng domain, đo từ CORE_SKILLS thật (sửa claim "11 tài nguyên")', () => { + /** + * Sửa theo yêu cầu **R2** của review vòng 8. Claim cũ — *"11 tài nguyên chỉ + * có create+delete"* — **sai**: thiếu 11 tên `update*` không chứng minh cả 11 + * domain đều có cặp create/delete. Đo lại từng domain, ba thao tác. + */ + const has = (n: string) => Boolean(CORE_SKILLS[n]); + /** [domain, createSkill|null, updateSkill|null, deleteSkill|null] */ + const table: Array<[string, string | null, string | null, string | null]> = [ + // A. có create + delete, KHÔNG có update → 8 domain + ['collection', 'createCollection', null, 'deleteCollection'], + ['field', 'createField', null, 'deleteField'], + ['role', 'createRole', null, 'deleteRole'], + ['policy', 'createPolicy', null, 'deletePolicy'], + ['flow', 'createFlow', null, 'deleteFlow'], + ['intent', 'createIntent', null, 'deleteIntent'], + ['team', 'createTeam', null, 'deleteTeam'], + ['cdcSubscription', 'createCdcSubscription', null, 'deleteCdcSubscription'], + // B. thiếu CẢ BA thao tác → 3 domain + ['release', null, null, null], + ['preset', null, null, null], + ['translationMemory (tm)', null, null, null], + ]; + + for (const [domain, c, u, d] of table) { + if (c) expect(has(c), `${domain}: ${c} tồn tại`).toBe(true); + if (d) expect(has(d), `${domain}: ${d} tồn tại`).toBe(true); + // update luôn absent trong bảng này + expect(u).toBeNull(); + for (const cand of [`update${domain[0]!.toUpperCase()}${domain.slice(1)}`]) { + expect(CORE_SKILLS[cand], `${cand} phải absent`).toBeUndefined(); + } + } + + // Nhóm A: 8 domain có cặp create/delete + expect(table.filter(([, c, , d]) => c !== null && d !== null)).toHaveLength(8); + // Nhóm B: 3 domain absent cả ba + expect(table.filter(([, c, u, d]) => c === null && u === null && d === null)).toHaveLength(3); + // release/preset/tm: absent cả ba, kiểm trực tiếp + for (const n of ['createRelease', 'updateRelease', 'deleteRelease', + 'createPreset', 'updatePreset', 'deletePreset']) { + expect(CORE_SKILLS[n], `${n} absent`).toBeUndefined(); + } + + // NGỮ CẢNH, không dùng để phủ domain khác: registry CÓ 7 skill update/upsert + // cho các domain khác. + const updateish = Object.keys(CORE_SKILLS).filter((n) => /^(update|upsert)/.test(n)).sort(); + expect(updateish).toEqual([ + 'updateExtension', 'updateItem', 'updateTranslation', 'updateUser', + 'updateVersion', 'updateWebhook', 'upsertSetting', + ]); + + // Và sửa nốt một con số sai: trong 11 tool update-ish chưa map, **9** thuộc + // nhóm C còn **2** (`update_role`, `update_policy`) thuộc nhóm P — nên câu + // "11 trong 26 nhóm C" của bản trước là sai. + const UPDATEISH_UNMAPPED_P = ['update_role', 'update_policy']; + const UPDATEISH_UNMAPPED_C = [ + 'update_collection', 'update_flow', 'update_intent', 'update_team', + 'update_cdc_subscription', 'update_release', 'update_preset', 'update_tm', 'upsert_field', + ]; + expect(UPDATEISH_UNMAPPED_P).toHaveLength(2); + expect(UPDATEISH_UNMAPPED_C).toHaveLength(9); + + // KHÔNG đề xuất delete+recreate làm workaround cho update (yêu cầu R2). + }); + + it('R18: thay marketplace install bằng generic registration làm MẤT gate/default/provenance (probe cặp)', async () => { + /** + * Sửa theo yêu cầu **R1** của review vòng 8. + * + * Bản trước gọi đây là "bỏ qua verify chữ ký" nhưng chỉ assert sự tồn tại + + * description của skill ⇒ **không phải bằng chứng đo được**. Reviewer đã đọc + * đủ hai đường và xác nhận rủi ro là **có căn cứ nhưng có điều kiện**: + * + * `routes/marketplace.ts:543-622` — kiểm `extensions:install`, resolve slug + * thành listing global đã publish, gọi `ExtensionVerifierService + * .verifyByMetadata`, chặn khi `requireSignature && !verdict.ok`, chặn + * reserved `lumibase-*` không có official signature, rồi mới insert; đồng + * thời bảo toàn signature/provenance/marketplaceSlug, derive + * `isOfficial`/`verifiedAt` **ở server**, dùng `enabledByDefault`, khởi tạo + * `capabilities: []`. + * + * `ai-harness.ts:1740` → `extensions-service.ts:42` — generic registration + * nhận metadata **do caller cấp** và insert; **không** marketplace lookup, + * **không** verifier, và cho caller cấp `capabilities`. + * + * PHÁT BIỂU ĐÚNG (không phải "bypass đã thành công"): *nếu* một adapter + * resolve đủ metadata rồi thay marketplace install bằng generic registration + * thì **mất** các check/default/provenance đó. Bản thân slug-only sẽ **fail** + * vì thiếu tham số bắt buộc, nên đây **không** phải bypass chạy được, và + * **không** suy ra "đã chạy được unsigned code" — kiểm crypto là việc riêng. + * + * Probe dưới đây đo **nửa generic registration**: metadata đầy đủ do caller + * cấp thì insert **không** đi qua verifier nào. Nửa marketplace (invalid + * verdict ⇒ reject + zero insert) thuộc route marketplace, ngoài hai file + * repro được cấp, nên ghi là source-backed thay vì tự mở scope. + */ + /** + * SỬA THEO F1. Bản trước gắn `verifyByMetadata` vào một **object giả** rồi + * assert bộ đếm bằng 0 — nhưng verifier thật là `ExtensionVerifierService`, + * một class khác, và `ExtensionsService` thật KHÔNG hề có method đó. Nên + * assertion ấy là **tautology**: nó đúng bất kể production làm gì. Kiểm âm + * đã chứng minh — thêm verification + ép provenance vào + * `ExtensionsService.installExtension` thật, test vẫn XANH. + * + * Bản này đo đường thật: + * - `ExtensionsService` **thật** (không mock), trên db recorder; + * - spy vào `ExtensionVerifierService.prototype.verifyByMetadata` — verifier + * **thật** — nên nếu service thật bắt đầu verify thì spy sẽ bắt được; + * - đọc giá trị **thực sự đi vào `db.insert().values()`**, không phải args + * mà caller truyền. + * + * Nhờ đó: thêm verifier vào đường generic ⇒ đỏ ở bộ đếm; ép + * `capabilities: []` ⇒ đỏ; derive `isOfficial`/`verifiedAt` server-side ⇒ đỏ. + */ + const verifierSpy = vi.spyOn(ExtensionVerifierService.prototype, 'verifyByMetadata'); + + const inserts: Array<{ table: string; values: Record }> = []; + const db = { + insert: (t: unknown) => { + const table = getTableName(t as Parameters[0]); + return { + values: (values: Record) => { + inserts.push({ table, values }); + const result = [{ id: 'ext_1', ...values }]; + return { + returning: () => Promise.resolve(result), + then: (resolve: (v: unknown[]) => unknown) => Promise.resolve(result).then(resolve), + }; + }, + }; + }, + } as unknown as Database; + + // Service THẬT — đây là điểm khác cốt lõi so với bản trước. + const extensionsService = new ExtensionsService({ db, siteId: 'site_1', userId: 'user_1' }); + const harness = new AISecureHarness({ + db, + siteId: 'site_1', + extensionsService, + enableAgentHarnessAudit: false, + }); + + // Caller tự cấp TOÀN BỘ metadata, gồm cả `capabilities` — thứ mà đường + // marketplace luôn khởi tạo `[]` ở server. + const outcome = await harness.runSkill('installExtension', { + key: 'evil-panel', + name: 'evil-panel', + version: '1.0.0', + type: 'panel', + enabled: true, + bundleUrl: 'https://attacker.example/bundle.js', + manifest: { entry: 'index.js' }, + capabilities: ['items:write', 'schema:write'], + }); + + expect(outcome.success).toBe(true); + + // Hàng THẬT mà service thật ghi xuống `extensions`. + const extRows = inserts.filter((i) => i.table === getTableName(extensions)); + expect(extRows, 'service thật phải insert đúng 1 hàng extensions').toHaveLength(1); + const row = extRows[0]!.values; + + // ĐO ĐƯỢC 1: verifier THẬT không được gọi ở đâu trên đường generic. + expect(verifierSpy).not.toHaveBeenCalled(); + expect(verifierSpy.mock.calls).toHaveLength(0); + + // ĐO ĐƯỢC 2: capabilities do CALLER quyết định — server KHÔNG ép `[]`. + expect(row['capabilities']).toEqual(['items:write', 'schema:write']); + + // ĐO ĐƯỢC 3: không trường provenance nào của marketplace được dựng, nên + // trust không thể derive ở server như đường marketplace làm. + for (const field of ['marketplaceSlug', 'verifiedAt', 'isOfficial', 'signature', 'publisherKeyId']) { + expect(row[field], `${field} không được dựng ở đường generic`).toBeUndefined(); + } + + verifierSpy.mockRestore(); + + // Điều kiện enable (ghi vào §5d): adapter phải bảo toàn signature policy, + // reserved namespace, server-derived trust, permission và provenance — + // không phải chỉ đổi tên tham số `slug` ↔ `bundleUrl`. + }); + it('R9: the FULL HTTP MCP registry is camelCase and contains no snake_case name', async () => { const registry = new ToolRegistryService(registryDb(), 'site_1', CORE_SKILLS); const httpNames = (await registry.listTools()).map((t) => t.name); diff --git a/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts b/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts index d3b79e5f..f6a3f218 100644 --- a/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts +++ b/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts @@ -643,3 +643,226 @@ describe('G2 repro · result-shape probes: forwarding and wrapper behaviour', () expect(props).not.toContain('occurredAfter'); }); }); + +describe('G2 repro · soát ngữ nghĩa: compile_intent bị xếp sai nhóm', () => { + /** + * Phát hiện khi soát ngữ nghĩa 49 mutation chưa map (mảnh audit cuối). + * + * `compile_intent` dùng POST nên bộ đếm theo HTTP method xếp nó vào mutation. + * Nhưng `IntentService.compile` ghi rõ trong docstring: *"Returns the compiled + * draft for the user to confirm — **never persists**"*, và nó gọi + * `this.deps.llm.provider.chat(...)`. Vậy nó là **provider-cost preview**, + * cùng lớp với `translate_text`, không phải mutation. + * + * Hệ quả cho các con số: mutation **90 → 89**, mutation chưa map **49 → 48**, + * provider action **1 → 2**. + */ + it('S12: khoá REST target của compile_intent — tách khỏi đường tạo intent', async () => { + const calls = await callToolIsolated('compile_intent', { + description: 'bài viết phải có ảnh bìa', + collection: 'posts', + }); + + // Nó POST tới endpoint compile — không tạo/sửa intent nào. + expect(calls).toHaveLength(1); + expect(calls[0]!.method).toBe('POST'); + expect(calls[0]!.path).toBe('/agent/intents/compile'); + + // Phân biệt với đường thật sự tạo intent (registerCrud trên /agent/intents). + const createCalls = await callToolIsolated('create_intent', { + name: 'i1', + collection: 'posts', + rules: [], + schedule: '* * * * *', + }); + expect(createCalls[0]!.path).toBe('/agent/intents'); + expect(createCalls[0]!.path).not.toBe(calls[0]!.path); + + // ── PHẠM VI (siết theo yêu cầu R4 của review vòng 8) ──────────────────── + // Test này CHỈ khoá REST target và cho thấy hai đường khác nhau. + // + // Kết luận "không persist" đến từ đọc phía CMS (`routes/intents.ts:182` + + // toàn bộ `IntentService.compile` tại `intent-service.ts:205`: provider.chat + // → parse/validate rules + schedule → trả draft; không có DB mutation, không + // create/update/activate intent), **không** từ test này. + // + // Và KHÔNG phát biểu "không có bất kỳ side effect nào": vẫn có request ra + // provider kèm chi phí, cộng middleware toàn cục không được test end-to-end ở + // đây. Ngoài ra route giữ nguyên guard `canWriteIntents` + // (`admin` | `intents:write` | `*`) — phân loại "preview" **không** hạ nó + // xuống quyền read. + }); + + it('S13: phân loại phải PHỦ ĐÚNG registry thật — membership, uniqueness, disjointness, union', async () => { + /** + * Viết lại theo yêu cầu **R3** của review vòng 8. Bản trước chỉ **cộng hằng + * số** nên vẫn xanh dù registry thêm/bớt/đổi tên tool — đúng là không khoá gì. + * + * Bản này gắn từng tập tên vào `listTools()` **thật**: + * - membership: mọi tên trong tập phải TỒN TẠI trong registry; + * - uniqueness: không trùng trong cùng tập; + * - disjointness: **năm** tập không giao nhau; + * - union **hai chiều**: registry ⊆ ∪tập và ∪tập ⊆ registry. + * + * SỬA THEO F2: bản trước chỉ khai báo 4 tập (98 tên) rồi lấy 63 tool còn lại + * TRỰC TIẾP từ registry và chỉ kiểm số lượng + prefix. Hệ quả: đổi tên một + * tool **trong nhóm 63** vẫn XANH — kiểm âm `get_release` → `get_release_v2` + * đi lọt. Tức nó khoá danh tính 98/161, không phải toàn registry. + * + * Giờ `READ_GET_63` là tập khai báo tường minh, nên cả **161/161** tên đều + * được khoá: đổi tên tool ở BẤT KỲ nhóm nào ⇒ membership/union đỏ; thêm/bớt + * tool ⇒ union đỏ; xếp một tên vào hai nhóm ⇒ disjointness đỏ. + */ + const { client } = await liveClient(); + const registry = (await client.listTools()).tools.map((t) => t.name); + const registrySet = new Set(registry); + + /** 41 mutation candidate map được (40 theo tên + 1 alias). */ + const MAPPED_41 = [ + 'create_item', 'update_item', 'delete_item', + 'create_collection', 'delete_collection', 'delete_field', + 'create_relation', 'delete_relation', + 'create_role', 'delete_role', 'create_policy', 'delete_policy', + 'create_flow', 'delete_flow', 'run_flow', + 'create_intent', 'delete_intent', + 'create_webhook', 'update_webhook', 'delete_webhook', + 'create_translation', 'update_translation', 'delete_translation', + 'upsert_setting', 'delete_setting', + 'create_cdc_subscription', 'delete_cdc_subscription', 'cdc_subscription_replay', + 'create_api_key', 'rotate_api_key', 'revoke_api_key', + 'invite_user', 'update_user', 'remove_user', + 'create_team', 'delete_team', 'add_team_member', 'remove_team_member', + 'install_extension', 'update_extension', 'uninstall_extension', + ]; + /** 48 mutation chưa map (xem §5d của PR). */ + const UNMAPPED_48 = [ + 'assign_role_user', 'remove_role_user', 'attach_role_policy', 'detach_role_policy', 'update_role', + 'add_policy_permission', 'update_policy_permission', 'delete_policy_permission', + 'attach_policy_user', 'detach_policy_user', 'update_policy', + 'attach_api_key_role', 'detach_api_key_role', 'attach_api_key_policy', 'detach_api_key_policy', + 'create_share', 'revoke_share', + 'apply_access_import', 'restore_backup', + 'approve_content', 'reject_content', 'submit_review', + 'apply_schema', 'update_collection', 'upsert_field', + 'create_release', 'update_release', 'delete_release', 'publish_release', + 'register_materialization', 'refresh_materialization', 'drop_materialization', + 'delete_media', + 'upsert_tm', 'update_tm', 'delete_tm', + 'update_cdc_subscription', 'update_flow', 'update_team', + 'pause_intent', 'resume_intent', 'scan_intent', 'update_intent', + 'create_preset', 'update_preset', 'delete_preset', + 'install_marketplace_extension', 'publish_extension', + ]; + const PROVIDER_2 = ['translate_text', 'compile_intent']; + /** 7 tool dùng POST nhưng ngữ nghĩa đọc/preview — REST target khoá ở `S7`. */ + const READ_VIA_POST_7 = [ + 'check_permission', 'check_access_conflicts', 'dry_run_access_import', + 'diff_schema', 'lookup_tm', 'query_insights', 'run_panel', + ]; + + /** + * 63 tool đọc-qua-GET. Khai báo TƯỜNG MINH theo yêu cầu F2: bản trước lấy + * nhóm này trực tiếp từ registry rồi chỉ kiểm số lượng + prefix, nên đổi tên + * một tool trong nhóm vẫn XANH (kiểm âm: `get_release` → `get_release_v2`). + * Có tập tên rồi thì union so hai chiều và rename ở đây cũng đỏ. + */ + const READ_GET_63 = [ + 'list_collections', 'get_collection', 'list_fields', 'list_items', 'get_item', + 'list_relations', 'list_presets', 'get_preset', 'get_effective_preset', + 'list_preset_bookmarks', 'list_translations', 'get_translation', 'list_settings', + 'get_setting', 'search', 'list_media', 'list_transform_presets', 'list_tm', + 'list_dashboards', 'get_dashboard', 'list_dashboard_panels', 'list_reviews', + 'list_releases', 'get_release', 'get_my_permissions', 'list_roles', 'get_role', + 'list_policies', 'get_policy', 'export_access', 'list_api_keys', 'get_api_key', + 'list_users', 'get_user', 'list_teams', 'get_team', 'list_team_members', + 'list_webhooks', 'list_cdc_subscriptions', 'get_cdc_subscription', 'cdc_events_read', + 'list_intents', 'get_intent', 'list_intent_drifts', 'list_flows', 'get_flow', + 'list_flow_runs', 'get_flow_run', 'list_activity', 'get_site', 'get_health', + 'get_metrics', 'export_backup', 'list_materializations', 'query_materialization', + 'list_extensions', 'list_marketplace_extensions', 'get_marketplace_extension', + 'list_marketplace_updates', 'list_deployment_targets', 'list_deployments', + 'get_deployment', 'get_deployment_logs', + ]; + + const sets: Array<[string, string[]]> = [ + ['MAPPED_41', MAPPED_41], + ['UNMAPPED_48', UNMAPPED_48], + ['PROVIDER_2', PROVIDER_2], + ['READ_VIA_POST_7', READ_VIA_POST_7], + ['READ_GET_63', READ_GET_63], + ]; + + // 1) Kích thước khai báo + expect(MAPPED_41).toHaveLength(41); + expect(UNMAPPED_48).toHaveLength(48); + expect(PROVIDER_2).toHaveLength(2); + expect(READ_VIA_POST_7).toHaveLength(7); + expect(READ_GET_63).toHaveLength(63); + + // 2) Uniqueness trong từng tập + membership trong registry THẬT + for (const [label, list] of sets) { + expect(new Set(list).size, `${label} không trùng nội bộ`).toBe(list.length); + const missing = list.filter((n) => !registrySet.has(n)); + expect(missing, `${label}: mọi tên phải tồn tại trong registry`).toEqual([]); + } + + // 3) Disjointness giữa bốn tập + const seen = new Map(); + const overlaps: string[] = []; + for (const [label, list] of sets) { + for (const n of list) { + const prev = seen.get(n); + if (prev) overlaps.push(`${n} ở cả ${prev} và ${label}`); + else seen.set(n, label); + } + } + expect(overlaps).toEqual([]); + + // 4) Union so HAI CHIỀU với registry thật (sửa theo F2). + // Trước đây nhóm 63 được lấy TỪ registry nên không khoá danh tính; giờ nó + // là tập khai báo, nên cả 161 tên đều có tập sở hữu. + const classified = new Set(seen.keys()); + expect(classified.size).toBe(41 + 48 + 2 + 7 + 63); + + // 4a) registry ⊆ các tập: không tool nào của registry bị bỏ rơi. + const unclassified = registry.filter((n) => !classified.has(n)); + expect(unclassified, 'mọi tool trong registry phải thuộc đúng một tập').toEqual([]); + + // 4b) các tập ⊆ registry: không tên khai báo nào biến mất khỏi registry. + // (membership ở bước 2 đã phủ, giữ lại để union là song ánh tường minh.) + const ghosts = [...classified].filter((n) => !registrySet.has(n)); + expect(ghosts, 'không tên khai báo nào được vắng mặt trong registry').toEqual([]); + + expect(classified.size).toBe(registry.length); + expect(registry).toHaveLength(161); + + // 5) Nhóm read-GET không được chứa động từ ghi — chốt nó thật là nhóm read. + const writeVerb = /^(create|update|delete|upsert|remove|revoke|rotate|attach|detach|assign|install|uninstall|publish|apply|restore|approve|reject|submit|register|drop|refresh|pause|resume|scan|replay|run)_/; + expect(READ_GET_63.filter((n) => writeVerb.test(n))).toEqual([]); + }); + + it('S13b: tổng kiểm số học của bảng phân loại', () => { + /** + * Chốt các con số sau soát ngữ nghĩa, để chúng không trôi ở lượt sau. + * Đây là **bảng phân loại**, không phải bằng chứng hành vi từng tool — + * bằng chứng nằm ở S7 (REST target), R16 (không có alias), R17 (hai ca + * trông-như-map-được thực chất không tương đương). + */ + const TOTAL = 161; + const READ_GET = 63; + const READ_VIA_POST_N = 7; + const PROVIDER_ACTION_N = 2; // translate_text + compile_intent + const MUTATIONS = 89; + const MUTATION_MAPPED = 41; // 40 theo tên + 1 alias + const MUTATION_UNMAPPED = 48; + + // Tổng phải khớp: read(GET) + read(POST) + provider + mutation = 161 + expect(READ_GET + READ_VIA_POST_N + PROVIDER_ACTION_N + MUTATIONS).toBe(TOTAL); + // Mutation phải chia hết thành mapped + unmapped + expect(MUTATION_MAPPED + MUTATION_UNMAPPED).toBe(MUTATIONS); + // Và 48 unmapped chia thành hai nhóm rủi ro (xem §5d của PR) + const PRIVILEGE_AFFECTING = 22; + const CONTENT_SCHEMA_OPS = 26; + expect(PRIVILEGE_AFFECTING + CONTENT_SCHEMA_OPS).toBe(MUTATION_UNMAPPED); + }); +});