From b81fc82621bbade883f01e41c1de8e8fbc1376cc Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:23:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(tag-vocab):=20telemetryEvents=20=E2=80=94?= =?UTF-8?q?=20tag=5Fvocab=5Fhit/miss=20zod=20schemas=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TagVocabHitPayload { tagId: int>0, vocabSize: int>=0 } .strict() - TagVocabMissPayload { vocabSize: int>=0 } .strict() - TelemetryEventSchema union 13 → 15 - 단위 +3 cases (hit accept, miss accept, hit extra field 거부) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/telemetryEvents.ts | 13 ++++++++++++- tests/unit/telemetryEvents.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 94e3c5f..66471e7 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -50,6 +50,15 @@ const AiRetryManualPayload = z.object({ failedCount: z.number().int().positive() }).strict(); +const TagVocabHitPayload = z.object({ + tagId: z.number().int().positive(), + vocabSize: z.number().int().nonnegative() +}).strict(); + +const TagVocabMissPayload = z.object({ + vocabSize: z.number().int().nonnegative() +}).strict(); + export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(), @@ -63,7 +72,9 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('ollama_unreachable'), payload: OllamaUnreachablePayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ollama_recovered'), payload: OllamaRecoveredPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict(), - z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict() + z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict() ]); export type TelemetryEvent = z.infer; diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index f41bdef..5266346 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -278,3 +278,31 @@ describe('ai_retry_manual event', () => { })).toThrow(); }); }); + +describe('validateEvent — tag vocab', () => { + it('accepts tag_vocab_hit event', () => { + const e = validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_hit', + payload: { tagId: 42, vocabSize: 17 } + }); + expect(e.kind).toBe('tag_vocab_hit'); + }); + + it('accepts tag_vocab_miss event without tagId', () => { + const e = validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_miss', + payload: { vocabSize: 17 } + }); + expect(e.kind).toBe('tag_vocab_miss'); + }); + + it('rejects tag_vocab_hit with extra field (privacy invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_hit', + payload: { tagId: 42, vocabSize: 17, tagName: 'leak' } + })).toThrow(); + }); +});