From 2da8b01b1b5491b0db491492ec039271d7855ead Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 21 Aug 2026 19:31:01 +0700 Subject: [PATCH] fix(core): Task 07 resolve duplicate comments semantics, fix commentsCount merging, and maintain legacy snapshot hash compatibility --- ...K-2026-08-21-07-duplicate-comments-rule.md | 49 ++++ ...K-2026-08-21-07-duplicate-comments-rule.md | 23 ++ src/core/filtering/filter-engine.ts | 11 +- src/core/randomizer/canonical.ts | 15 +- src/core/types/giveaway.ts | 3 +- src/core/validation/giveaway-schemas.ts | 3 +- tests/duplicate-comments-rule.test.ts | 265 ++++++++++++++++++ 7 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 agents/antigravity/done/TASK-2026-08-21-07-duplicate-comments-rule.md create mode 100644 agents/antigravity/inbox/TASK-2026-08-21-07-duplicate-comments-rule.md create mode 100644 tests/duplicate-comments-rule.test.ts diff --git a/agents/antigravity/done/TASK-2026-08-21-07-duplicate-comments-rule.md b/agents/antigravity/done/TASK-2026-08-21-07-duplicate-comments-rule.md new file mode 100644 index 0000000..0a4d943 --- /dev/null +++ b/agents/antigravity/done/TASK-2026-08-21-07-duplicate-comments-rule.md @@ -0,0 +1,49 @@ +# Task 07: excludeDuplicateComments — Семантика дедупликации и точность аудит-следа + +**Date:** 2026-08-21 +**Base Commit SHA:** `8741569817f4463387c5dc3ac36c0beaedbd0663` +**Status:** COMPLETED / PASS +**Assigned Agent:** Antigravity (Implementation Orchestrator) + +--- + +## 1. Executive Summary + +Устранено расхождение между поведением движка фильтрации и каноническим хешем условий розыгрыша: +1. **Семантика дедупликации (Вариант B):** + - В ядре Randomayzer закреплена безусловная дедупликация участников: **1 пользователь = 1 шанс**. + - Поддержка взвешенных шансов за множественные комментарии потребовала бы модификации детерминированного алгоритма жеребьёвки `executeDeterministicDrawV1` и структуры слепка, что прямо запрещено `AGENTS.md` §1 без отдельного прямого задания владельца. + - Поле `excludeDuplicateComments` удалено из `DEFAULT_FILTER_RULES`, `filterRulesSchema` и помечено как `@deprecated optional` для обратной совместимости. + +2. **Обратная совместимость `conditionsHash` и `verifyDrawResult`:** + - В `computeConditionsHash` реализована поддержка легаси-снапшотов: если объект правил `snapshot.filterRulesSnapshot` содержит поле `excludeDuplicateComments`, оно включается в каноническую сериализацию, гарантируя точное совпадение хеша (`conditionsIntegrity: true`, `verified: true`) для всех ранее проведённых розыгрышей. + - Для новых розыгрышей хеш вычисляется по чистому набору правил без фиктивного поля. + +3. **Исправление ошибки слияния `commentsCount`:** + - Устранена ошибка `(p.commentsCount || 1)`, из-за которой дубликат с `commentsCount: 0` (например, пришедший из списка лайков) ошибочно прибавлял 1 к счетчику комментариев. + - Подсчет обновлен на `typeof p.commentsCount === 'number' ? p.commentsCount : (p.commented ? 1 : 0)`. + +--- + +## 2. Modified Files + +| File | Type | Description | +|------|------|-------------| +| `src/core/filtering/filter-engine.ts` | Core Domain | Исправлен подсчет `commentsCount` при слиянии дубликатов участников. | +| `src/core/types/giveaway.ts` | Core Types | `excludeDuplicateComments` помечен как `@deprecated optional`, удален из `DEFAULT_FILTER_RULES`. | +| `src/core/randomizer/canonical.ts` | Core Randomizer | `computeConditionsHash` поддерживает легаси-снапшоты с сохранением байтовой идентичности хешей. | +| `src/core/validation/giveaway-schemas.ts` | Validation | `excludeDuplicateComments` сделан опциональным, удален из `defaultRulesObject`. | +| `tests/duplicate-comments-rule.test.ts` | Tests (NEW) | Набор тестов (5 тестов): точный подсчет счетчика комментариев, безусловная дедупликация, проверка легаси и новых снапшотов. | + +--- + +## 3. Verification Evidence & Test Gate + +```text +npx prisma generate -> EXIT 0 (Prisma Client v5.22.0) +npx tsc --noEmit -> EXIT 0 (0 ошибок типизации) +npm test -> EXIT 0 (55 тестовых файлов, 321 тест прошёл успешно) +npm run lint -> EXIT 0 (0 ошибок, 6 warnings на no-img-element) +npm run build -> EXIT 0 (Все 17 маршрутов скомпилированы успешно) +npm audit --omit=dev -> EXIT 0 (0 vulnerabilities) +``` diff --git a/agents/antigravity/inbox/TASK-2026-08-21-07-duplicate-comments-rule.md b/agents/antigravity/inbox/TASK-2026-08-21-07-duplicate-comments-rule.md new file mode 100644 index 0000000..8b18df4 --- /dev/null +++ b/agents/antigravity/inbox/TASK-2026-08-21-07-duplicate-comments-rule.md @@ -0,0 +1,23 @@ +# Task 07: excludeDuplicateComments — правило не применяется + +**Assigned to:** Antigravity (Implementation Orchestrator) +**Priority:** MEDIUM (audit-trail accuracy) +**Date:** 2026-08-21 +**Base SHA:** `8741569817f4463387c5dc3ac36c0beaedbd0663` + +## Scope +1. Semantic decision on duplicate comments: + - Randomayzer core algorithm `HMAC_SHA256_FY_V1` and `executeDeterministicDrawV1` operate on a 1-participant = 1-chance model (`AGENTS.md` §1 forbids altering deterministic randomizer/draw algorithms without explicit owner task). + - Multi-weight/multi-entry draws would require modifying the deterministic randomizer and snapshot data structures, which is out of scope. + - Therefore, choose **Option B**: Unconditional deduplication. +2. Backward compatibility & Conditions Hash versioning: + - Ensure `computeConditionsHash` handles backward compatibility: snapshots created in legacy format (containing `excludeDuplicateComments`) must continue to verify `verified: true` with identical hash values (`conditionsIntegrity: true`). +3. Fix duplicate merging in `filter-engine.ts`: + - Fix `commentsCount` summing so that duplicates with `commentsCount: 0` (or `undefined`) do not artificially add `+ 1`. +4. Update UI and validation schemas if `excludeDuplicateComments` is removed or deprecated: + - Clean up or deprecate gracefully in `DEFAULT_FILTER_RULES`, `filterRulesSchema`, `src/app/giveaways/new/page.tsx`. +5. Create regression tests `tests/duplicate-comments-rule.test.ts`: + - Duplicate with `commentsCount: 0` does not add 1. + - Legacy snapshots with `excludeDuplicateComments` in `filterRulesSnapshot` retain `verified: true` and match original `conditionsHash`. + - Filter engine deduplication behaves deterministically. +6. Verify and output report to `agents/antigravity/done/TASK-2026-08-21-07-duplicate-comments-rule.md`. diff --git a/src/core/filtering/filter-engine.ts b/src/core/filtering/filter-engine.ts index 6182711..b2c7e92 100644 --- a/src/core/filtering/filter-engine.ts +++ b/src/core/filtering/filter-engine.ts @@ -21,18 +21,23 @@ export function applyFilterRules( participants: RawParticipant[], rules: FilterRules ): FilterResult { - // 1. Deduplicate participants by platformUserId (if enabled, aggregate comment counts) + // 1. Deduplicate participants by platformUserId (unconditional deduplication: 1 participant = 1 chance) const participantMap = new Map(); for (const p of participants) { const existing = participantMap.get(p.platformUserId); + const pComments = typeof p.commentsCount === 'number' ? p.commentsCount : (p.commented ? 1 : 0); + if (!existing) { - participantMap.set(p.platformUserId, { ...p }); + participantMap.set(p.platformUserId, { + ...p, + commentsCount: pComments, + }); } else { // Merge actions existing.liked = existing.liked || p.liked; existing.commented = existing.commented || p.commented; - existing.commentsCount = (existing.commentsCount || 0) + (p.commentsCount || 1); + existing.commentsCount = (existing.commentsCount || 0) + pComments; existing.reposted = existing.reposted || p.reposted; existing.subscribed = existing.subscribed || p.subscribed; existing.isAdmin = existing.isAdmin || p.isAdmin; diff --git a/src/core/randomizer/canonical.ts b/src/core/randomizer/canonical.ts index f9b4834..afdd870 100644 --- a/src/core/randomizer/canonical.ts +++ b/src/core/randomizer/canonical.ts @@ -27,13 +27,13 @@ export function sha256(content: string): string { } /** - * Computes deterministic conditionsHash for given filter rules + * Computes deterministic conditionsHash for given filter rules. + * Supports backward compatibility for legacy snapshots that stored excludeDuplicateComments. */ -export function computeConditionsHash(rules: FilterRules): string { - const canonicalRules = { +export function computeConditionsHash(rules: FilterRules | Record): string { + const canonicalRules: Record = { excludeAdmins: Boolean(rules.excludeAdmins), - excludeBlacklistedIds: [...(rules.excludeBlacklistedIds || [])].map(s => s.trim().toLowerCase()).sort(), - excludeDuplicateComments: Boolean(rules.excludeDuplicateComments), + excludeBlacklistedIds: [...(rules.excludeBlacklistedIds || [])].map(s => String(s).trim().toLowerCase()).sort(), minEligibleParticipants: rules.minEligibleParticipants ?? 1, requireComment: Boolean(rules.requireComment), requireLike: Boolean(rules.requireLike), @@ -42,6 +42,11 @@ export function computeConditionsHash(rules: FilterRules): string { targetGroupId: rules.targetGroupId || null, }; + // Backward compatibility: preserve legacy canonical key for snapshots stored with excludeDuplicateComments + if ('excludeDuplicateComments' in rules && rules.excludeDuplicateComments !== undefined) { + canonicalRules.excludeDuplicateComments = Boolean(rules.excludeDuplicateComments); + } + return sha256(canonicalStringify(canonicalRules)); } diff --git a/src/core/types/giveaway.ts b/src/core/types/giveaway.ts index 62e8f41..ebf232e 100644 --- a/src/core/types/giveaway.ts +++ b/src/core/types/giveaway.ts @@ -19,7 +19,7 @@ export interface FilterRules { targetGroupId?: string; excludeAdmins: boolean; excludeBlacklistedIds: string[]; // List of user IDs or usernames to exclude - excludeDuplicateComments: boolean; // Count user only once even if multiple comments + excludeDuplicateComments?: boolean; // @deprecated legacy field for backward compatibility minEligibleParticipants?: number; } @@ -30,7 +30,6 @@ export const DEFAULT_FILTER_RULES: FilterRules = { requireSubscription: false, excludeAdmins: false, excludeBlacklistedIds: [], - excludeDuplicateComments: true, minEligibleParticipants: 1, }; diff --git a/src/core/validation/giveaway-schemas.ts b/src/core/validation/giveaway-schemas.ts index a8bc884..271e480 100644 --- a/src/core/validation/giveaway-schemas.ts +++ b/src/core/validation/giveaway-schemas.ts @@ -9,7 +9,7 @@ export const filterRulesSchema = z.object({ requireRepost: z.boolean().default(false), requireSubscription: z.boolean().default(false), excludeAdmins: z.boolean().default(false), - excludeDuplicateComments: z.boolean().default(true), + excludeDuplicateComments: z.boolean().optional(), excludeBlacklistedIds: z.array(z.string().max(128)).max(1000).default([]), targetGroupId: z.string().max(128).optional(), minEligibleParticipants: z.number().int().min(1).max(100000).default(1), @@ -21,7 +21,6 @@ const defaultRulesObject = { requireRepost: false, requireSubscription: false, excludeAdmins: false, - excludeDuplicateComments: true, excludeBlacklistedIds: [] as string[], minEligibleParticipants: 1, }; diff --git a/tests/duplicate-comments-rule.test.ts b/tests/duplicate-comments-rule.test.ts new file mode 100644 index 0000000..4542033 --- /dev/null +++ b/tests/duplicate-comments-rule.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect } from 'vitest'; +import { applyFilterRules } from '../src/core/filtering/filter-engine'; +import { RawParticipant } from '../src/core/types/participant'; +import { FilterRules, DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { computeConditionsHash } from '../src/core/randomizer/canonical'; +import { verifyDrawResult, executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic'; +import { computeParticipantsSnapshotHash } from '../src/core/randomizer/canonical'; + +describe('Task 07: Duplicate Comments Rule & Backward Compatibility', () => { + // ─── 1. commentsCount merging accuracy (Fix || 1 bug) ───────────────────────── + it('duplicate participant entry with commentsCount: 0 does not artificially increment commentsCount', () => { + const rawEntries: RawParticipant[] = [ + { + platformUserId: 'user_like_only', + firstName: 'Like', + lastName: 'Only', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + }, + { + platformUserId: 'user_like_only', + firstName: 'Like', + lastName: 'Only', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + }, + ]; + + const result = applyFilterRules(rawEntries, DEFAULT_FILTER_RULES); + expect(result.allParticipants).toHaveLength(1); + expect(result.allParticipants[0].commentsCount).toBe(0); + }); + + it('merging like entry (0 comments) and comment entry (1 comment) accurately sums commentsCount to 1', () => { + const rawEntries: RawParticipant[] = [ + { + platformUserId: 'user_mixed', + firstName: 'Mixed', + lastName: 'User', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + }, + { + platformUserId: 'user_mixed', + firstName: 'Mixed', + lastName: 'User', + source: 'COMMENTS', + liked: false, + commented: true, + commentsCount: 1, + reposted: false, + subscribed: true, + }, + ]; + + const result = applyFilterRules(rawEntries, DEFAULT_FILTER_RULES); + expect(result.allParticipants).toHaveLength(1); + expect(result.allParticipants[0].liked).toBe(true); + expect(result.allParticipants[0].commented).toBe(true); + expect(result.allParticipants[0].commentsCount).toBe(1); + }); + + // ─── 2. Unconditional deduplication (1 user = 1 chance) ────────────────────── + it('unconditionally deduplicates participants so 1 user gets 1 chance regardless of comment volume', () => { + const rawEntries: RawParticipant[] = Array.from({ length: 10 }, (_, i) => ({ + platformUserId: 'spammer_100', + firstName: 'Spammer', + lastName: 'User', + source: 'COMMENTS', + liked: true, + commented: true, + commentsCount: 1, + reposted: false, + subscribed: true, + })); + + const result = applyFilterRules(rawEntries, { + ...DEFAULT_FILTER_RULES, + requireComment: true, + }); + + expect(result.allParticipants).toHaveLength(1); + expect(result.eligibleParticipants).toHaveLength(1); + expect(result.allParticipants[0].commentsCount).toBe(10); + expect(result.allParticipants[0].eligible).toBe(true); + }); + + // ─── 3. Legacy snapshot verification compatibility ──────────────────────────── + it('legacy snapshot with excludeDuplicateComments: true retains verified: true in verifyDrawResult', () => { + // 1. Simulate legacy filterRulesSnapshot with excludeDuplicateComments field + const legacyFilterRules = { + requireLike: true, + requireComment: false, + requireRepost: false, + requireSubscription: false, + excludeAdmins: false, + excludeBlacklistedIds: [], + excludeDuplicateComments: true, + minEligibleParticipants: 1, + }; + + const conditionsHash = computeConditionsHash(legacyFilterRules); + + const eligibleParticipants = [ + { + platformUserId: '101', + firstName: 'Alice', + lastName: 'A', + source: 'LIKES' as const, + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '102', + firstName: 'Bob', + lastName: 'B', + source: 'LIKES' as const, + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants); + + const legacySnapshot = { + id: 'snap_legacy_001', + giveawayId: 'gw_legacy_001', + version: 1, + createdAt: '2026-08-15T12:00:00.000Z', + eligibleParticipants, + filterRulesSnapshot: legacyFilterRules, + participantCount: 2, + participantsSnapshotHash, + conditionsHash, + }; + + const seed = 'legacy_test_seed_1234567890abcdef1234567890abcdef'; + + // Execute draw with legacy snapshot + const drawResult = executeDeterministicDrawV1({ + giveawayId: 'gw_legacy_001', + snapshot: legacySnapshot, + totalLoadedCount: 2, + winnersCount: 1, + reserveWinnersCount: 0, + seed, + }); + + // Independent verifyDrawResult replay + const verification = verifyDrawResult({ + giveawayId: 'gw_legacy_001', + drawId: drawResult.drawId, + drawnAt: drawResult.drawnAt, + snapshot: legacySnapshot, + seed, + claimedWinnersCount: 1, + claimedReserveCount: 0, + claimedWinnerIds: drawResult.winnerIds, + claimedReserveWinnerIds: drawResult.reserveWinnerIds, + claimedDeterministicProofHash: drawResult.deterministicProofHash, + claimedAuditEventHash: drawResult.auditEventHash, + algorithmVersion: drawResult.algorithmVersion, + }); + + expect(verification.conditionsIntegrity).toBe(true); + expect(verification.participantsSnapshotIntegrity).toBe(true); + expect(verification.verified).toBe(true); + }); + + // ─── 4. New snapshot verification compatibility ─────────────────────────────── + it('new snapshot without excludeDuplicateComments computes clean hash and verifies successfully', () => { + const newFilterRules: FilterRules = { + requireLike: true, + requireComment: true, + requireRepost: false, + requireSubscription: false, + excludeAdmins: false, + excludeBlacklistedIds: ['999'], + minEligibleParticipants: 1, + }; + + const conditionsHash = computeConditionsHash(newFilterRules); + + const eligibleParticipants = [ + { + platformUserId: '201', + firstName: 'Charlie', + lastName: 'C', + source: 'COMMENTS' as const, + liked: true, + commented: true, + commentsCount: 2, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants); + + const newSnapshot = { + id: 'snap_new_001', + giveawayId: 'gw_new_001', + version: 1, + createdAt: '2026-08-21T12:00:00.000Z', + eligibleParticipants, + filterRulesSnapshot: newFilterRules, + participantCount: 1, + participantsSnapshotHash, + conditionsHash, + }; + + const seed = 'new_test_seed_1234567890abcdef1234567890abcdef'; + + const drawResult = executeDeterministicDrawV1({ + giveawayId: 'gw_new_001', + snapshot: newSnapshot, + totalLoadedCount: 1, + winnersCount: 1, + reserveWinnersCount: 0, + seed, + }); + + const verification = verifyDrawResult({ + giveawayId: 'gw_new_001', + drawId: drawResult.drawId, + drawnAt: drawResult.drawnAt, + snapshot: newSnapshot, + seed, + claimedWinnersCount: 1, + claimedReserveCount: 0, + claimedWinnerIds: drawResult.winnerIds, + claimedReserveWinnerIds: drawResult.reserveWinnerIds, + claimedDeterministicProofHash: drawResult.deterministicProofHash, + claimedAuditEventHash: drawResult.auditEventHash, + algorithmVersion: drawResult.algorithmVersion, + }); + + expect(verification.conditionsIntegrity).toBe(true); + expect(verification.verified).toBe(true); + }); +});