@yoonion/mimi-seed-mcp 0.19.1 → 0.19.2

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.
@@ -1,11 +1,33 @@
1
1
  import { type ReportRow } from './sales.js';
2
+ interface JsonApiRow<T> {
3
+ id: string;
4
+ attributes?: T;
5
+ }
6
+ interface AnalyticsReportAttributes {
7
+ name?: string;
8
+ category?: 'APP_USAGE' | 'APP_STORE_ENGAGEMENT' | 'COMMERCE' | 'FRAMEWORK_USAGE' | 'PERFORMANCE';
9
+ }
10
+ export declare function selectWeeklyAnalyticsReports(reports: Array<JsonApiRow<AnalyticsReportAttributes>>): {
11
+ engagement: JsonApiRow<AnalyticsReportAttributes> | undefined;
12
+ downloads: JsonApiRow<AnalyticsReportAttributes> | undefined;
13
+ purchases: JsonApiRow<AnalyticsReportAttributes> | undefined;
14
+ };
2
15
  interface WeeklyMetrics {
3
16
  date: string;
4
17
  impressions: number;
5
18
  productPageViews: number;
6
19
  downloads: number;
7
20
  proceeds: number;
21
+ available: {
22
+ engagement: boolean;
23
+ downloads: boolean;
24
+ purchases: boolean;
25
+ };
8
26
  }
27
+ export declare function selectLatestInstanceRows(batches: Array<{
28
+ processingDate: string;
29
+ rows: ReportRow[];
30
+ }>): ReportRow[];
9
31
  export declare function buildWeeklyInsight(input: {
10
32
  engagement: ReportRow[];
11
33
  downloads: ReportRow[];
@@ -34,7 +56,13 @@ export declare function buildWeeklyInsight(input: {
34
56
  insight: {
35
57
  area: string;
36
58
  changePercent: number | null;
59
+ trend: string;
37
60
  recommendation: string;
61
+ } | {
62
+ area: string;
63
+ changePercent: null;
64
+ recommendation: string;
65
+ trend?: undefined;
38
66
  };
39
67
  weeks?: undefined;
40
68
  recommendation?: undefined;
@@ -90,7 +118,13 @@ export declare function getWeeklyInsight(input: {
90
118
  insight: {
91
119
  area: string;
92
120
  changePercent: number | null;
121
+ trend: string;
122
+ recommendation: string;
123
+ } | {
124
+ area: string;
125
+ changePercent: null;
93
126
  recommendation: string;
127
+ trend?: undefined;
94
128
  };
95
129
  weeks?: undefined;
96
130
  recommendation?: undefined;
@@ -4,7 +4,21 @@ import { getReportsAuthHeaders } from './auth.js';
4
4
  import { V1_BASE, apiRequest, authHeadersOrThrow } from './http.js';
5
5
  import { fetchWithTimeout, HTTP_TRANSFER_TIMEOUT_MS } from '../lib/http.js';
6
6
  import { parseTsv } from './sales.js';
7
+ export function selectWeeklyAnalyticsReports(reports) {
8
+ const standard = reports.filter((row) => !/Detailed/i.test(row.attributes?.name ?? ''));
9
+ const detailed = reports.filter((row) => /Detailed/i.test(row.attributes?.name ?? ''));
10
+ return {
11
+ engagement: standard.find((row) => /Discovery and Engagement/i.test(row.attributes?.name ?? '')),
12
+ // Apple only exposes weekly App Store Downloads as a detailed report. Selecting the
13
+ // standard report here silently yields no WEEKLY instances even when analytics is ready.
14
+ downloads: detailed.find((row) => /App Store Downloads/i.test(row.attributes?.name ?? ''))
15
+ ?? standard.find((row) => /App Store Downloads/i.test(row.attributes?.name ?? '')),
16
+ purchases: standard.find((row) => /App Store Purchases/i.test(row.attributes?.name ?? '')),
17
+ };
18
+ }
7
19
  const MAX_SEGMENT_BYTES = 50 * 1024 * 1024;
20
+ const MAX_DECOMPRESSED_BYTES = 100 * 1024 * 1024;
21
+ const MAX_WEEKLY_INSTANCES = 8;
8
22
  async function reportHeaders() {
9
23
  const auth = await getReportsAuthHeaders();
10
24
  if (!auth)
@@ -39,35 +53,64 @@ async function downloadSegment(segment) {
39
53
  const response = await fetchWithTimeout(attributes.url, {}, HTTP_TRANSFER_TIMEOUT_MS);
40
54
  if (!response.ok)
41
55
  throw new Error(`Analytics segment download failed (${response.status}).`);
56
+ const contentLength = Number(response.headers.get('content-length') ?? 0);
57
+ if (Number.isFinite(contentLength) && contentLength > MAX_SEGMENT_BYTES) {
58
+ throw new Error(`Analytics segment ${segment.id} response is larger than the 50 MB safety limit.`);
59
+ }
42
60
  const bytes = Buffer.from(await response.arrayBuffer());
61
+ if (bytes.length > MAX_SEGMENT_BYTES) {
62
+ throw new Error(`Analytics segment ${segment.id} downloaded more than the 50 MB safety limit.`);
63
+ }
43
64
  if (attributes.checksum) {
44
65
  const actual = crypto.createHash('md5').update(bytes).digest('hex');
45
66
  if (actual !== attributes.checksum.toLowerCase()) {
46
67
  throw new Error(`Analytics segment checksum mismatch for ${segment.id}.`);
47
68
  }
48
69
  }
49
- let text;
50
- try {
51
- text = zlib.gunzipSync(bytes).toString('utf8');
52
- }
53
- catch {
54
- text = bytes.toString('utf8');
55
- }
70
+ const isGzip = bytes[0] === 0x1f && bytes[1] === 0x8b;
71
+ const text = isGzip
72
+ ? zlib.gunzipSync(bytes, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString('utf8')
73
+ : bytes.toString('utf8');
56
74
  return parseTsv(text);
57
75
  }
76
+ export function selectLatestInstanceRows(batches) {
77
+ const latestByDate = new Map();
78
+ for (const batch of batches) {
79
+ const rowsByDate = new Map();
80
+ for (const row of batch.rows) {
81
+ const date = dateOf(row);
82
+ if (!date)
83
+ continue;
84
+ const rows = rowsByDate.get(date) ?? [];
85
+ rows.push(row);
86
+ rowsByDate.set(date, rows);
87
+ }
88
+ for (const [date, rows] of rowsByDate) {
89
+ const current = latestByDate.get(date);
90
+ if (!current || batch.processingDate > current.processingDate) {
91
+ latestByDate.set(date, { processingDate: batch.processingDate, rows });
92
+ }
93
+ }
94
+ }
95
+ return [...latestByDate.entries()]
96
+ .sort(([left], [right]) => left.localeCompare(right))
97
+ .flatMap(([, value]) => value.rows);
98
+ }
58
99
  async function rowsForReport(reportId) {
59
100
  const instances = await reportGet(`/analyticsReports/${reportId}/instances`, { 'filter[granularity]': 'WEEKLY', limit: '200' });
60
101
  const latest = (instances.data ?? [])
61
102
  .filter((row) => row.attributes?.processingDate)
62
103
  .sort((a, b) => (b.attributes?.processingDate ?? '').localeCompare(a.attributes?.processingDate ?? ''))
63
- .slice(0, 2);
64
- const allRows = [];
104
+ .slice(0, MAX_WEEKLY_INSTANCES);
105
+ const batches = [];
65
106
  for (const instance of latest) {
66
107
  const segments = await reportGet(`/analyticsReportInstances/${instance.id}/segments`, { limit: '200' });
108
+ const rows = [];
67
109
  for (const segment of segments.data ?? [])
68
- allRows.push(...await downloadSegment(segment));
110
+ rows.push(...await downloadSegment(segment));
111
+ batches.push({ processingDate: instance.attributes.processingDate, rows });
69
112
  }
70
- return allRows;
113
+ return selectLatestInstanceRows(batches);
71
114
  }
72
115
  function numberFrom(row, names) {
73
116
  for (const name of names) {
@@ -86,7 +129,14 @@ function dateOf(row) {
86
129
  function ensureWeek(map, date) {
87
130
  let week = map.get(date);
88
131
  if (!week) {
89
- week = { date, impressions: 0, productPageViews: 0, downloads: 0, proceeds: 0 };
132
+ week = {
133
+ date,
134
+ impressions: 0,
135
+ productPageViews: 0,
136
+ downloads: 0,
137
+ proceeds: 0,
138
+ available: { engagement: false, downloads: false, purchases: false },
139
+ };
90
140
  map.set(date, week);
91
141
  }
92
142
  return week;
@@ -103,9 +153,10 @@ export function buildWeeklyInsight(input) {
103
153
  if (!date)
104
154
  continue;
105
155
  const week = ensureWeek(weeks, date);
156
+ week.available.engagement = true;
106
157
  const event = (row.Event ?? '').toLowerCase();
107
158
  const pageType = (row['Page Type'] ?? '').toLowerCase();
108
- const count = numberFrom(row, ['Counts', 'Count']);
159
+ const count = numberFrom(row, ['Unique Counts', 'Counts', 'Count']);
109
160
  if (event === 'impression')
110
161
  week.impressions += count;
111
162
  if (event === 'page view' && (pageType === 'product page' || !pageType))
@@ -115,20 +166,27 @@ export function buildWeeklyInsight(input) {
115
166
  const date = dateOf(row);
116
167
  if (!date)
117
168
  continue;
118
- ensureWeek(weeks, date).downloads += numberFrom(row, ['Counts', 'Count', 'Downloads', 'Units']);
169
+ const week = ensureWeek(weeks, date);
170
+ week.available.downloads = true;
171
+ week.downloads += numberFrom(row, ['Counts', 'Count', 'Downloads', 'Units']);
119
172
  }
120
173
  for (const row of input.purchases) {
121
174
  const date = dateOf(row);
122
175
  if (!date)
123
176
  continue;
124
- ensureWeek(weeks, date).proceeds += numberFrom(row, [
177
+ const week = ensureWeek(weeks, date);
178
+ week.available.purchases = true;
179
+ week.proceeds += numberFrom(row, [
180
+ 'Proceeds in USD',
125
181
  'Developer Proceeds',
126
182
  'Proceeds',
127
183
  'Estimated Proceeds',
128
184
  'Sales',
129
185
  ]);
130
186
  }
131
- const ordered = [...weeks.values()].sort((a, b) => a.date.localeCompare(b.date));
187
+ const ordered = [...weeks.values()]
188
+ .filter((week) => week.available.engagement && week.available.downloads)
189
+ .sort((a, b) => a.date.localeCompare(b.date));
132
190
  if (ordered.length < 2) {
133
191
  return {
134
192
  status: 'collecting',
@@ -148,17 +206,22 @@ export function buildWeeklyInsight(input) {
148
206
  {
149
207
  area: 'product_page',
150
208
  changePercent: percentageChange(currentPageRate, previousPageRate),
151
- recommendation: '제품 페이지 진입률이 하락했습니다. 아이콘·스크린샷 첫 장·부제 중 하나를 바꿔 Product Page Optimization 테스트를 시작하세요.',
209
+ declineRecommendation: '제품 페이지 진입률이 하락했습니다. 아이콘·스크린샷 첫 장·부제 중 하나를 바꿔 Product Page Optimization 테스트를 시작하세요.',
210
+ healthyRecommendation: '핵심 전환 지표의 주간 하락은 없습니다. 가장 개선 폭이 작은 제품 페이지 진입률을 다음 실험 대상으로 삼으세요.',
152
211
  },
153
212
  {
154
213
  area: 'acquisition',
155
214
  changePercent: percentageChange(currentDownloadRate, previousDownloadRate),
156
- recommendation: '제품 페이지 조회 대비 다운로드 전환이 하락했습니다. 유입 소스별 전환을 나누고 가장 큰 하락 소스에 맞춘 커스텀 제품 페이지를 만드세요.',
215
+ declineRecommendation: '제품 페이지 조회 대비 다운로드 전환이 하락했습니다. 유입 소스별 전환을 나누고 가장 큰 하락 소스에 맞춘 커스텀 제품 페이지를 만드세요.',
216
+ healthyRecommendation: '핵심 전환 지표의 주간 하락은 없습니다. 가장 개선 폭이 작은 다운로드 전환을 유입 소스별로 나눠 다음 실험을 정하세요.',
157
217
  },
158
218
  {
159
219
  area: 'monetization',
160
- changePercent: percentageChange(currentRevenuePerDownload, previousRevenuePerDownload),
161
- recommendation: '다운로드당 수익이 하락했습니다. 구매 리포트에서 상품별 하락을 확인하고 가격·오퍼·구독 전환 중 한 가지를 실험하세요.',
220
+ changePercent: previous.available.purchases && current.available.purchases
221
+ ? percentageChange(currentRevenuePerDownload, previousRevenuePerDownload)
222
+ : null,
223
+ declineRecommendation: '다운로드당 수익이 하락했습니다. 구매 리포트에서 상품별 하락을 확인하고 가격·오퍼·구독 전환 중 한 가지를 실험하세요.',
224
+ healthyRecommendation: '핵심 전환 지표의 주간 하락은 없습니다. 가장 개선 폭이 작은 다운로드당 수익을 상품별로 나눠 다음 가격·오퍼 실험을 정하세요.',
162
225
  },
163
226
  ].filter((candidate) => candidate.changePercent !== null)
164
227
  .sort((a, b) => (a.changePercent ?? 0) - (b.changePercent ?? 0));
@@ -171,21 +234,28 @@ export function buildWeeklyInsight(input) {
171
234
  impressions: percentageChange(current.impressions, previous.impressions),
172
235
  productPageViews: percentageChange(current.productPageViews, previous.productPageViews),
173
236
  downloads: percentageChange(current.downloads, previous.downloads),
174
- proceeds: percentageChange(current.proceeds, previous.proceeds),
237
+ proceeds: previous.available.purchases && current.available.purchases
238
+ ? percentageChange(current.proceeds, previous.proceeds)
239
+ : null,
175
240
  productPageRate: percentageChange(currentPageRate, previousPageRate),
176
241
  downloadRate: percentageChange(currentDownloadRate, previousDownloadRate),
177
- revenuePerDownload: percentageChange(currentRevenuePerDownload, previousRevenuePerDownload),
242
+ revenuePerDownload: previous.available.purchases && current.available.purchases
243
+ ? percentageChange(currentRevenuePerDownload, previousRevenuePerDownload)
244
+ : null,
178
245
  },
179
246
  insight: selected
180
247
  ? {
181
248
  area: selected.area,
182
249
  changePercent: selected.changePercent,
183
- recommendation: selected.recommendation,
250
+ trend: (selected.changePercent ?? 0) < 0 ? 'declining' : 'stable_or_improving',
251
+ recommendation: (selected.changePercent ?? 0) < 0
252
+ ? selected.declineRecommendation
253
+ : selected.healthyRecommendation,
184
254
  }
185
255
  : {
186
256
  area: 'data_quality',
187
257
  changePercent: null,
188
- recommendation: '비교 가능한 분모 데이터가 부족합니다. 표준 Engagement·Downloads·Purchases 리포트가 생성되는지 확인하세요.',
258
+ recommendation: '비교 가능한 분모 데이터가 부족합니다. Engagement·주간 Downloads Detailed·Purchases 리포트가 생성되는지 확인하세요.',
189
259
  },
190
260
  };
191
261
  }
@@ -214,10 +284,7 @@ export async function getWeeklyInsight(input) {
214
284
  };
215
285
  }
216
286
  const reports = await reportGet(`/analyticsReportRequests/${active.id}/reports`, { limit: '200' });
217
- const standard = (reports.data ?? []).filter((row) => !/Detailed/i.test(row.attributes?.name ?? ''));
218
- const engagement = standard.find((row) => /Discovery and Engagement/i.test(row.attributes?.name ?? ''));
219
- const downloads = standard.find((row) => /App Store Downloads/i.test(row.attributes?.name ?? ''));
220
- const purchases = standard.find((row) => /App Store Purchases/i.test(row.attributes?.name ?? ''));
287
+ const { engagement, downloads, purchases } = selectWeeklyAnalyticsReports(reports.data ?? []);
221
288
  if (!engagement && !downloads && !purchases) {
222
289
  return {
223
290
  status: 'collecting',
@@ -13,10 +13,16 @@ export interface BillingComplianceResult {
13
13
  detectedVersions: string[];
14
14
  evidence: BillingEvidence[];
15
15
  policy: {
16
- minimumSupportedMajor: number;
16
+ minimumSupportedMajor: number | null;
17
17
  submissionDeadline: string;
18
18
  extensionDeadline: string;
19
19
  latestKnownMajor: number;
20
+ scheduleCurrent: boolean;
21
+ knownSchedule: Array<{
22
+ major: number;
23
+ submissionDeadline: string;
24
+ extensionDeadline: string;
25
+ }>;
20
26
  sourceUrl: string;
21
27
  };
22
28
  summary: string;
@@ -4,6 +4,13 @@ const BILLING_MODULE = /com\.android\.billingclient:billing(?:-ktx)?/;
4
4
  const LITERAL_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})/g;
5
5
  const VARIABLE_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:\$\{?([A-Za-z_][A-Za-z0-9_.-]*)\}?/g;
6
6
  const VERSION_ASSIGNMENT = /(?:^|\s)([A-Za-z_][A-Za-z0-9_.-]*)\s*(?:=|:)\s*["']([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})["']/gm;
7
+ const BILLING_SUPPORT_SCHEDULE = [
8
+ { major: 5, submissionDeadline: '2024-08-31', extensionDeadline: '2024-11-01' },
9
+ { major: 6, submissionDeadline: '2025-08-31', extensionDeadline: '2025-11-01' },
10
+ { major: 7, submissionDeadline: '2026-08-31', extensionDeadline: '2026-11-01' },
11
+ { major: 8, submissionDeadline: '2027-08-31', extensionDeadline: '2027-11-01' },
12
+ { major: 9, submissionDeadline: '2028-08-31', extensionDeadline: '2028-11-01' },
13
+ ];
7
14
  const SKIP_DIRS = new Set([
8
15
  '.git',
9
16
  '.gradle',
@@ -45,6 +52,7 @@ async function walk(root, maxDepth = 7) {
45
52
  function parseCatalog(text) {
46
53
  const versions = new Map();
47
54
  const libraries = new Map();
55
+ const bundles = new Map();
48
56
  let section = '';
49
57
  for (const rawLine of text.split(/\r?\n/)) {
50
58
  const line = rawLine.replace(/\s+#.*$/, '').trim();
@@ -61,18 +69,37 @@ function parseCatalog(text) {
61
69
  versions.set(match[1], match[2]);
62
70
  continue;
63
71
  }
72
+ if (section === 'bundles') {
73
+ const match = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*\[([^\]]*)]/);
74
+ if (match) {
75
+ bundles.set(match[1], [...match[2].matchAll(/["']([^"']+)["']/g)].map((item) => item[1]));
76
+ }
77
+ continue;
78
+ }
64
79
  if (section !== 'libraries')
65
80
  continue;
81
+ const shorthand = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*["']([^"']+)["']/);
82
+ if (shorthand) {
83
+ const coordinates = shorthand[2].split(':');
84
+ libraries.set(shorthand[1], {
85
+ module: coordinates.length >= 2 ? `${coordinates[0]}:${coordinates[1]}` : undefined,
86
+ version: coordinates.length >= 3 ? coordinates.slice(2).join(':') : undefined,
87
+ });
88
+ continue;
89
+ }
66
90
  const match = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*\{(.+)}\s*$/);
67
91
  if (!match)
68
92
  continue;
69
93
  const body = match[2];
70
- const module = body.match(/module\s*=\s*["']([^"']+)["']/)?.[1];
94
+ const explicitModule = body.match(/module\s*=\s*["']([^"']+)["']/)?.[1];
95
+ const group = body.match(/group\s*=\s*["']([^"']+)["']/)?.[1];
96
+ const name = body.match(/name\s*=\s*["']([^"']+)["']/)?.[1];
97
+ const module = explicitModule ?? (group && name ? `${group}:${name}` : undefined);
71
98
  const version = body.match(/(?:^|,)\s*version\s*=\s*["']([^"']+)["']/)?.[1];
72
99
  const versionRef = body.match(/version\.ref\s*=\s*["']([^"']+)["']/)?.[1];
73
100
  libraries.set(match[1], { module, version, versionRef });
74
101
  }
75
- return { versions, libraries };
102
+ return { versions, libraries, bundles };
76
103
  }
77
104
  function collectVariables(text) {
78
105
  const result = new Map();
@@ -88,51 +115,72 @@ function majorOf(version) {
88
115
  return Number.isFinite(major) ? major : null;
89
116
  }
90
117
  function policyAt(now) {
91
- const currentYear = now.getUTCFullYear();
92
- const deadlineThisYear = new Date(Date.UTC(currentYear, 7, 31, 23, 59, 59));
93
- const minimumSupportedMajor = now > deadlineThisYear ? currentYear - 2018 : currentYear - 2019;
94
- const unsupportedMajor = minimumSupportedMajor - 1;
118
+ const deadlineEnd = (date) => new Date(`${date}T23:59:59.999Z`);
119
+ const next = BILLING_SUPPORT_SCHEDULE.find((row) => now <= deadlineEnd(row.submissionDeadline));
120
+ const lastExpired = [...BILLING_SUPPORT_SCHEDULE]
121
+ .filter((row) => now > deadlineEnd(row.submissionDeadline))
122
+ .at(-1);
95
123
  return {
96
- minimumSupportedMajor,
97
- submissionDeadline: `${unsupportedMajor + 2019}-08-31`,
98
- extensionDeadline: `${unsupportedMajor + 2019}-11-01`,
99
- latestKnownMajor: Math.max(9, minimumSupportedMajor + 1),
124
+ minimumSupportedMajor: next?.major ?? null,
125
+ submissionDeadline: lastExpired?.submissionDeadline ?? BILLING_SUPPORT_SCHEDULE[0].submissionDeadline,
126
+ extensionDeadline: lastExpired?.extensionDeadline ?? BILLING_SUPPORT_SCHEDULE[0].extensionDeadline,
127
+ latestKnownMajor: BILLING_SUPPORT_SCHEDULE.at(-1).major,
128
+ scheduleCurrent: Boolean(next),
129
+ knownSchedule: BILLING_SUPPORT_SCHEDULE.map((row) => ({ ...row })),
100
130
  sourceUrl: 'https://developer.android.com/google/play/billing/deprecation-faq',
101
131
  };
102
132
  }
133
+ function scheduleForMajor(major) {
134
+ return BILLING_SUPPORT_SCHEDULE.find((row) => row.major === major);
135
+ }
136
+ function catalogScope(file) {
137
+ const parent = path.dirname(file);
138
+ return path.basename(parent) === 'gradle' ? path.dirname(parent) : parent;
139
+ }
140
+ function isWithin(scope, file) {
141
+ const relative = path.relative(scope, file);
142
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
143
+ }
103
144
  export async function checkBillingCompliance(projectPath, now = new Date()) {
104
145
  const root = path.resolve(projectPath);
146
+ let rootStat;
147
+ try {
148
+ rootStat = await fs.stat(root);
149
+ }
150
+ catch {
151
+ throw new Error(`Android project path does not exist or is not readable: ${root}`);
152
+ }
153
+ if (!rootStat.isDirectory())
154
+ throw new Error(`Android project path is not a directory: ${root}`);
105
155
  const files = await walk(root);
106
156
  const texts = new Map();
107
157
  for (const file of files)
108
158
  texts.set(file, await fs.readFile(file, 'utf8'));
109
- const catalogFile = files.find((file) => path.basename(file) === 'libs.versions.toml');
110
- const catalog = catalogFile ? parseCatalog(texts.get(catalogFile) ?? '') : { versions: new Map(), libraries: new Map() };
111
- const allVariables = new Map();
159
+ const catalogs = files
160
+ .filter((file) => path.basename(file) === 'libs.versions.toml')
161
+ .map((file) => ({ file, scope: catalogScope(file), info: parseCatalog(texts.get(file) ?? '') }));
162
+ const variablesByFile = new Map();
112
163
  for (const [file, text] of texts) {
113
164
  if (path.basename(file) === 'libs.versions.toml')
114
165
  continue;
115
- for (const [key, value] of collectVariables(text))
116
- allVariables.set(key, value);
166
+ variablesByFile.set(file, collectVariables(text));
117
167
  }
168
+ const catalogFor = (file) => catalogs
169
+ .filter((entry) => isWithin(entry.scope, file))
170
+ .sort((left, right) => right.scope.length - left.scope.length)[0]?.info
171
+ ?? (catalogs.length === 1 ? catalogs[0].info : undefined);
172
+ const variableFor = (file, key) => [...variablesByFile.entries()]
173
+ .filter(([candidate]) => candidate === file || isWithin(path.dirname(candidate), file))
174
+ .sort(([left], [right]) => path.dirname(right).length - path.dirname(left).length)
175
+ .map(([, variables]) => variables.get(key))
176
+ .find((value) => value !== undefined);
118
177
  const evidence = [];
119
178
  for (const [file, text] of texts) {
120
179
  const relative = path.relative(root, file).replace(/\\/g, '/');
121
180
  if (path.basename(file) === 'libs.versions.toml') {
122
- for (const [alias, lib] of catalog.libraries) {
123
- if (!lib.module || !BILLING_MODULE.test(lib.module))
124
- continue;
125
- const version = lib.version ?? (lib.versionRef ? catalog.versions.get(lib.versionRef) : undefined);
126
- evidence.push({
127
- file: relative,
128
- module: lib.module,
129
- version,
130
- expression: `libs.${alias.replace(/-/g, '.')}`,
131
- source: version ? 'version_catalog' : 'unresolved',
132
- });
133
- }
134
181
  continue;
135
182
  }
183
+ const catalog = catalogFor(file);
136
184
  for (const match of text.matchAll(LITERAL_DEPENDENCY)) {
137
185
  evidence.push({
138
186
  file: relative,
@@ -142,7 +190,7 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
142
190
  });
143
191
  }
144
192
  for (const match of text.matchAll(VARIABLE_DEPENDENCY)) {
145
- const version = allVariables.get(match[1]);
193
+ const version = variableFor(file, match[1]);
146
194
  evidence.push({
147
195
  file: relative,
148
196
  module: match[0].slice(0, match[0].lastIndexOf(':')),
@@ -151,12 +199,34 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
151
199
  source: version ? 'variable' : 'unresolved',
152
200
  });
153
201
  }
202
+ for (const bundleMatch of text.matchAll(/\blibs\.bundles\.([A-Za-z0-9_.-]+)/g)) {
203
+ const bundleAlias = normalizeAlias(bundleMatch[1]);
204
+ const libraryAliases = catalog?.bundles.get(bundleAlias) ?? catalog?.bundles.get(bundleMatch[1]) ?? [];
205
+ for (const libraryAlias of libraryAliases) {
206
+ const normalizedLibraryAlias = normalizeAlias(libraryAlias);
207
+ const lib = catalog?.libraries.get(normalizedLibraryAlias) ?? catalog?.libraries.get(libraryAlias);
208
+ if (!lib?.module || !BILLING_MODULE.test(lib.module))
209
+ continue;
210
+ const version = lib.version ?? (lib.versionRef ? catalog?.versions.get(lib.versionRef) : undefined);
211
+ if (!evidence.some((row) => row.file === relative && row.expression === bundleMatch[0] && row.module === lib.module)) {
212
+ evidence.push({
213
+ file: relative,
214
+ module: lib.module,
215
+ version,
216
+ expression: bundleMatch[0],
217
+ source: version ? 'version_catalog' : 'unresolved',
218
+ });
219
+ }
220
+ }
221
+ }
154
222
  for (const aliasMatch of text.matchAll(/\blibs\.([A-Za-z0-9_.-]+)/g)) {
223
+ if (aliasMatch[1].startsWith('bundles.'))
224
+ continue;
155
225
  const alias = normalizeAlias(aliasMatch[1]);
156
- const lib = catalog.libraries.get(alias) ?? catalog.libraries.get(aliasMatch[1]);
226
+ const lib = catalog?.libraries.get(alias) ?? catalog?.libraries.get(aliasMatch[1]);
157
227
  if (!lib?.module || !BILLING_MODULE.test(lib.module))
158
228
  continue;
159
- const version = lib.version ?? (lib.versionRef ? catalog.versions.get(lib.versionRef) : undefined);
229
+ const version = lib.version ?? (lib.versionRef ? catalog?.versions.get(lib.versionRef) : undefined);
160
230
  if (!evidence.some((row) => row.file === relative && row.expression === aliasMatch[0])) {
161
231
  evidence.push({
162
232
  file: relative,
@@ -187,11 +257,21 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
187
257
  status = 'not_used';
188
258
  summary = 'Google Play Billing dependency was not found in the scanned Gradle project.';
189
259
  }
260
+ else if (!policy.scheduleCurrent || policy.minimumSupportedMajor === null) {
261
+ status = 'unresolved';
262
+ summary = `The official schedule embedded in this release ends at Billing Library ${policy.latestKnownMajor}; current policy must be refreshed from the source.`;
263
+ actions.push('Check the official Billing deprecation table and update Mimi Seed before relying on this result.');
264
+ }
190
265
  else if (majors.some((major) => major < policy.minimumSupportedMajor)) {
191
266
  status = 'blocker';
192
267
  summary = `Billing Library ${detectedVersions.join(', ')} is below the submission minimum major ${policy.minimumSupportedMajor}.`;
193
268
  actions.push(`Upgrade to a supported Billing Library before submitting a new app or update.`);
194
- actions.push(`If Google granted an extension, verify it in Play Console; the listed extension deadline is ${policy.extensionDeadline}.`);
269
+ for (const major of [...new Set(majors.filter((value) => value < policy.minimumSupportedMajor))].sort()) {
270
+ const schedule = scheduleForMajor(major);
271
+ actions.push(schedule
272
+ ? `Billing Library ${major}: standard deadline ${schedule.submissionDeadline}; extension deadline ${schedule.extensionDeadline} only if Google granted it in Play Console.`
273
+ : `Billing Library ${major}: its deadline predates the embedded official table; no active extension should be assumed.`);
274
+ }
195
275
  }
196
276
  else if (unresolved || majors.length === 0) {
197
277
  status = 'unresolved';
@@ -201,7 +281,9 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
201
281
  else if (majors.some((major) => major === policy.minimumSupportedMajor)) {
202
282
  status = 'warning';
203
283
  summary = `Billing Library ${detectedVersions.join(', ')} is currently supported but is the next major scheduled for deprecation.`;
204
- actions.push(`Plan an upgrade before ${policy.minimumSupportedMajor + 2019}-08-31.`);
284
+ const nextDeadline = scheduleForMajor(policy.minimumSupportedMajor);
285
+ if (nextDeadline)
286
+ actions.push(`Plan an upgrade before ${nextDeadline.submissionDeadline}.`);
205
287
  }
206
288
  else {
207
289
  status = 'pass';
@@ -1,5 +1,6 @@
1
1
  import { publisher, withEdit } from '../playstore/tools.js';
2
2
  import { apiGet } from '../appstore/tools.js';
3
+ import { isNotFound } from '../appstore/http.js';
3
4
  const APPSTORE_EDITABLE_STATES = 'PREPARE_FOR_SUBMISSION,DEVELOPER_REJECTED,METADATA_REJECTED,REJECTED';
4
5
  export async function checkPlayStoreRisks(auth, packageName, language = 'ko-KR') {
5
6
  const risks = [];
@@ -145,9 +146,19 @@ export async function checkAppStoreRisks(appId, expected = {}) {
145
146
  'fields[appInfoLocalizations]': 'locale,privacyPolicyUrl,privacyPolicyText',
146
147
  'limit': '200',
147
148
  }), risks, 'PRIVACY', '개인정보 로컬라이제이션'),
148
- safeGet(() => apiGet(`/appInfos/${editable.id}/ageRatingDeclaration`, {
149
- 'fields[ageRatingDeclarations]': 'userGeneratedContent,socialMedia,socialMediaAgeRestricted',
150
- }), risks, 'AGE_RATING_SOCIAL', '연령등급 소셜 미디어 응답'),
149
+ safeGet(async () => {
150
+ try {
151
+ return await apiGet(`/appInfos/${editable.id}/ageRatingDeclaration`, {
152
+ 'fields[ageRatingDeclarations]': 'userGeneratedContent,socialMedia,socialMediaAgeRestricted,ageAssurance',
153
+ });
154
+ }
155
+ catch (error) {
156
+ // A 404 means the declaration is genuinely absent, not that the check is unknown.
157
+ if (isNotFound(error))
158
+ return { data: null };
159
+ throw error;
160
+ }
161
+ }, risks, 'AGE_RATING_SOCIAL', '연령등급 소셜 미디어 응답'),
151
162
  ]);
152
163
  if (localizations) {
153
164
  const locs = (localizations.data ?? []);
@@ -175,7 +186,18 @@ export async function checkAppStoreRisks(appId, expected = {}) {
175
186
  detail: 'socialMediaAgeRestricted=true는 socialMedia=true인 앱에서만 의미가 있습니다.',
176
187
  });
177
188
  }
178
- if (expected.socialMedia !== undefined && declaration.socialMedia !== expected.socialMedia) {
189
+ if (declaration.socialMediaAgeRestricted === true && declaration.ageAssurance !== true) {
190
+ risks.push({
191
+ level: 'blocker',
192
+ code: 'SOCIAL_MEDIA_AGE_ASSURANCE_MISSING',
193
+ title: '13세 미만 소셜 기능 제한에 연령 확인이 없음',
194
+ detail: 'Apple은 socialMediaAgeRestricted=true인 경우 최소한 Declared Age Range API로 연령 범위를 확인하도록 요구합니다. ageAssurance 응답과 실제 구현을 확인하세요.',
195
+ fixUrl: 'https://developer.apple.com/help/app-store-connect/reference/app-information/age-ratings-values-and-definitions',
196
+ });
197
+ }
198
+ if (expected.socialMedia !== undefined
199
+ && declaration.socialMedia !== undefined
200
+ && declaration.socialMedia !== expected.socialMedia) {
179
201
  risks.push({
180
202
  level: 'blocker',
181
203
  code: 'SOCIAL_MEDIA_CAPABILITY_MISMATCH',
@@ -184,6 +206,7 @@ export async function checkAppStoreRisks(appId, expected = {}) {
184
206
  });
185
207
  }
186
208
  if (expected.socialMediaAgeRestricted !== undefined
209
+ && declaration.socialMediaAgeRestricted !== undefined
187
210
  && declaration.socialMediaAgeRestricted !== expected.socialMediaAgeRestricted) {
188
211
  risks.push({
189
212
  level: 'blocker',
@@ -18,26 +18,14 @@ interface RemoteConfigTemplate {
18
18
  updateType?: string;
19
19
  };
20
20
  }
21
- interface ExperimentList {
22
- experiments?: Array<{
23
- name?: string;
24
- state?: string;
25
- startTime?: string;
26
- endTime?: string;
27
- lastUpdateTime?: string;
28
- definition?: unknown;
29
- }>;
30
- }
31
- interface RolloutList {
32
- rollouts?: Array<{
33
- name?: string;
34
- state?: string;
35
- startTime?: string;
36
- endTime?: string;
37
- lastUpdateTime?: string;
38
- definition?: unknown;
39
- }>;
21
+ interface PagedItems<T> {
22
+ items: T[];
23
+ pages: number;
24
+ truncated: boolean;
40
25
  }
26
+ export declare function countStates(items: Array<{
27
+ state?: string;
28
+ }>): Record<string, number>;
41
29
  export declare function estimateRemoteConfigDailyCost(fetches: number): number;
42
30
  export declare function remoteConfigUsageLevel(fetches: number): 'ok' | 'warning' | 'critical';
43
31
  export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
@@ -87,6 +75,13 @@ export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
87
75
  date: string;
88
76
  fetches: number;
89
77
  } | null;
78
+ peak: {
79
+ utilizationPercent: number;
80
+ level: "ok" | "warning" | "critical";
81
+ projectedStandardDailyCostUsd: number;
82
+ date: string;
83
+ fetches: number;
84
+ } | null;
90
85
  };
91
86
  template: OptionalResult<RemoteConfigTemplate> | {
92
87
  available: boolean;
@@ -103,29 +98,65 @@ export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
103
98
  parameterGroupCount: number;
104
99
  conditionCount: number;
105
100
  };
106
- experiments: OptionalResult<ExperimentList> | {
101
+ experiments: OptionalResult<PagedItems<{
102
+ name?: string;
103
+ state?: string;
104
+ startTime?: string;
105
+ endTime?: string;
106
+ lastUpdateTime?: string;
107
+ definition?: unknown;
108
+ }>> | {
107
109
  available: boolean;
108
110
  total: number;
111
+ stateCounts: Record<string, number>;
109
112
  active: {
110
- name?: string;
111
- state?: string;
112
- startTime?: string;
113
- endTime?: string;
114
- lastUpdateTime?: string;
115
- definition?: unknown;
113
+ name: string | undefined;
114
+ displayName: string | undefined;
115
+ state: string | undefined;
116
+ startTime: string | undefined;
117
+ endTime: string | undefined;
118
+ lastUpdateTime: string | undefined;
116
119
  }[];
120
+ recent: {
121
+ name: string | undefined;
122
+ displayName: string | undefined;
123
+ state: string | undefined;
124
+ startTime: string | undefined;
125
+ endTime: string | undefined;
126
+ lastUpdateTime: string | undefined;
127
+ }[];
128
+ pages: number;
129
+ truncated: boolean;
117
130
  };
118
- rollouts: OptionalResult<RolloutList> | {
131
+ rollouts: OptionalResult<PagedItems<{
132
+ name?: string;
133
+ state?: string;
134
+ startTime?: string;
135
+ endTime?: string;
136
+ lastUpdateTime?: string;
137
+ definition?: unknown;
138
+ }>> | {
119
139
  available: boolean;
120
140
  total: number;
141
+ stateCounts: Record<string, number>;
121
142
  active: {
122
- name?: string;
123
- state?: string;
124
- startTime?: string;
125
- endTime?: string;
126
- lastUpdateTime?: string;
127
- definition?: unknown;
143
+ name: string | undefined;
144
+ displayName: string | undefined;
145
+ state: string | undefined;
146
+ startTime: string | undefined;
147
+ endTime: string | undefined;
148
+ lastUpdateTime: string | undefined;
149
+ }[];
150
+ recent: {
151
+ name: string | undefined;
152
+ displayName: string | undefined;
153
+ state: string | undefined;
154
+ startTime: string | undefined;
155
+ endTime: string | undefined;
156
+ lastUpdateTime: string | undefined;
128
157
  }[];
158
+ pages: number;
159
+ truncated: boolean;
129
160
  };
130
161
  warnings: string[];
131
162
  }>;
@@ -3,6 +3,14 @@ import { getBillingInfo } from '../billing/tools.js';
3
3
  const FREE_DAILY_FETCHES = 100_000;
4
4
  const FIRST_PAID_TIER_END = 10_000_000;
5
5
  const REMOTE_CONFIG_BASE = 'https://firebaseremoteconfig.googleapis.com/v1';
6
+ export function countStates(items) {
7
+ const counts = {};
8
+ for (const item of items) {
9
+ const state = item.state ?? 'UNSPECIFIED';
10
+ counts[state] = (counts[state] ?? 0) + 1;
11
+ }
12
+ return counts;
13
+ }
6
14
  async function optionalRequest(auth, url) {
7
15
  try {
8
16
  const response = await auth.request({ url });
@@ -15,6 +23,31 @@ async function optionalRequest(auth, url) {
15
23
  };
16
24
  }
17
25
  }
26
+ async function optionalPagedRequest(auth, url, field) {
27
+ try {
28
+ const items = [];
29
+ let pageToken;
30
+ let pages = 0;
31
+ do {
32
+ const requestUrl = new URL(url);
33
+ requestUrl.searchParams.set('pageSize', '100');
34
+ if (pageToken)
35
+ requestUrl.searchParams.set('pageToken', pageToken);
36
+ const response = await auth.request({ url: requestUrl.toString() });
37
+ const data = response.data;
38
+ items.push(...(data[field] ?? []));
39
+ pageToken = data.nextPageToken;
40
+ pages += 1;
41
+ } while (pageToken && pages < 10);
42
+ return { available: true, data: { items, pages, truncated: Boolean(pageToken) } };
43
+ }
44
+ catch (error) {
45
+ return {
46
+ available: false,
47
+ error: error instanceof Error ? error.message : String(error),
48
+ };
49
+ }
50
+ }
18
51
  function numericPoint(point) {
19
52
  const raw = point.value?.int64Value ?? point.value?.doubleValue ?? 0;
20
53
  const value = typeof raw === 'string' ? Number.parseInt(raw, 10) : raw;
@@ -52,12 +85,17 @@ async function fetchDailyUsage(auth, projectId, days) {
52
85
  view: 'FULL',
53
86
  pageSize: Math.max(days + 2, 10),
54
87
  });
55
- const rows = (response.data.timeSeries ?? [])
88
+ const points = (response.data.timeSeries ?? [])
56
89
  .flatMap((series) => series.points ?? [])
57
90
  .map((point) => ({
58
91
  date: (point.interval?.endTime ?? end.toISOString()).slice(0, 10),
59
92
  fetches: numericPoint(point),
60
- }))
93
+ }));
94
+ const byDate = new Map();
95
+ for (const point of points)
96
+ byDate.set(point.date, (byDate.get(point.date) ?? 0) + point.fetches);
97
+ const rows = [...byDate.entries()]
98
+ .map(([date, fetches]) => ({ date, fetches }))
61
99
  .sort((a, b) => a.date.localeCompare(b.date));
62
100
  return { available: true, data: rows };
63
101
  }
@@ -76,8 +114,8 @@ export async function getRemoteConfigOverview(auth, input) {
76
114
  const parent = `${REMOTE_CONFIG_BASE}/projects/${encodedProject}/namespaces/${encodedNamespace}`;
77
115
  const [template, experiments, rollouts, usage, billing] = await Promise.all([
78
116
  optionalRequest(auth, `${parent}/remoteConfig`),
79
- optionalRequest(auth, `${parent}/experiments`),
80
- optionalRequest(auth, `${parent}/rollouts`),
117
+ optionalPagedRequest(auth, `${parent}/experiments`, 'experiments'),
118
+ optionalPagedRequest(auth, `${parent}/rollouts`, 'rollouts'),
81
119
  fetchDailyUsage(auth, input.projectId, days),
82
120
  getBillingInfo(auth, input.projectId)
83
121
  .then((data) => ({ available: true, data }))
@@ -88,8 +126,45 @@ export async function getRemoteConfigOverview(auth, input) {
88
126
  ]);
89
127
  const daily = usage.data ?? [];
90
128
  const latest = daily.at(-1);
91
- const activeExperiments = experiments.data?.experiments?.filter((item) => ['RUNNING', 'ACTIVE', 'STARTED'].includes(item.state ?? '')) ?? [];
92
- const activeRollouts = rollouts.data?.rollouts?.filter((item) => ['RUNNING', 'ACTIVE', 'STARTED', 'IN_PROGRESS'].includes(item.state ?? '')) ?? [];
129
+ const peak = daily.reduce((current, point) => !current || point.fetches > current.fetches ? point : current, undefined);
130
+ const withUsageProjection = (point) => ({
131
+ ...point,
132
+ utilizationPercent: Number(((point.fetches / FREE_DAILY_FETCHES) * 100).toFixed(2)),
133
+ level: remoteConfigUsageLevel(point.fetches),
134
+ projectedStandardDailyCostUsd: Number(estimateRemoteConfigDailyCost(point.fetches).toFixed(4)),
135
+ });
136
+ const summarizeExperiment = (item) => ({
137
+ name: item.name,
138
+ displayName: item.definition?.displayName,
139
+ state: item.state,
140
+ startTime: item.startTime,
141
+ endTime: item.endTime,
142
+ lastUpdateTime: item.lastUpdateTime,
143
+ });
144
+ const summarizeRollout = (item) => ({
145
+ name: item.name,
146
+ displayName: item.definition?.displayName,
147
+ state: item.state,
148
+ startTime: item.startTime,
149
+ endTime: item.endTime,
150
+ lastUpdateTime: item.lastUpdateTime,
151
+ });
152
+ const activeExperiments = experiments.data?.items
153
+ .filter((item) => item.state === 'RUNNING')
154
+ .map(summarizeExperiment) ?? [];
155
+ const activeRollouts = rollouts.data?.items
156
+ .filter((item) => item.state === 'RUNNING')
157
+ .map(summarizeRollout) ?? [];
158
+ const recentExperiments = [...(experiments.data?.items ?? [])]
159
+ .sort((left, right) => (right.lastUpdateTime ?? right.endTime ?? right.startTime ?? '')
160
+ .localeCompare(left.lastUpdateTime ?? left.endTime ?? left.startTime ?? ''))
161
+ .slice(0, 20)
162
+ .map(summarizeExperiment);
163
+ const recentRollouts = [...(rollouts.data?.items ?? [])]
164
+ .sort((left, right) => (right.lastUpdateTime ?? right.endTime ?? right.startTime ?? '')
165
+ .localeCompare(left.lastUpdateTime ?? left.endTime ?? left.startTime ?? ''))
166
+ .slice(0, 20)
167
+ .map(summarizeRollout);
93
168
  return {
94
169
  projectId: input.projectId,
95
170
  namespace,
@@ -113,14 +188,8 @@ export async function getRemoteConfigOverview(auth, input) {
113
188
  available: true,
114
189
  metric: 'firebaseremoteconfig.googleapis.com/project/fetch_request_count',
115
190
  days: daily,
116
- latest: latest
117
- ? {
118
- ...latest,
119
- utilizationPercent: Number(((latest.fetches / FREE_DAILY_FETCHES) * 100).toFixed(2)),
120
- level: remoteConfigUsageLevel(latest.fetches),
121
- projectedStandardDailyCostUsd: Number(estimateRemoteConfigDailyCost(latest.fetches).toFixed(4)),
122
- }
123
- : null,
191
+ latest: latest ? withUsageProjection(latest) : null,
192
+ peak: peak ? withUsageProjection(peak) : null,
124
193
  }
125
194
  : usage,
126
195
  template: template.available
@@ -135,26 +204,36 @@ export async function getRemoteConfigOverview(auth, input) {
135
204
  experiments: experiments.available
136
205
  ? {
137
206
  available: true,
138
- total: experiments.data?.experiments?.length ?? 0,
207
+ total: experiments.data?.items.length ?? 0,
208
+ stateCounts: countStates(experiments.data?.items ?? []),
139
209
  active: activeExperiments,
210
+ recent: recentExperiments,
211
+ pages: experiments.data?.pages ?? 0,
212
+ truncated: experiments.data?.truncated ?? false,
140
213
  }
141
214
  : experiments,
142
215
  rollouts: rollouts.available
143
216
  ? {
144
217
  available: true,
145
- total: rollouts.data?.rollouts?.length ?? 0,
218
+ total: rollouts.data?.items.length ?? 0,
219
+ stateCounts: countStates(rollouts.data?.items ?? []),
146
220
  active: activeRollouts,
221
+ recent: recentRollouts,
222
+ pages: rollouts.data?.pages ?? 0,
223
+ truncated: rollouts.data?.truncated ?? false,
147
224
  }
148
225
  : rollouts,
149
226
  warnings: [
150
- ...(latest && remoteConfigUsageLevel(latest.fetches) !== 'ok'
151
- ? [`Daily fetch usage is ${remoteConfigUsageLevel(latest.fetches)} at ${latest.fetches.toLocaleString()} requests.`]
227
+ ...(peak && remoteConfigUsageLevel(peak.fetches) !== 'ok'
228
+ ? [`Peak daily fetch usage is ${remoteConfigUsageLevel(peak.fetches)} at ${peak.fetches.toLocaleString()} requests on ${peak.date}.`]
152
229
  : []),
153
- ...(latest && latest.fetches >= FREE_DAILY_FETCHES && billing.available && !billing.data.billingEnabled
230
+ ...(peak && peak.fetches >= FREE_DAILY_FETCHES && billing.available && !billing.data.billingEnabled
154
231
  ? ['Spark projects risk throttling above 100,000 daily fetches after the applicable grace period; upgrade to Blaze for uninterrupted overage.']
155
232
  : []),
156
233
  ...(!usage.available ? ['Cloud Monitoring usage is unavailable; no fetch count was estimated.'] : []),
157
234
  ...(!billing.available ? ['Billing plan could not be verified; cost/throttling interpretation is incomplete.'] : []),
235
+ ...(experiments.data?.truncated ? ['Experiment list exceeded 1,000 items and was truncated.'] : []),
236
+ ...(rollouts.data?.truncated ? ['Rollout list exceeded 1,000 items and was truncated.'] : []),
158
237
  ],
159
238
  };
160
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
4
4
  "description": "Mimi Seed MCP server \u2014 Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {