@tienne/gestalt 0.99.0 → 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CLAUDE.md +1 -1
  2. package/dist/package.json +2 -2
  3. package/dist/plugin/research-sources/amplitude/SOURCE.md +28 -0
  4. package/dist/plugin/research-sources/app-store-reviews/SOURCE.md +25 -0
  5. package/dist/plugin/research-sources/app-store-search/SOURCE.md +25 -0
  6. package/dist/plugin/research-sources/tech-blog-search/SOURCE.md +27 -0
  7. package/dist/plugin/research-sources/uibowl/SOURCE.md +26 -0
  8. package/dist/plugin/research-sources/web-search/SOURCE.md +24 -0
  9. package/dist/plugin/skills/_shared/proactive-routing.md +5 -0
  10. package/dist/plugin/skills/market-scan/SKILL.md +61 -0
  11. package/dist/plugin/skills/research/SKILL.md +438 -0
  12. package/dist/plugin/skills/research/references/design.md +938 -0
  13. package/dist/plugin/skills/research/references/output-template.md +366 -0
  14. package/dist/plugin/skills/research/scripts/pii-vision.swift +327 -0
  15. package/dist/plugin/skills/research/scripts/slide-extract.mjs +223 -0
  16. package/dist/plugin/skills/reverse-plan/SKILL.md +52 -0
  17. package/dist/plugin/skills/screen-ref/SKILL.md +63 -0
  18. package/dist/plugin/skills/ux-research/SKILL.md +51 -0
  19. package/dist/src/core/config.d.ts +3 -0
  20. package/dist/src/core/config.d.ts.map +1 -1
  21. package/dist/src/core/config.js +4 -0
  22. package/dist/src/core/config.js.map +1 -1
  23. package/dist/src/mcp/schemas.d.ts +93 -0
  24. package/dist/src/mcp/schemas.d.ts.map +1 -1
  25. package/dist/src/mcp/schemas.js +110 -0
  26. package/dist/src/mcp/schemas.js.map +1 -1
  27. package/dist/src/mcp/server.d.ts.map +1 -1
  28. package/dist/src/mcp/server.js +11 -1
  29. package/dist/src/mcp/server.js.map +1 -1
  30. package/dist/src/mcp/tools/research-passthrough.d.ts +21 -0
  31. package/dist/src/mcp/tools/research-passthrough.d.ts.map +1 -0
  32. package/dist/src/mcp/tools/research-passthrough.js +566 -0
  33. package/dist/src/mcp/tools/research-passthrough.js.map +1 -0
  34. package/dist/src/research/canvas-view.d.ts +5 -0
  35. package/dist/src/research/canvas-view.d.ts.map +1 -0
  36. package/dist/src/research/canvas-view.js +274 -0
  37. package/dist/src/research/canvas-view.js.map +1 -0
  38. package/dist/src/research/evidence.d.ts +145 -0
  39. package/dist/src/research/evidence.d.ts.map +1 -0
  40. package/dist/src/research/evidence.js +50 -0
  41. package/dist/src/research/evidence.js.map +1 -0
  42. package/dist/src/research/figma-plan.d.ts +80 -0
  43. package/dist/src/research/figma-plan.d.ts.map +1 -0
  44. package/dist/src/research/figma-plan.js +527 -0
  45. package/dist/src/research/figma-plan.js.map +1 -0
  46. package/dist/src/research/figma-script.d.ts +19 -0
  47. package/dist/src/research/figma-script.d.ts.map +1 -0
  48. package/dist/src/research/figma-script.js +363 -0
  49. package/dist/src/research/figma-script.js.map +1 -0
  50. package/dist/src/research/index.d.ts +8 -0
  51. package/dist/src/research/index.d.ts.map +1 -0
  52. package/dist/src/research/index.js +8 -0
  53. package/dist/src/research/index.js.map +1 -0
  54. package/dist/src/research/pii-core.d.ts +16 -0
  55. package/dist/src/research/pii-core.d.ts.map +1 -0
  56. package/dist/src/research/pii-core.js +345 -0
  57. package/dist/src/research/pii-core.js.map +1 -0
  58. package/dist/src/research/pii-dom.d.ts +13 -0
  59. package/dist/src/research/pii-dom.d.ts.map +1 -0
  60. package/dist/src/research/pii-dom.js +208 -0
  61. package/dist/src/research/pii-dom.js.map +1 -0
  62. package/dist/src/research/pii-image.d.ts +71 -0
  63. package/dist/src/research/pii-image.d.ts.map +1 -0
  64. package/dist/src/research/pii-image.js +163 -0
  65. package/dist/src/research/pii-image.js.map +1 -0
  66. package/dist/src/research/pii.d.ts +21 -0
  67. package/dist/src/research/pii.d.ts.map +1 -0
  68. package/dist/src/research/pii.js +47 -0
  69. package/dist/src/research/pii.js.map +1 -0
  70. package/dist/src/research/product-profile.d.ts +108 -0
  71. package/dist/src/research/product-profile.d.ts.map +1 -0
  72. package/dist/src/research/product-profile.js +104 -0
  73. package/dist/src/research/product-profile.js.map +1 -0
  74. package/dist/src/research/render-html.d.ts +203 -0
  75. package/dist/src/research/render-html.d.ts.map +1 -0
  76. package/dist/src/research/render-html.js +901 -0
  77. package/dist/src/research/render-html.js.map +1 -0
  78. package/dist/src/research/schema-base.d.ts +5 -0
  79. package/dist/src/research/schema-base.d.ts.map +1 -0
  80. package/dist/src/research/schema-base.js +10 -0
  81. package/dist/src/research/schema-base.js.map +1 -0
  82. package/dist/src/research/schema.d.ts +5142 -0
  83. package/dist/src/research/schema.d.ts.map +1 -0
  84. package/dist/src/research/schema.js +991 -0
  85. package/dist/src/research/schema.js.map +1 -0
  86. package/dist/src/research/source-create.d.ts +79 -0
  87. package/dist/src/research/source-create.d.ts.map +1 -0
  88. package/dist/src/research/source-create.js +226 -0
  89. package/dist/src/research/source-create.js.map +1 -0
  90. package/dist/src/research/source-registry.d.ts +29 -0
  91. package/dist/src/research/source-registry.d.ts.map +1 -0
  92. package/dist/src/research/source-registry.js +74 -0
  93. package/dist/src/research/source-registry.js.map +1 -0
  94. package/dist/src/research/source-schema.d.ts +158 -0
  95. package/dist/src/research/source-schema.d.ts.map +1 -0
  96. package/dist/src/research/source-schema.js +215 -0
  97. package/dist/src/research/source-schema.js.map +1 -0
  98. package/dist/src/research/source-status.d.ts +40 -0
  99. package/dist/src/research/source-status.d.ts.map +1 -0
  100. package/dist/src/research/source-status.js +109 -0
  101. package/dist/src/research/source-status.js.map +1 -0
  102. package/dist/src/research/store.d.ts +24 -0
  103. package/dist/src/research/store.d.ts.map +1 -0
  104. package/dist/src/research/store.js +60 -0
  105. package/dist/src/research/store.js.map +1 -0
  106. package/dist/src/research/target-view.d.ts +33 -0
  107. package/dist/src/research/target-view.d.ts.map +1 -0
  108. package/dist/src/research/target-view.js +133 -0
  109. package/dist/src/research/target-view.js.map +1 -0
  110. package/dist/src/research/target.d.ts +883 -0
  111. package/dist/src/research/target.d.ts.map +1 -0
  112. package/dist/src/research/target.js +323 -0
  113. package/dist/src/research/target.js.map +1 -0
  114. package/dist/src/research/video-transfer.d.ts +28 -0
  115. package/dist/src/research/video-transfer.d.ts.map +1 -0
  116. package/dist/src/research/video-transfer.js +100 -0
  117. package/dist/src/research/video-transfer.js.map +1 -0
  118. package/package.json +2 -2
  119. package/plugin/.codex-plugin/plugin.json +1 -1
  120. package/plugin/.mcp.json +1 -1
  121. package/plugin/mcp.json +1 -1
  122. package/plugin/research-sources/amplitude/SOURCE.md +28 -0
  123. package/plugin/research-sources/app-store-reviews/SOURCE.md +25 -0
  124. package/plugin/research-sources/app-store-search/SOURCE.md +25 -0
  125. package/plugin/research-sources/tech-blog-search/SOURCE.md +27 -0
  126. package/plugin/research-sources/uibowl/SOURCE.md +26 -0
  127. package/plugin/research-sources/web-search/SOURCE.md +24 -0
  128. package/plugin/skills/_shared/proactive-routing.md +5 -0
  129. package/plugin/skills/market-scan/SKILL.md +61 -0
  130. package/plugin/skills/research/SKILL.md +438 -0
  131. package/plugin/skills/research/references/design.md +938 -0
  132. package/plugin/skills/research/references/output-template.md +366 -0
  133. package/plugin/skills/research/scripts/pii-vision.swift +327 -0
  134. package/plugin/skills/research/scripts/slide-extract.mjs +223 -0
  135. package/plugin/skills/reverse-plan/SKILL.md +52 -0
  136. package/plugin/skills/screen-ref/SKILL.md +63 -0
  137. package/plugin/skills/ux-research/SKILL.md +51 -0
@@ -0,0 +1,991 @@
1
+ import { z } from 'zod';
2
+ import { ValidationError } from '../core/errors.js';
3
+ import { err, ok } from '../core/result.js';
4
+ import { basisFields, checkBasis, evidenceListSchema } from './evidence.js';
5
+ import { httpsUrlSchema, idSchema, textSchema } from './schema-base.js';
6
+ import { hasUsableBaseline, liftBasisSchema, lintTarget, targetSchema } from './target.js';
7
+ /** 새 필드는 전부 선택이라 1.0.0 문서가 그대로 읽힌다 */
8
+ export const RESEARCH_SCHEMA_VERSION = '1.0.0';
9
+ /** design.md 2.1절의 경우 5개. video-analysis는 영상 블록만 채우는 단독 문서다 */
10
+ export const RESEARCH_KINDS = [
11
+ 'ux-research',
12
+ 'screens-only',
13
+ 'reverse-plan',
14
+ 'market-scan',
15
+ 'video-analysis',
16
+ ];
17
+ /** own-service: 조사하는 쪽 우리 서비스. 비교표에는 열로 들어가지만 레퍼런스처럼 가져올 대상은 아니다 */
18
+ export const PRODUCT_AXES = ['same-market', 'same-problem', 'own-service'];
19
+ /** 후보 제품이 우리 기능이 붙는 도메인(premise.attachTo.domain)과 얼마나 같은지 */
20
+ export const DOMAIN_FITS = ['same', 'adjacent', 'different'];
21
+ export const SCREEN_SOURCES = [
22
+ 'app-explore',
23
+ 'uibowl',
24
+ 'site-search',
25
+ 'youtube',
26
+ 'store-screenshot',
27
+ 'tech-blog',
28
+ 'own-app',
29
+ 'own-web',
30
+ 'figma',
31
+ 'none',
32
+ ];
33
+ export const CAPTURE_STATES = [
34
+ 'captured',
35
+ 'reference-image',
36
+ 'stopped-before-payment',
37
+ 'not-captured',
38
+ ];
39
+ export const CAPTURE_KINDS = [
40
+ 'direct',
41
+ 'reference-db',
42
+ 'video-frame',
43
+ 'store',
44
+ 'own-app',
45
+ 'own-web',
46
+ 'figma',
47
+ ];
48
+ /** replaced: 탐지기가 돌아 가짜 값으로 바꿨다. none: 탐지기가 돌았고 0건이었다. unchecked: 돌리지 못했다 */
49
+ export const PII_STATES = ['replaced', 'none', 'unchecked'];
50
+ export const EVIDENCE_KINDS = ['observation', 'company-statement', 'interpretation'];
51
+ export const SCALE_KINDS = [
52
+ 'store-rating-count',
53
+ 'download-estimate',
54
+ 'active-users',
55
+ 'company-statement',
56
+ 'other',
57
+ ];
58
+ export const VIDEO_KINDS = ['screen-recording', 'seminar', 'mixed'];
59
+ /** public: 공개된 영상. recorded: 직접 화면을 녹화한 영상 */
60
+ export const VIDEO_ORIGINS = ['public', 'recorded'];
61
+ /** 제품별 흐름, 구좌별, 유형별, 종합. design.md 8.2절의 묶음 순서다 */
62
+ export const VIDEO_GROUPS = ['flow', 'slot', 'type', 'summary'];
63
+ export const VIDEO_NATURES = ['direction', 'convention'];
64
+ export const VIDEO_CADENCES = ['always', 'seasonal', 'exclusive'];
65
+ export const COMPARISON_SYMBOLS = ['◎', '○', '△'];
66
+ export const COMPARISON_ROW_KINDS = ['graded', 'burden', 'risk'];
67
+ /** 구좌 행이 5사에서 같은 방식인지(common), 갈리는지(split), 한 곳만 하는지(unique) */
68
+ export const COMPARISON_PATTERNS = ['common', 'split', 'unique'];
69
+ export const FINDING_KINDS = ['wow', 'pain', 'insight', 'design'];
70
+ export const FINDING_VIEWPOINTS = ['user', 'biz'];
71
+ /** immediate: 지금 정할 수 있다. data: 데이터를 봐야 한다. other-team: 다른 직군이 확인한다 */
72
+ export const QUESTION_OWNERS = ['immediate', 'data', 'other-team'];
73
+ export const ADOPTION_STATUSES = ['adopted', 'candidate', 'rejected'];
74
+ export const SCREEN_PLATFORMS = ['ios', 'aos', 'web'];
75
+ /** 화면 사이 전이를 일으키는 사용자 행동 */
76
+ export const GESTURES = ['tap', 'swipe', 'drag', 'scroll', 'press', 'pan', 'input'];
77
+ /** found: 찾음. partial: 일부만 찾음. not-found: 못 찾음. too-many: 너무 많아 고르지 못함 */
78
+ export const ATTEMPT_RESULTS = ['found', 'partial', 'not-found', 'too-many'];
79
+ export const CONFIDENCE_LEVELS = ['likely', 'trial', 'hold', 'out'];
80
+ export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'];
81
+ /** 리서치 폴더 밖으로 나가는 경로를 막는다. 공유용 HTML에 임의 파일이 실리는 길이기도 하다 */
82
+ export function isSafeRelativePath(path) {
83
+ if (path.includes('\0') || path.includes('\\'))
84
+ return false;
85
+ if (path.startsWith('/') || /^[A-Za-z]:/.test(path))
86
+ return false;
87
+ return !path.split('/').some((part) => part === '..' || part === '');
88
+ }
89
+ const relativePathSchema = z
90
+ .string()
91
+ .refine(isSafeRelativePath, '리서치 폴더 안의 상대 경로만 쓴다 (절대 경로, .. 불가)');
92
+ const foundViaSchema = z.object({
93
+ source: textSchema,
94
+ query: z.string().optional(),
95
+ reviewMentions: z.number().int().nonnegative().optional(),
96
+ url: httpsUrlSchema.optional(),
97
+ });
98
+ const scaleSchema = z.object({
99
+ value: textSchema,
100
+ kind: z.enum(SCALE_KINDS),
101
+ source: textSchema,
102
+ evidence: z.enum(EVIDENCE_KINDS),
103
+ });
104
+ export const productCandidateSchema = z.object({
105
+ id: idSchema,
106
+ name: textSchema,
107
+ axis: z.enum(PRODUCT_AXES),
108
+ reason: textSchema,
109
+ foundVia: z.array(foundViaSchema).default([]),
110
+ scale: scaleSchema.optional(),
111
+ platformIds: z.record(z.string(), z.string()).optional(),
112
+ domainFit: z.enum(DOMAIN_FITS).optional(),
113
+ });
114
+ export const screenMapItemSchema = z.object({
115
+ id: idSchema,
116
+ productId: idSchema,
117
+ screen: textSchema,
118
+ entryPath: z.array(textSchema).default([]),
119
+ source: z.enum(SCREEN_SOURCES),
120
+ capture: z.enum(CAPTURE_STATES),
121
+ platform: z.enum(SCREEN_PLATFORMS).optional(),
122
+ /** 흐름 표에서 화면이 나오는 순서. 없으면 screenMap 배열 순서 */
123
+ order: z.number().int().nonnegative().optional(),
124
+ });
125
+ export const captureSchema = z.object({
126
+ id: idSchema,
127
+ productId: idSchema,
128
+ screenMapId: idSchema.optional(),
129
+ path: relativePathSchema.refine((p) => IMAGE_EXTENSIONS.some((ext) => p.toLowerCase().endsWith(ext)), 'jpg, png, webp 이미지만 쓴다'),
130
+ kind: z.enum(CAPTURE_KINDS),
131
+ pii: z.enum(PII_STATES),
132
+ /** 예: 유아이볼 수집 시점을 확인 못함 */
133
+ sourceNote: z.string().optional(),
134
+ label: z.string().optional(),
135
+ /** 캡처 아래에 붙는 한 줄. "번호. 제목 — 요소 나열" 꼴 */
136
+ caption: z.string().optional(),
137
+ /** 영상이나 스토어 원본에서 몇 번째 프레임인지. 근거 표기 "(프레임 08)"에 쓴다 */
138
+ frameNo: z.string().optional(),
139
+ /** kind가 figma일 때 화면을 읽어 온 노드 링크. 근거를 되짚을 때 쓴다 */
140
+ figmaUrl: httpsUrlSchema
141
+ .refine((u) => /(^|\.)figma\.com$/.test(new URL(u).hostname), 'figma.com 링크만 쓴다')
142
+ .optional(),
143
+ });
144
+ /** 기능이 우리 서비스의 어느 흐름에 붙는지. 레퍼런스 도메인과 대리 퍼널을 이걸로 고른다 */
145
+ const attachToSchema = z.object({
146
+ /** 예: 다이닝 예약 결제 */
147
+ flow: textSchema,
148
+ /** 예: 예약 상품권 */
149
+ domain: textSchema,
150
+ note: z.string().optional(),
151
+ });
152
+ const premiseSchema = z.object({
153
+ goalMetric: textSchema,
154
+ attachTo: attachToSchema.optional(),
155
+ scaleAssumption: z.string().optional(),
156
+ structureConstraints: z.array(textSchema).default([]),
157
+ pendingInputs: z.array(textSchema).default([]),
158
+ evidenceLimits: z.array(textSchema).default([]),
159
+ symbolLegend: z.string().default('◎ 강함, ○ 보통, △ 약함'),
160
+ });
161
+ const planSchema = z.object({
162
+ /** 사용자가 조합을 확인한 시각. 확인 전에 캡처가 있으면 warnings에 오른다 */
163
+ confirmedAt: z.string().datetime({ offset: true }).optional(),
164
+ note: z.string().optional(),
165
+ });
166
+ const referenceSchema = z.object({
167
+ id: idSchema,
168
+ symbol: z.string().regex(/^[A-Z]$/, '기호는 대문자 한 글자'),
169
+ typeName: textSchema,
170
+ productIds: z.array(idSchema).min(1),
171
+ /** 어떤 해법을 대표해서 골랐는지 한 줄 */
172
+ reason: textSchema,
173
+ });
174
+ const mockupSchema = z.object({
175
+ referenceId: idSchema,
176
+ url: httpsUrlSchema.optional(),
177
+ note: z.string().optional(),
178
+ });
179
+ const comparisonCellSchema = z
180
+ .object({
181
+ referenceId: idSchema,
182
+ symbol: z.enum(COMPARISON_SYMBOLS).optional(),
183
+ text: textSchema,
184
+ ...basisFields,
185
+ /** 근거 상태는 선택이다. 없으면 lint가 한 번 알린다 */
186
+ basis: basisFields.basis.optional(),
187
+ /** 이 칸의 대표 화면. 썸네일로 그려진다 */
188
+ captureIds: z.array(idSchema).default([]),
189
+ })
190
+ .superRefine(checkBasis);
191
+ const comparisonRowSchema = z
192
+ .object({
193
+ phase: textSchema,
194
+ criterion: textSchema,
195
+ /** 구좌 한 줄 정의. 격자의 회색 글자 */
196
+ definition: z.string().optional(),
197
+ pattern: z.enum(COMPARISON_PATTERNS).optional(),
198
+ kind: z.enum(COMPARISON_ROW_KINDS).default('graded'),
199
+ cells: z.array(comparisonCellSchema).min(1),
200
+ })
201
+ .superRefine((row, ctx) => {
202
+ row.cells.forEach((cell, i) => {
203
+ const ungraded = cell.basis === 'unconfirmed' || cell.basis === 'absent';
204
+ if (row.kind === 'graded' && !cell.symbol && !ungraded) {
205
+ ctx.addIssue({
206
+ code: z.ZodIssueCode.custom,
207
+ path: ['cells', i, 'symbol'],
208
+ message: '기준 행의 칸에는 기호(◎○△)가 필요하다',
209
+ });
210
+ }
211
+ if (row.kind !== 'graded' && cell.symbol) {
212
+ ctx.addIssue({
213
+ code: z.ZodIssueCode.custom,
214
+ path: ['cells', i, 'symbol'],
215
+ message: '구현 부담과 위험 행에는 기호를 쓰지 않는다',
216
+ });
217
+ }
218
+ });
219
+ });
220
+ const bulletsSchema = z.array(textSchema).default([]);
221
+ const wowPainSchema = z.object({
222
+ referenceId: idSchema,
223
+ userWow: bulletsSchema,
224
+ userPain: bulletsSchema,
225
+ bizWow: bulletsSchema,
226
+ bizPain: bulletsSchema,
227
+ });
228
+ /** 화면이나 구좌에 걸린 발견 하나. 2×2 불릿과 달리 근거와 조작 구체값을 단다 */
229
+ export const findingSchema = z
230
+ .object({
231
+ id: idSchema,
232
+ referenceId: idSchema,
233
+ kind: z.enum(FINDING_KINDS),
234
+ viewpoint: z.enum(FINDING_VIEWPOINTS),
235
+ text: textSchema,
236
+ /** 비교표의 구좌 이름이나 시나리오 단계 */
237
+ slot: z.string().optional(),
238
+ captureId: idSchema.optional(),
239
+ screenMapId: idSchema.optional(),
240
+ /** 조작 횟수나 수치. 예: "20명 입력하려면 19번 터치" */
241
+ concrete: z.string().optional(),
242
+ /** 이번 시안에서 다루지 않는 페인이면 그 이유. 적으면 안 풀린 페인 경고에서 빠진다 */
243
+ excludedReason: z.string().optional(),
244
+ ...basisFields,
245
+ })
246
+ .superRefine(checkBasis);
247
+ const adoptionSchema = z
248
+ .object({
249
+ id: idSchema,
250
+ /** 시안 이름. 예: "A" */
251
+ variant: textSchema,
252
+ referenceIds: z.array(idSchema).default([]),
253
+ status: z.enum(ADOPTION_STATUSES),
254
+ /** 시안이 얹히는 자리. 레퍼런스에서 가져온 시안이 아니라 어디에 넣을지를 고르는 안이면 여기에 적는다 */
255
+ placement: z.string().optional(),
256
+ /** 가져온 것과 덜어낸 것. 시안 카드에 그대로 오른다 */
257
+ adopted: z.array(textSchema).default([]),
258
+ mitigated: z.array(textSchema).default([]),
259
+ /** 가져오는 와우(findings)와 푸는 페인(findings) */
260
+ takesFindingIds: z.array(idSchema).default([]),
261
+ solvesFindingIds: z.array(idSchema).default([]),
262
+ /** 채택하지 않은 이유. rejected면 필수 */
263
+ reason: z.string().optional(),
264
+ })
265
+ .superRefine((a, ctx) => {
266
+ if (a.status === 'rejected' && !a.reason?.trim()) {
267
+ ctx.addIssue({
268
+ code: z.ZodIssueCode.custom,
269
+ path: ['reason'],
270
+ message: '채택하지 않은 항목에는 이유(reason)를 적는다',
271
+ });
272
+ }
273
+ });
274
+ const reasonSchema = z.object({
275
+ referenceId: idSchema,
276
+ business: textSchema,
277
+ screens: textSchema,
278
+ difference: textSchema,
279
+ take: z.array(textSchema).default([]),
280
+ skip: z.array(textSchema).default([]),
281
+ });
282
+ const judgmentSchema = z
283
+ .object({
284
+ area: textSchema,
285
+ conclusion: textSchema,
286
+ confidence: z.enum(CONFIDENCE_LEVELS),
287
+ rationale: textSchema,
288
+ /** 보류일 때 무엇이 정해지면 결정할 수 있는지 */
289
+ holdUntil: z.string().optional(),
290
+ /** 우선순위. 1이 가장 먼저 */
291
+ rank: z.number().int().positive().optional(),
292
+ /** 이 판단 뒤에 할 일 */
293
+ todo: z.string().optional(),
294
+ evidence: evidenceListSchema,
295
+ })
296
+ .superRefine((j, ctx) => {
297
+ if (j.confidence === 'hold' && !j.holdUntil?.trim()) {
298
+ ctx.addIssue({
299
+ code: z.ZodIssueCode.custom,
300
+ path: ['holdUntil'],
301
+ message: '보류에는 무엇이 정해지면 결정할 수 있는지(holdUntil)를 함께 쓴다',
302
+ });
303
+ }
304
+ });
305
+ /** 다른 회사가 공개한 지표 사례. 성공 기준과 표본 가정을 정할 때 근거로 쓴다 */
306
+ export const metricCaseSchema = z
307
+ .object({
308
+ id: idSchema,
309
+ /** 회사나 서비스 이름 */
310
+ company: textSchema,
311
+ /** 제품 후보에 있는 회사면 그 id */
312
+ productId: idSchema.optional(),
313
+ metric: textSchema,
314
+ /** 무엇 대비 비율인지. 비율 값인데 비어 있으면 경고한다 */
315
+ denominator: z.string().optional(),
316
+ /** 값이나 변화. 예: "12%에서 18%로" */
317
+ value: textSchema,
318
+ /** 무엇을 바꿔서 나온 값인지 */
319
+ change: z.string().optional(),
320
+ /** 언제의 값인지 */
321
+ period: z.string().optional(),
322
+ ...basisFields,
323
+ })
324
+ .superRefine(checkBasis);
325
+ const verificationSchema = z.object({
326
+ hypothesis: textSchema,
327
+ metric: textSchema,
328
+ splitBy: textSchema,
329
+ /** 성공 기준과 표본 가정의 근거로 삼은 지표 사례 */
330
+ metricCaseIds: z.array(idSchema).default([]),
331
+ /** 누구를 몇 명쯤 볼지. 사용성 시험이면 인원, 로그 지표면 대상 집단 */
332
+ sample: z.string().optional(),
333
+ /** 얼마 동안 볼지 */
334
+ period: z.string().optional(),
335
+ /** 맞았다고 판정하는 기준. 이게 없으면 결과가 나와도 가설을 판정할 수 없다 */
336
+ successCriteria: z.string().optional(),
337
+ /** 성공하면 목표 지표가 몇 %p 오를지. 목표 지표를 안 움직이는 가설은 비운다 */
338
+ expectedLiftPp: z.number().positive().optional(),
339
+ liftBasis: liftBasisSchema.optional(),
340
+ /** 이 가설을 재는 이벤트. target.trackingPlan에서 상태를 본다 */
341
+ events: z.array(textSchema).default([]),
342
+ caveat: z.string().optional(),
343
+ });
344
+ export const videoClipSchema = z.object({
345
+ id: idSchema,
346
+ productId: idSchema,
347
+ title: textSchema,
348
+ kind: z.enum(VIDEO_KINDS),
349
+ origin: z.enum(VIDEO_ORIGINS),
350
+ url: httpsUrlSchema.optional(),
351
+ durationSec: z.number().nonnegative().optional(),
352
+ });
353
+ export const videoFindingSchema = z.object({
354
+ clipId: idSchema,
355
+ group: z.enum(VIDEO_GROUPS),
356
+ /** 구좌 이름이나 유형 이름처럼 묶음 안에서 가르는 말 */
357
+ topic: textSchema,
358
+ text: textSchema,
359
+ /** 영상에서 본 시각(초). 관찰과 회사 발언은 출처를 남기려고 꼭 적는다 */
360
+ at: z.number().nonnegative().optional(),
361
+ /** 이 발견이 나온 프레임. 캡처 이미지는 1번 섹션 캡처에 있다 */
362
+ captureId: idSchema.optional(),
363
+ evidence: z.enum(EVIDENCE_KINDS),
364
+ /** 방향성(회사가 가려는 쪽)인지 관례(업계가 하는 쪽)인지 */
365
+ nature: z.enum(VIDEO_NATURES).optional(),
366
+ /** 상시, 분기, 단독 중 어느 쪽 노출인지 */
367
+ cadence: z.enum(VIDEO_CADENCES).optional(),
368
+ });
369
+ const videoSchema = z.object({
370
+ clips: z.array(videoClipSchema).default([]),
371
+ findings: z.array(videoFindingSchema).default([]),
372
+ });
373
+ const openQuestionsSchema = z.object({
374
+ recipient: textSchema,
375
+ intro: textSchema,
376
+ items: z
377
+ .array(z.object({
378
+ question: textSchema,
379
+ temporary: textSchema,
380
+ /** 답에 따라 바뀌는 시안이나 화면. 비면 우선순위를 못 매긴다 */
381
+ affects: textSchema,
382
+ /** 누가 확인해야 풀리는지 */
383
+ owner: z.enum(QUESTION_OWNERS).optional(),
384
+ /** 다른 직군이면 어느 쪽인지. 예: 개발팀, 법무 */
385
+ ownerNote: z.string().optional(),
386
+ }))
387
+ .default([]),
388
+ });
389
+ /** 화면 A에서 B로 가는 사용자 행동 하나. 역기획의 빨간 화살표 */
390
+ const transitionSchema = z
391
+ .object({
392
+ id: idSchema,
393
+ from: idSchema,
394
+ to: idSchema,
395
+ /** 예: "상품 카드 탭" */
396
+ trigger: textSchema,
397
+ gesture: z.enum(GESTURES),
398
+ /** 분기 조건. 예: "로그인 상태" */
399
+ condition: z.string().optional(),
400
+ /** 같은 화면으로 되돌아오는 반복. 반복 비용을 note에 적는다 */
401
+ loop: z.boolean().optional(),
402
+ ...basisFields,
403
+ })
404
+ .superRefine(checkBasis);
405
+ /** 화면 위 번호 표시 하나. 기획서의 "1", "3-1" 같은 마커 */
406
+ const markerSchema = z.object({
407
+ id: idSchema,
408
+ screenMapId: idSchema,
409
+ /** 화면 안에서 겹치지 않는 번호. 하위 팝업은 "3-1" 꼴 */
410
+ no: textSchema,
411
+ element: textSchema,
412
+ /** "트리거 탭 시 → 결과" 꼴 */
413
+ behavior: textSchema,
414
+ /** 개수 상한 같은 제약 */
415
+ limit: z.string().optional(),
416
+ parentId: idSchema.optional(),
417
+ });
418
+ /** 무언가를 찾으려고 해본 기록. 실패도 결과로 남긴다 */
419
+ const attemptSchema = z.object({
420
+ id: idSchema,
421
+ referenceId: idSchema.optional(),
422
+ what: textSchema,
423
+ result: z.enum(ATTEMPT_RESULTS),
424
+ text: textSchema,
425
+ limit: z.string().optional(),
426
+ captureIds: z.array(idSchema).default([]),
427
+ });
428
+ /** 사람이 입력하는 사용자 유형 카드. 도구는 인터뷰를 하지 못한다 */
429
+ const userTypeSchema = z.object({
430
+ id: idSchema,
431
+ name: textSchema,
432
+ summary: textSchema,
433
+ /** 인터뷰 자료의 이름. 내용은 옮기지 않는다 */
434
+ source: textSchema,
435
+ });
436
+ const flowSchema = z
437
+ .object({
438
+ transitions: z.array(transitionSchema).default([]),
439
+ markers: z.array(markerSchema).default([]),
440
+ attempts: z.array(attemptSchema).default([]),
441
+ userTypes: z.array(userTypeSchema).default([]),
442
+ })
443
+ .default({});
444
+ export const researchSchema = z
445
+ .object({
446
+ schemaVersion: z.literal(RESEARCH_SCHEMA_VERSION).default(RESEARCH_SCHEMA_VERSION),
447
+ meta: z.object({
448
+ slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, 'slug는 소문자, 숫자, -만 쓴다'),
449
+ title: textSchema,
450
+ task: textSchema,
451
+ kind: z.enum(RESEARCH_KINDS),
452
+ }),
453
+ plan: planSchema.default({}),
454
+ premise: premiseSchema,
455
+ /** 목표치를 기존 데이터에서 세운 근거. 0번 전제에 그린다 */
456
+ target: targetSchema.optional(),
457
+ productCandidates: z.array(productCandidateSchema).default([]),
458
+ screenMap: z.array(screenMapItemSchema).default([]),
459
+ captures: z.array(captureSchema).default([]),
460
+ references: z.array(referenceSchema).default([]),
461
+ mockups: z.array(mockupSchema).default([]),
462
+ comparison: z.array(comparisonRowSchema).default([]),
463
+ wowPain: z
464
+ .object({
465
+ userViewpoint: z.string().default(''),
466
+ bizViewpoint: z.string().default(''),
467
+ items: z.array(wowPainSchema).default([]),
468
+ findings: z.array(findingSchema).default([]),
469
+ })
470
+ .default({}),
471
+ /** 시안이 리서치의 무엇을 가져오고 푸는지. 와우와 페인에서 시안으로 이어지는 선 */
472
+ adoptions: z.array(adoptionSchema).default([]),
473
+ /** 화면 전이, 번호 마커, 시도 기록, 사용자 유형. 역기획에서 채운다 */
474
+ flow: flowSchema,
475
+ reasons: z.array(reasonSchema).default([]),
476
+ judgment: z.array(judgmentSchema).default([]),
477
+ /** 다른 회사의 지표 사례. 7번 검증 제안의 근거가 된다 */
478
+ metricCases: z.array(metricCaseSchema).default([]),
479
+ verification: z.array(verificationSchema).default([]),
480
+ openQuestions: openQuestionsSchema.optional(),
481
+ /** 영상 분석. 본문에 합쳐 쓰거나 video-analysis 문서로 따로 쓴다 */
482
+ video: videoSchema.optional(),
483
+ })
484
+ .superRefine((r, ctx) => {
485
+ const issue = (path, message) => ctx.addIssue({ code: z.ZodIssueCode.custom, path, message });
486
+ const checkUnique = (key, ids) => {
487
+ const seen = new Set();
488
+ ids.forEach((id, i) => {
489
+ if (seen.has(id))
490
+ issue([key, i, 'id'], `id가 겹친다: ${id}`);
491
+ seen.add(id);
492
+ });
493
+ };
494
+ checkUnique('productCandidates', r.productCandidates.map((p) => p.id));
495
+ checkUnique('screenMap', r.screenMap.map((s) => s.id));
496
+ checkUnique('captures', r.captures.map((c) => c.id));
497
+ checkUnique('references', r.references.map((x) => x.id));
498
+ checkUnique('metricCases', r.metricCases.map((m) => m.id));
499
+ checkUnique('video.clips', (r.video?.clips ?? []).map((c) => c.id));
500
+ const products = new Set(r.productCandidates.map((p) => p.id));
501
+ const screens = new Set(r.screenMap.map((s) => s.id));
502
+ const refs = new Set(r.references.map((x) => x.id));
503
+ r.screenMap.forEach((s, i) => {
504
+ if (!products.has(s.productId))
505
+ issue(['screenMap', i, 'productId'], `없는 제품: ${s.productId}`);
506
+ });
507
+ r.captures.forEach((c, i) => {
508
+ if (!products.has(c.productId))
509
+ issue(['captures', i, 'productId'], `없는 제품: ${c.productId}`);
510
+ if (c.screenMapId && !screens.has(c.screenMapId)) {
511
+ issue(['captures', i, 'screenMapId'], `없는 화면: ${c.screenMapId}`);
512
+ }
513
+ });
514
+ r.references.forEach((x, i) => {
515
+ x.productIds.forEach((pid, j) => {
516
+ if (!products.has(pid))
517
+ issue(['references', i, 'productIds', j], `없는 제품: ${pid}`);
518
+ });
519
+ });
520
+ const clips = new Map((r.video?.clips ?? []).map((c) => [c.id, c]));
521
+ const capturesById = new Map(r.captures.map((c) => [c.id, c]));
522
+ (r.video?.clips ?? []).forEach((c, i) => {
523
+ if (!products.has(c.productId))
524
+ issue(['video', 'clips', i, 'productId'], `없는 제품: ${c.productId}`);
525
+ });
526
+ (r.video?.findings ?? []).forEach((f, i) => {
527
+ const clip = clips.get(f.clipId);
528
+ if (!clip)
529
+ issue(['video', 'findings', i, 'clipId'], `없는 영상: ${f.clipId}`);
530
+ if (f.at === undefined && f.evidence !== 'interpretation') {
531
+ issue(['video', 'findings', i, 'at'], '관찰과 회사 발언은 영상 시각(초)을 적는다');
532
+ }
533
+ if (f.captureId) {
534
+ const cap = capturesById.get(f.captureId);
535
+ if (!cap)
536
+ issue(['video', 'findings', i, 'captureId'], `없는 캡처: ${f.captureId}`);
537
+ else if (clip && cap.productId !== clip.productId) {
538
+ issue(['video', 'findings', i, 'captureId'], '영상과 캡처의 제품이 다르다');
539
+ }
540
+ }
541
+ });
542
+ const checkRef = (key, i, id) => {
543
+ if (!refs.has(id))
544
+ issue([key, i, 'referenceId'], `없는 레퍼런스: ${id}`);
545
+ };
546
+ r.mockups.forEach((m, i) => checkRef('mockups', i, m.referenceId));
547
+ r.wowPain.items.forEach((w, i) => checkRef('wowPain.items', i, w.referenceId));
548
+ r.reasons.forEach((x, i) => checkRef('reasons', i, x.referenceId));
549
+ r.comparison.forEach((row, i) => row.cells.forEach((cell, j) => {
550
+ if (!refs.has(cell.referenceId)) {
551
+ issue(['comparison', i, 'cells', j, 'referenceId'], `없는 레퍼런스: ${cell.referenceId}`);
552
+ }
553
+ }));
554
+ const checkEvidence = (path, list) => {
555
+ list.forEach((ev, k) => {
556
+ if (ev.type === 'capture' && !capturesById.has(ev.captureId)) {
557
+ issue([...path, k, 'captureId'], `없는 캡처: ${ev.captureId}`);
558
+ }
559
+ if (ev.type === 'video' && !clips.has(ev.clipId)) {
560
+ issue([...path, k, 'clipId'], `없는 영상: ${ev.clipId}`);
561
+ }
562
+ });
563
+ };
564
+ r.comparison.forEach((row, i) => row.cells.forEach((cell, j) => {
565
+ const base = ['comparison', i, 'cells', j];
566
+ checkEvidence([...base, 'evidence'], cell.evidence);
567
+ cell.captureIds.forEach((id, k) => {
568
+ if (!capturesById.has(id))
569
+ issue([...base, 'captureIds', k], `없는 캡처: ${id}`);
570
+ });
571
+ }));
572
+ r.judgment.forEach((j, i) => checkEvidence(['judgment', i, 'evidence'], j.evidence));
573
+ checkUnique('wowPain.findings', r.wowPain.findings.map((f) => f.id));
574
+ r.wowPain.findings.forEach((f, i) => {
575
+ checkRef('wowPain.findings', i, f.referenceId);
576
+ checkEvidence(['wowPain', 'findings', i, 'evidence'], f.evidence);
577
+ if (f.captureId && !capturesById.has(f.captureId)) {
578
+ issue(['wowPain', 'findings', i, 'captureId'], `없는 캡처: ${f.captureId}`);
579
+ }
580
+ if (f.screenMapId && !screens.has(f.screenMapId)) {
581
+ issue(['wowPain', 'findings', i, 'screenMapId'], `없는 화면: ${f.screenMapId}`);
582
+ }
583
+ });
584
+ checkUnique('adoptions', r.adoptions.map((a) => a.id));
585
+ const findingIds = new Set(r.wowPain.findings.map((f) => f.id));
586
+ r.adoptions.forEach((a, i) => {
587
+ a.referenceIds.forEach((id, k) => {
588
+ if (!refs.has(id))
589
+ issue(['adoptions', i, 'referenceIds', k], `없는 레퍼런스: ${id}`);
590
+ });
591
+ for (const key of ['takesFindingIds', 'solvesFindingIds']) {
592
+ a[key].forEach((id, k) => {
593
+ if (!findingIds.has(id))
594
+ issue(['adoptions', i, key, k], `없는 발견: ${id}`);
595
+ });
596
+ }
597
+ });
598
+ const caseIds = new Set(r.metricCases.map((m) => m.id));
599
+ r.metricCases.forEach((m, i) => {
600
+ if (m.productId && !products.has(m.productId)) {
601
+ issue(['metricCases', i, 'productId'], `없는 제품: ${m.productId}`);
602
+ }
603
+ checkEvidence(['metricCases', i, 'evidence'], m.evidence);
604
+ });
605
+ if (r.target) {
606
+ const t = r.target;
607
+ checkUnique('target.steps', t.steps.map((s) => s.id));
608
+ checkEvidence(['target', 'baseline', 'evidence'], t.baseline.evidence);
609
+ if (t.dailyEntrants) {
610
+ checkEvidence(['target', 'dailyEntrants', 'evidence'], t.dailyEntrants.evidence);
611
+ }
612
+ t.steps.forEach((s, i) => checkEvidence(['target', 'steps', i, 'evidence'], s.evidence));
613
+ }
614
+ r.verification.forEach((v, i) => {
615
+ if (v.liftBasis)
616
+ checkEvidence(['verification', i, 'liftBasis', 'evidence'], v.liftBasis.evidence);
617
+ v.metricCaseIds.forEach((id, k) => {
618
+ if (!caseIds.has(id))
619
+ issue(['verification', i, 'metricCaseIds', k], `없는 지표 사례: ${id}`);
620
+ });
621
+ });
622
+ checkUnique('flow.transitions', r.flow.transitions.map((t) => t.id));
623
+ checkUnique('flow.markers', r.flow.markers.map((m) => m.id));
624
+ checkUnique('flow.attempts', r.flow.attempts.map((a) => a.id));
625
+ checkUnique('flow.userTypes', r.flow.userTypes.map((u) => u.id));
626
+ r.flow.transitions.forEach((t, i) => {
627
+ for (const key of ['from', 'to']) {
628
+ if (!screens.has(t[key]))
629
+ issue(['flow', 'transitions', i, key], `없는 화면: ${t[key]}`);
630
+ }
631
+ checkEvidence(['flow', 'transitions', i, 'evidence'], t.evidence);
632
+ });
633
+ const markerIds = new Set(r.flow.markers.map((m) => m.id));
634
+ const markerNos = new Set();
635
+ r.flow.markers.forEach((m, i) => {
636
+ if (!screens.has(m.screenMapId)) {
637
+ issue(['flow', 'markers', i, 'screenMapId'], `없는 화면: ${m.screenMapId}`);
638
+ }
639
+ if (m.parentId && !markerIds.has(m.parentId)) {
640
+ issue(['flow', 'markers', i, 'parentId'], `없는 마커: ${m.parentId}`);
641
+ }
642
+ const key = `${m.screenMapId}#${m.no}`;
643
+ if (markerNos.has(key))
644
+ issue(['flow', 'markers', i, 'no'], `화면 안에서 번호가 겹친다: ${m.no}`);
645
+ markerNos.add(key);
646
+ });
647
+ r.flow.attempts.forEach((a, i) => {
648
+ if (a.referenceId)
649
+ checkRef('flow.attempts', i, a.referenceId);
650
+ a.captureIds.forEach((id, k) => {
651
+ if (!capturesById.has(id))
652
+ issue(['flow', 'attempts', i, 'captureIds', k], `없는 캡처: ${id}`);
653
+ });
654
+ });
655
+ });
656
+ /** 못 찾았거나 없다고 확인한 사례는 근거로 못 쓴다 */
657
+ export function isUsableMetricCase(m) {
658
+ return m.basis !== 'unconfirmed' && m.basis !== 'absent';
659
+ }
660
+ /** 근거로 쓸 수 있는 지표 사례를 제품별로 묶는다. 레퍼런스 표에 지표가 있는 제품을 드러낼 때 쓴다 */
661
+ export function metricCasesByProduct(research) {
662
+ const byProduct = new Map();
663
+ for (const m of research.metricCases) {
664
+ if (!m.productId || !isUsableMetricCase(m))
665
+ continue;
666
+ byProduct.set(m.productId, [...(byProduct.get(m.productId) ?? []), m]);
667
+ }
668
+ return byProduct;
669
+ }
670
+ export function parseResearch(input) {
671
+ const parsed = researchSchema.safeParse(input);
672
+ if (parsed.success)
673
+ return ok(parsed.data);
674
+ const issues = parsed.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`);
675
+ return err(new ValidationError(`research.json 형식 오류: ${issues.join('; ')}`, issues));
676
+ }
677
+ /** 효과나 의도를 말하는 문장의 낌새 */
678
+ const EFFECT_PATTERN = /늘어|줄어|올라|높아|낮아|돌려|이탈|전환|효과|할 수 있|때문에|해서 .*(했|한다|하다)|려고|위해/;
679
+ /** 근거 표기. 해석, 회사 발언, 관찰 출처 중 하나가 문장 안에 있으면 통과 */
680
+ const EVIDENCE_TAG = /\(해석\)|\(회사 [^)]+\)|\((화면|영상|프레임) [^)]+\)|https?:\/\//;
681
+ function sentencesOf(text) {
682
+ return text
683
+ .split(/(?<=[.!?。다요])\s+(?!\()|\n/)
684
+ .map((s) => s.trim())
685
+ .filter((s) => s.length > 0);
686
+ }
687
+ function lintEffectSentences(text, path, warnings) {
688
+ for (const sentence of sentencesOf(text)) {
689
+ if (EFFECT_PATTERN.test(sentence) && !EVIDENCE_TAG.test(sentence)) {
690
+ warnings.push({
691
+ path,
692
+ message: `효과나 의도를 말하는 문장에 (해석)이나 출처 표기가 없다: "${sentence.slice(0, 40)}"`,
693
+ });
694
+ }
695
+ }
696
+ }
697
+ /** 못 봤다고 말하는 문장의 낌새 */
698
+ const UNCONFIRMED_PATTERN = /확인 못|확인하지 못|확인되지 않|리서치에 없/;
699
+ const DIGIT = /\d/;
700
+ /** 와우와 페인이 비었거나 근거 상태가 비어 있는 자리를 본다. 근거 구조(basis, evidence)가 있어야 걸린다 */
701
+ function lintEvidenceStructure(research, warnings) {
702
+ const { references, comparison, wowPain, adoptions, openQuestions } = research;
703
+ const needsWowPain = ['ux-research', 'reverse-plan', 'market-scan'].includes(research.meta.kind);
704
+ if (needsWowPain) {
705
+ for (const ref of references) {
706
+ const items = wowPain.items.filter((w) => w.referenceId === ref.id);
707
+ const found = wowPain.findings.filter((f) => f.referenceId === ref.id);
708
+ const wow = found.filter((f) => f.kind === 'wow').length +
709
+ items.reduce((n, w) => n + w.userWow.length + w.bizWow.length, 0);
710
+ const pain = found.filter((f) => f.kind === 'pain').length +
711
+ items.reduce((n, w) => n + w.userPain.length + w.bizPain.length, 0);
712
+ if (wow === 0 || pain === 0) {
713
+ warnings.push({
714
+ path: 'wowPain',
715
+ message: `${ref.symbol}. ${ref.typeName}에 ${wow === 0 ? '와우' : '페인'} 포인트가 하나도 없다. 못 찾았으면 못 찾았다고 근거 상태(unconfirmed)로 적는다`,
716
+ });
717
+ }
718
+ }
719
+ }
720
+ research.flow.transitions.forEach((t, i) => {
721
+ if (t.loop && !t.note?.trim()) {
722
+ warnings.push({
723
+ path: `flow.transitions.${i}`,
724
+ message: `반복 전이(${t.id})에 반복 비용이 없다. note에 "몇 번 되풀이하는지"를 적는다`,
725
+ });
726
+ }
727
+ });
728
+ wowPain.findings.forEach((f, i) => {
729
+ const path = `wowPain.findings.${i}`;
730
+ if (f.kind === 'pain' && f.basis === 'observation' && !f.concrete && !DIGIT.test(f.text)) {
731
+ warnings.push({
732
+ path,
733
+ message: `본 페인에 조작 횟수나 수치가 없다 (${f.id}). concrete에 "몇 번 눌러야 하는지" 같은 값을 적는다`,
734
+ });
735
+ }
736
+ if (f.slot &&
737
+ comparison.length > 0 &&
738
+ (f.kind === 'wow' || f.kind === 'pain') &&
739
+ !comparison.some((row) => row.criterion === f.slot)) {
740
+ warnings.push({
741
+ path: `${path}.slot`,
742
+ message: `구좌 "${f.slot}"가 비교표 기준 이름과 달라 격자 칸에 안 붙는다 (${f.id}). 비교표의 기준 이름과 똑같이 적는다`,
743
+ });
744
+ }
745
+ if (UNCONFIRMED_PATTERN.test(f.text) && f.basis !== 'unconfirmed') {
746
+ warnings.push({
747
+ path: `${path}.basis`,
748
+ message: `확인 못했다고 적었는데 근거 상태가 ${f.basis}이다 (${f.id})`,
749
+ });
750
+ }
751
+ });
752
+ if (references.length > 0 && comparison.length > 0) {
753
+ let noBasis = 0;
754
+ comparison.forEach((row, i) => {
755
+ const present = new Set(row.cells.map((c) => c.referenceId));
756
+ const missing = references.filter((r) => !present.has(r.id));
757
+ if (missing.length > 0) {
758
+ warnings.push({
759
+ path: `comparison.${i}.cells`,
760
+ message: `${row.criterion} 행에 빠진 칸이 있다 (${missing.map((r) => r.symbol).join(', ')}). 못 본 칸은 빈칸으로 두지 않고 "확인 못함"과 이유를 적는다`,
761
+ });
762
+ }
763
+ row.cells.forEach((cell, j) => {
764
+ if (!cell.basis)
765
+ noBasis++;
766
+ if (UNCONFIRMED_PATTERN.test(cell.text) && cell.basis && cell.basis !== 'unconfirmed') {
767
+ warnings.push({
768
+ path: `comparison.${i}.cells.${j}.basis`,
769
+ message: `확인 못했다고 적었는데 근거 상태가 ${cell.basis}이다 (${row.criterion})`,
770
+ });
771
+ }
772
+ });
773
+ });
774
+ if (noBasis > 0) {
775
+ warnings.push({
776
+ path: 'comparison',
777
+ message: `비교표 칸 ${noBasis}개에 근거 상태(basis)가 없다. 관찰, 회사 발언, 해석, 확인 못함, 없음 중 하나를 정한다`,
778
+ });
779
+ }
780
+ }
781
+ if (adoptions.length > 0) {
782
+ adoptions.forEach((a, i) => {
783
+ if (a.referenceIds.length === 0 && !a.placement?.trim()) {
784
+ warnings.push({
785
+ path: `adoptions.${i}`,
786
+ message: `레퍼런스도 자리도 없는 시안이다 (${a.id}). 어디서 가져왔는지(referenceIds)나 어디에 넣는지(placement)를 적는다`,
787
+ });
788
+ }
789
+ });
790
+ const solved = new Set(adoptions.flatMap((a) => a.solvesFindingIds));
791
+ wowPain.findings.forEach((f, i) => {
792
+ if (f.kind !== 'pain')
793
+ return;
794
+ const excluded = Boolean(f.excludedReason?.trim());
795
+ if (!solved.has(f.id) && !excluded) {
796
+ warnings.push({
797
+ path: `wowPain.findings.${i}`,
798
+ message: `어느 시안도 풀지 않는 페인이다 (${f.id}). 시안의 solvesFindingIds에 걸거나 제외 이유(excludedReason)를 적는다`,
799
+ });
800
+ }
801
+ if (solved.has(f.id) && excluded) {
802
+ warnings.push({
803
+ path: `wowPain.findings.${i}.excludedReason`,
804
+ message: `푸는 시안이 있는데 제외 이유도 적힌 페인이다 (${f.id}). 둘 중 하나만 남긴다`,
805
+ });
806
+ }
807
+ });
808
+ }
809
+ const items = openQuestions?.items ?? [];
810
+ const noOwner = items.filter((q) => !q.owner).length;
811
+ if (noOwner > 0) {
812
+ warnings.push({
813
+ path: 'openQuestions.items',
814
+ message: `질문 ${noOwner}개에 확인 주체(owner)가 없다. 바로 정할 것, 데이터를 봐야 할 것, 다른 직군이 확인할 것으로 나눈다`,
815
+ });
816
+ }
817
+ }
818
+ // 전환율, 이탈률처럼 이름에 율이나 률이 붙은 지표도 비율로 본다
819
+ const RATE_PATTERN = /%|퍼센트|[율률]/;
820
+ /** 지표 사례가 성공 기준의 근거로 쓰일 수 있는지 본다 */
821
+ function lintMetricCases(research, warnings) {
822
+ const { metricCases, verification } = research;
823
+ // 기준값을 우리 데이터로 채웠으면 타사 사례는 교차 확인용이라 없어도 경고하지 않는다
824
+ const crossCheckOnly = hasUsableBaseline(research);
825
+ if (!crossCheckOnly && verification.length > 0 && metricCases.length === 0) {
826
+ warnings.push({
827
+ path: 'metricCases',
828
+ message: '타사 지표 사례가 없다. 성공 기준과 표본 가정의 근거가 된다. 찾지 못했으면 basis를 unconfirmed로 두고 이유를 남긴다',
829
+ });
830
+ }
831
+ metricCases.forEach((m, i) => {
832
+ if (RATE_PATTERN.test(`${m.metric} ${m.value}`) && !m.denominator?.trim()) {
833
+ warnings.push({
834
+ path: `metricCases.${i}.denominator`,
835
+ message: `분모가 없는 비율 사례다 (${m.id}). 무엇 대비 비율인지 모르면 우리 지표와 견줄 수 없다`,
836
+ });
837
+ }
838
+ });
839
+ const usable = metricCases.some(isUsableMetricCase);
840
+ if (!usable || crossCheckOnly)
841
+ return;
842
+ verification.forEach((v, i) => {
843
+ if (v.successCriteria?.trim() && v.metricCaseIds.length === 0) {
844
+ warnings.push({
845
+ path: `verification.${i}.metricCaseIds`,
846
+ message: `지표 사례를 걸지 않은 성공 기준이다 (가설 ${i + 1}). 근거로 삼은 사례를 metricCaseIds에 건다`,
847
+ });
848
+ }
849
+ });
850
+ }
851
+ /**
852
+ * 스키마로 못 잡는 의미 검사. 막지는 않고 경고만 돌려준다.
853
+ * 효과 문장의 근거 표기, 레퍼런스 개수, 캡처 상태, 조합 확인 여부를 본다.
854
+ */
855
+ export function lintResearch(research) {
856
+ const warnings = [];
857
+ if (research.references.length > 0 &&
858
+ (research.references.length < 3 || research.references.length > 5)) {
859
+ warnings.push({
860
+ path: 'references',
861
+ message: `레퍼런스 유형은 3~5개를 권한다 (지금 ${research.references.length}개)`,
862
+ });
863
+ }
864
+ if (research.captures.length > 0 && !research.plan.confirmedAt) {
865
+ warnings.push({
866
+ path: 'plan.confirmedAt',
867
+ message: '조합을 확인받기 전에 캡처가 쌓였다',
868
+ });
869
+ }
870
+ research.verification.forEach((v, i) => {
871
+ if (!v.successCriteria?.trim()) {
872
+ warnings.push({
873
+ path: `verification.${i}`,
874
+ message: `성공 기준이 없는 가설이다 (가설 ${i + 1}). 어떤 값이 나오면 맞았다고 볼지(successCriteria)를 적는다`,
875
+ });
876
+ }
877
+ });
878
+ lintMetricCases(research, warnings);
879
+ const ownProducts = new Set(research.productCandidates.filter((p) => p.axis === 'own-service').map((p) => p.id));
880
+ if (ownProducts.size > 1) {
881
+ warnings.push({
882
+ path: 'products',
883
+ message: `우리 서비스(own-service)는 하나만 둔다 (지금 ${ownProducts.size}개)`,
884
+ });
885
+ }
886
+ research.captures.forEach((c, i) => {
887
+ const ownKind = c.kind === 'own-app' || c.kind === 'own-web' || c.kind === 'figma';
888
+ if (ownKind !== ownProducts.has(c.productId)) {
889
+ warnings.push({
890
+ path: `captures.${i}.kind`,
891
+ message: ownKind
892
+ ? `${c.kind} 캡처는 own-service 제품에만 붙인다 (${c.id})`
893
+ : `own-service 제품의 캡처는 own-app, own-web, figma 중 하나로 적는다 (${c.id})`,
894
+ });
895
+ }
896
+ if (c.kind === 'figma' && !c.figmaUrl) {
897
+ warnings.push({
898
+ path: `captures.${i}.figmaUrl`,
899
+ message: `figma 캡처에 노드 링크가 없다 (${c.id}). 어느 프레임인지 되짚을 수 없다`,
900
+ });
901
+ }
902
+ if (c.pii === 'unchecked') {
903
+ warnings.push({
904
+ path: `captures.${i}.pii`,
905
+ message: `개인정보 확인을 못 한 캡처다 (${c.id}). 공유용 렌더가 막힌다`,
906
+ });
907
+ }
908
+ });
909
+ const captured = new Set(research.captures.map((c) => c.screenMapId).filter(Boolean));
910
+ research.screenMap.forEach((s, i) => {
911
+ if ((s.capture === 'captured' || s.capture === 'reference-image') && !captured.has(s.id)) {
912
+ warnings.push({
913
+ path: `screenMap.${i}.capture`,
914
+ message: `캡처 상태가 ${s.capture}인데 연결된 캡처가 없다 (${s.id})`,
915
+ });
916
+ }
917
+ });
918
+ research.reasons.forEach((r, i) => {
919
+ for (const key of ['business', 'screens', 'difference']) {
920
+ lintEffectSentences(r[key], `reasons.${i}.${key}`, warnings);
921
+ }
922
+ const flat = [...r.take, ...r.skip];
923
+ flat.forEach((t, j) => lintEffectSentences(t, `reasons.${i}.take_skip.${j}`, warnings));
924
+ });
925
+ research.judgment.forEach((j, i) => {
926
+ lintEffectSentences(j.rationale, `judgment.${i}.rationale`, warnings);
927
+ });
928
+ research.wowPain.items.forEach((w, i) => {
929
+ [...w.bizWow, ...w.bizPain].forEach((b, j) => lintEffectSentences(b, `wowPain.items.${i}.biz.${j}`, warnings));
930
+ ['userWow', 'userPain', 'bizWow', 'bizPain'].forEach((key) => {
931
+ if (w[key].length !== 2) {
932
+ warnings.push({
933
+ path: `wowPain.items.${i}.${key}`,
934
+ message: `칸마다 불릿 2개를 쓴다 (지금 ${w[key].length}개)`,
935
+ });
936
+ }
937
+ });
938
+ });
939
+ research.comparison.forEach((row, i) => {
940
+ if (row.kind !== 'risk')
941
+ return;
942
+ row.cells.forEach((cell, j) => lintEffectSentences(cell.text, `comparison.${i}.cells.${j}`, warnings));
943
+ });
944
+ lintEvidenceStructure(research, warnings);
945
+ lintTarget(research, warnings);
946
+ lintAttachTo(research, warnings);
947
+ const video = research.video;
948
+ if (research.meta.kind === 'video-analysis' && (!video || video.clips.length === 0)) {
949
+ warnings.push({ path: 'video.clips', message: '영상 분석 문서인데 영상이 하나도 없다' });
950
+ }
951
+ if (video) {
952
+ const withFindings = new Set(video.findings.map((f) => f.clipId));
953
+ video.clips.forEach((c, i) => {
954
+ if (!withFindings.has(c.id)) {
955
+ warnings.push({
956
+ path: `video.clips.${i}`,
957
+ message: `발견이 하나도 없는 영상이다 (${c.id})`,
958
+ });
959
+ }
960
+ });
961
+ }
962
+ const openQuestionsOptional = research.meta.kind === 'video-analysis';
963
+ if (!openQuestionsOptional &&
964
+ (!research.openQuestions || research.openQuestions.items.length === 0)) {
965
+ warnings.push({
966
+ path: 'openQuestions',
967
+ message: '열린 질문 섹션이 비어 있다. 마지막 섹션이라 꼭 둔다',
968
+ });
969
+ }
970
+ return warnings;
971
+ }
972
+ function lintAttachTo(research, warnings) {
973
+ const attachTo = research.premise.attachTo;
974
+ if (!attachTo) {
975
+ if (research.verification.length > 0 || research.target) {
976
+ warnings.push({
977
+ path: 'premise.attachTo',
978
+ message: '기능이 우리 서비스의 어느 흐름에 붙는지가 없다. 이게 정해져야 레퍼런스 도메인과 대리 퍼널을 고를 수 있다',
979
+ });
980
+ }
981
+ return;
982
+ }
983
+ const others = research.productCandidates.filter((p) => p.axis !== 'own-service');
984
+ if (others.length > 0 && !others.some((p) => p.domainFit === 'same')) {
985
+ warnings.push({
986
+ path: 'products',
987
+ message: `우리 기능과 같은 도메인(${attachTo.domain})의 후보가 없다. 다른 도메인 사례는 화면 패턴만 빌려오고 수치는 참고만 한다`,
988
+ });
989
+ }
990
+ }
991
+ //# sourceMappingURL=schema.js.map