@yoonion/mimi-seed-mcp 0.21.2 → 0.21.4

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 (46) hide show
  1. package/dist/ci/config.js +2 -2
  2. package/dist/core/checks/billing.d.ts +34 -0
  3. package/dist/core/checks/billing.js +273 -52
  4. package/dist/core/checks/gradle-text.d.ts +28 -0
  5. package/dist/core/checks/gradle-text.js +212 -0
  6. package/dist/core/checks/lockfile.d.ts +21 -0
  7. package/dist/core/checks/lockfile.js +185 -0
  8. package/dist/core/checks/release-doctor-render.js +18 -7
  9. package/dist/core/checks/release-doctor.d.ts +45 -0
  10. package/dist/core/checks/release-doctor.js +2470 -261
  11. package/dist/core/lang.d.ts +7 -1
  12. package/dist/core/lang.js +29 -5
  13. package/dist/googleads/config.d.ts +5 -2
  14. package/dist/googleads/config.js +22 -9
  15. package/dist/googleads/errors.js +25 -17
  16. package/dist/googleads/setup-cli.js +1 -1
  17. package/dist/googleads/tools.d.ts +8 -1
  18. package/dist/googleads/tools.js +19 -1
  19. package/dist/jenkins/config.js +1 -1
  20. package/dist/jenkins/credentials.js +2 -1
  21. package/dist/playstore/products.d.ts +1 -1
  22. package/dist/playstore/products.js +1 -1
  23. package/dist/registers/appstore/apps.d.ts +4 -0
  24. package/dist/registers/appstore/apps.js +43 -0
  25. package/dist/registers/appstore/customer-reviews.d.ts +4 -0
  26. package/dist/registers/appstore/customer-reviews.js +31 -0
  27. package/dist/registers/appstore/declarations.d.ts +4 -0
  28. package/dist/registers/appstore/declarations.js +163 -0
  29. package/dist/registers/appstore/media.d.ts +6 -0
  30. package/dist/registers/appstore/media.js +116 -0
  31. package/dist/registers/appstore/metadata.d.ts +11 -0
  32. package/dist/registers/appstore/metadata.js +120 -0
  33. package/dist/registers/appstore/products.d.ts +6 -0
  34. package/dist/registers/appstore/products.js +297 -0
  35. package/dist/registers/appstore/reports.d.ts +6 -0
  36. package/dist/registers/appstore/reports.js +77 -0
  37. package/dist/registers/appstore/review-submission.d.ts +6 -0
  38. package/dist/registers/appstore/review-submission.js +113 -0
  39. package/dist/registers/appstore/testflight.d.ts +6 -0
  40. package/dist/registers/appstore/testflight.js +193 -0
  41. package/dist/registers/appstore/versions.d.ts +8 -0
  42. package/dist/registers/appstore/versions.js +205 -0
  43. package/dist/registers/appstore.d.ts +8 -0
  44. package/dist/registers/appstore.js +37 -1274
  45. package/dist/registers/ci.js +2 -2
  46. package/package.json +1 -1
@@ -0,0 +1,212 @@
1
+ // Gradle build-script text helpers shared by the Release Doctor checks.
2
+ /** Characters after which a Groovy `/` starts a slashy string rather than a division (`~ = ( , :` and `?:`). */
3
+ const SLASHY_CONTEXT = new Set(['~', '=', '(', ',', ':', '?']);
4
+ /**
5
+ * Scans Gradle (Groovy or Kotlin DSL) source and reports each token span to `visit`: `code` (outside strings and
6
+ * comments), `string` (any string literal, copied verbatim), or `comment`. String-aware: `'…'`, `"…"`, `'''…'''`,
7
+ * `"""…"""` (escapes, `${…}` templates with nested strings), Groovy slashy `/…/` (after `~ = ( , :` or `?:`, as in
8
+ * `exclude group: /a{/`) and dollar-slashy `$/…/$` strings. Degrades safely: an unterminated single-line string or
9
+ * template ends at the line break, and a `/*` with no closing `*\/` is text, not a comment that deletes the rest of
10
+ * the file.
11
+ */
12
+ function scanGradle(text, visit) {
13
+ const length = text.length;
14
+ /** Index after the string literal starting at `start` (a quote). */
15
+ function stringEnd(start) {
16
+ const quote = text[start];
17
+ const triple = text.startsWith(quote.repeat(3), start);
18
+ const delimiter = triple ? quote.repeat(3) : quote;
19
+ let cursor = start + delimiter.length;
20
+ while (cursor < length) {
21
+ const char = text[cursor];
22
+ if (char === '\\' && !triple) {
23
+ cursor += 2;
24
+ continue;
25
+ }
26
+ if (text.startsWith(delimiter, cursor))
27
+ return cursor + delimiter.length;
28
+ if (quote === '"' && char === '$' && text[cursor + 1] === '{') {
29
+ cursor = templateEnd(cursor + 2, !triple);
30
+ continue;
31
+ }
32
+ if (!triple && char === '\n')
33
+ return cursor;
34
+ cursor++;
35
+ }
36
+ return cursor;
37
+ }
38
+ /** Index after the `}` closing a template expression starting at `start` (or at the line break, single-line). */
39
+ function templateEnd(start, singleLine) {
40
+ let depth = 1;
41
+ let cursor = start;
42
+ while (cursor < length && depth > 0) {
43
+ const char = text[cursor];
44
+ if (singleLine && char === '\n')
45
+ return cursor;
46
+ if (char === '"' || char === "'") {
47
+ cursor = stringEnd(cursor);
48
+ continue;
49
+ }
50
+ if (char === '{')
51
+ depth++;
52
+ else if (char === '}')
53
+ depth--;
54
+ cursor++;
55
+ }
56
+ return cursor;
57
+ }
58
+ let index = 0;
59
+ let codeStart = 0;
60
+ let lastSignificant = '';
61
+ const flushCode = (end) => {
62
+ if (end > codeStart)
63
+ visit('code', codeStart, end);
64
+ };
65
+ while (index < length) {
66
+ const char = text[index];
67
+ const next = text[index + 1];
68
+ let tokenEnd = -1;
69
+ let kind = 'string';
70
+ if (char === '/' && next === '/') {
71
+ tokenEnd = text.indexOf('\n', index);
72
+ if (tokenEnd < 0)
73
+ tokenEnd = length;
74
+ kind = 'comment';
75
+ }
76
+ else if (char === '/' && next === '*') {
77
+ const close = text.indexOf('*/', index + 2);
78
+ if (close >= 0) {
79
+ tokenEnd = close + 2;
80
+ kind = 'comment';
81
+ }
82
+ }
83
+ else if (char === '"' || char === "'") {
84
+ tokenEnd = stringEnd(index);
85
+ }
86
+ else if (char === '$' && next === '/') {
87
+ const close = text.indexOf('/$', index + 2);
88
+ if (close >= 0)
89
+ tokenEnd = close + 2;
90
+ }
91
+ else if (char === '/' && SLASHY_CONTEXT.has(lastSignificant)) {
92
+ let cursor = index + 1;
93
+ while (cursor < length && text[cursor] !== '/')
94
+ cursor += text[cursor] === '\\' ? 2 : 1;
95
+ if (cursor < length)
96
+ tokenEnd = cursor + 1;
97
+ }
98
+ if (tokenEnd < 0) {
99
+ if (!/\s/.test(char))
100
+ lastSignificant = char;
101
+ index++;
102
+ continue;
103
+ }
104
+ flushCode(index);
105
+ visit(kind, index, tokenEnd);
106
+ if (kind === 'string')
107
+ lastSignificant = '"';
108
+ index = tokenEnd;
109
+ codeStart = tokenEnd;
110
+ }
111
+ flushCode(length);
112
+ }
113
+ /**
114
+ * Gradle source with comments removed and strings kept verbatim, so `/*` in a glob such as `pickFirst '**\/*.so'`
115
+ * or `//` in a URL is text, not a comment. Block comments become the newlines they spanned, so line-based matching
116
+ * keeps working.
117
+ */
118
+ export function stripGradleComments(text) {
119
+ let out = '';
120
+ scanGradle(text, (kind, start, end) => {
121
+ const slice = text.slice(start, end);
122
+ out += kind === 'comment' ? slice.replace(/[^\n]/g, '') : slice;
123
+ });
124
+ return out;
125
+ }
126
+ /** Gradle source with comments blanked to spaces (same length and line breaks, so indexes still line up). */
127
+ export function blankComments(text) {
128
+ let out = '';
129
+ scanGradle(text, (kind, start, end) => {
130
+ const slice = text.slice(start, end);
131
+ out += kind === 'comment' ? slice.replace(/[^\n]/g, ' ') : slice;
132
+ });
133
+ return out;
134
+ }
135
+ /**
136
+ * Gradle source with comments and string contents blanked (same length, line breaks kept): only code is left to
137
+ * match tokens against, so `"targetSdk 33"` in a string or a comment is never read as an assignment. Each string
138
+ * literal keeps its first and last character so `x = "…"` still shows a value is there.
139
+ */
140
+ export function maskStrings(text) {
141
+ let out = '';
142
+ scanGradle(text, (kind, start, end) => {
143
+ const slice = text.slice(start, end);
144
+ if (kind === 'code')
145
+ out += slice;
146
+ else if (kind === 'comment' || slice.length < 2)
147
+ out += slice.replace(/[^\n]/g, ' ');
148
+ else
149
+ out += slice[0] + slice.slice(1, -1).replace(/[^\n]/g, ' ') + slice.at(-1);
150
+ });
151
+ return out;
152
+ }
153
+ /**
154
+ * Like `maskStrings`, but string literals for which `keep(literal)` is true stay verbatim (`literal` includes its
155
+ * quotes). Comments are blanked. Same length and line breaks as `text`.
156
+ */
157
+ export function keepStrings(text, keep) {
158
+ let out = '';
159
+ scanGradle(text, (kind, start, end) => {
160
+ const slice = text.slice(start, end);
161
+ if (kind === 'code' || (kind === 'string' && keep(slice)))
162
+ out += slice;
163
+ else if (kind === 'comment' || slice.length < 2)
164
+ out += slice.replace(/[^\n]/g, ' ');
165
+ else
166
+ out += slice[0] + slice.slice(1, -1).replace(/[^\n]/g, ' ') + slice.at(-1);
167
+ });
168
+ return out;
169
+ }
170
+ /**
171
+ * Index of the `}` that closes the `{` at `open`, counting braces only in code (not inside strings or comments);
172
+ * -1 when it is never closed.
173
+ */
174
+ export function closingBrace(text, open) {
175
+ let depth = 0;
176
+ let result = -1;
177
+ scanGradle(text.slice(open), (kind, start, end) => {
178
+ if (kind !== 'code' || result >= 0)
179
+ return;
180
+ for (let index = start; index < end; index++) {
181
+ const char = text[open + index];
182
+ if (char === '{')
183
+ depth++;
184
+ else if (char === '}' && --depth === 0) {
185
+ result = open + index;
186
+ return;
187
+ }
188
+ }
189
+ });
190
+ return result;
191
+ }
192
+ /** `text` without every block opened by `opener` (a regex ending at `{`), braces matched with `closingBrace`. */
193
+ export function removeBlocks(text, opener) {
194
+ let result = text;
195
+ for (;;) {
196
+ const start = new RegExp(opener.source).exec(result);
197
+ if (!start)
198
+ return result;
199
+ const close = closingBrace(result, start.index + start[0].length - 1);
200
+ // An unclosed block (malformed or truncated script) loses only its opener, never the rest of the file.
201
+ result = result.slice(0, start.index) + result.slice(close < 0 ? start.index + start[0].length : close + 1);
202
+ }
203
+ }
204
+ /** The contents of the first block opened by `opener` (a regex ending at `{`), or undefined. */
205
+ export function blockContents(text, opener) {
206
+ const start = new RegExp(opener.source).exec(text);
207
+ if (!start)
208
+ return undefined;
209
+ const open = start.index + start[0].length - 1;
210
+ const close = closingBrace(text, open);
211
+ return close < 0 ? undefined : text.slice(open + 1, close);
212
+ }
@@ -0,0 +1,21 @@
1
+ export declare function readText(file: string): Promise<string | undefined>;
2
+ /** The nearest directory at or above `start` that holds a `.git` entry (a checkout or a worktree), if any. */
3
+ export declare function repositoryRoot(start: string): Promise<string | undefined>;
4
+ /**
5
+ * Directories that may hold the lockfile (or the hoisted `node_modules`) for the package in `packageDir`: every
6
+ * ancestor up to the scan root, then — when the scan root sits inside a repository (`--path` into a workspace
7
+ * monorepo) — on up to that repository's root, where Yarn/npm/pnpm workspaces keep the one lockfile.
8
+ */
9
+ export declare function lockfileDirectories(packageDir: string, root: string): Promise<string[]>;
10
+ export interface LockedVersion {
11
+ version: string;
12
+ /** The lockfile's base name, for evidence text. */
13
+ lockfile: 'package-lock.json' | 'pnpm-lock.yaml' | 'yarn.lock';
14
+ /**
15
+ * The package actually installed under the dependency's name when it differs — an `npm:` alias such as
16
+ * `"react-native-iap": "npm:@fork/react-native-iap@^12"`. The version is then that package's, not `name`'s.
17
+ */
18
+ aliasOf?: string;
19
+ }
20
+ /** The version a lockfile in `lockDir` resolved for dependency `name` (declared as `declared`) of the package in `packageDir`. */
21
+ export declare function lockedPackageVersion(name: string, packageDir: string, lockDir: string, declared: string): Promise<LockedVersion | undefined>;
@@ -0,0 +1,185 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ // Lockfile and repository-root lookups shared by the Release Doctor checks (firebase-admin, react-native-iap).
4
+ // Repository-only: these read files, never the network.
5
+ export async function readText(file) {
6
+ try {
7
+ return await fs.readFile(file, 'utf8');
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ async function exists(file) {
14
+ try {
15
+ await fs.stat(file);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function escapeRegExp(value) {
23
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
24
+ }
25
+ /** The nearest directory at or above `start` that holds a `.git` entry (a checkout or a worktree), if any. */
26
+ export async function repositoryRoot(start) {
27
+ let current = path.resolve(start);
28
+ for (;;) {
29
+ if (await exists(path.join(current, '.git')))
30
+ return current;
31
+ const parent = path.dirname(current);
32
+ if (parent === current)
33
+ return undefined;
34
+ current = parent;
35
+ }
36
+ }
37
+ function isWithin(scope, target) {
38
+ const relative = path.relative(scope, target);
39
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
40
+ }
41
+ /**
42
+ * Directories that may hold the lockfile (or the hoisted `node_modules`) for the package in `packageDir`: every
43
+ * ancestor up to the scan root, then — when the scan root sits inside a repository (`--path` into a workspace
44
+ * monorepo) — on up to that repository's root, where Yarn/npm/pnpm workspaces keep the one lockfile.
45
+ */
46
+ export async function lockfileDirectories(packageDir, root) {
47
+ const result = [];
48
+ const repo = await repositoryRoot(root);
49
+ const limit = repo && isWithin(repo, root) ? repo : root;
50
+ let current = path.resolve(packageDir);
51
+ for (;;) {
52
+ result.push(current);
53
+ if (current === limit || !isWithin(limit, current))
54
+ break;
55
+ const parent = path.dirname(current);
56
+ if (parent === current)
57
+ break;
58
+ current = parent;
59
+ }
60
+ return result;
61
+ }
62
+ /** `npm:@scope/pkg@^1.2.3` -> `@scope/pkg`. */
63
+ function aliasTarget(spec) {
64
+ return spec.match(/^npm:((?:@[^/@]+\/)?[^@]+)(?:@|$)/)?.[1];
65
+ }
66
+ const SEMVER_PREFIX = /^(\d+\.\d+\.\d+[^\s_('"]*)/;
67
+ /**
68
+ * pnpm-lock.yaml: the importer block for the package (v6+ workspaces, and v5 workspaces), or the top level for a
69
+ * single-project lockfile without `importers:` (v5 and v6). Entries are either `name: 1.2.3_peer@x` (v5 inline) or a
70
+ * nested `specifier:` / `version: 1.2.3(peer@x)` pair (v6+).
71
+ */
72
+ function pnpmLockedVersion(text, name, fromLock) {
73
+ // (an aliased entry also carries the installed package's version: `react-native-iap@12.16.2`)
74
+ const lines = text.split(/\r?\n/);
75
+ const importers = lines.findIndex((line) => /^importers:\s*$/.test(line));
76
+ let start;
77
+ let end;
78
+ let indent;
79
+ if (importers >= 0) {
80
+ const importer = lines.findIndex((line, index) => index > importers
81
+ && new RegExp(`^ ['"]?${escapeRegExp(fromLock || '.')}['"]?:\\s*$`).test(line));
82
+ if (importer < 0)
83
+ return undefined;
84
+ start = importer + 1;
85
+ end = lines.findIndex((line, index) => index > importer && /^ {0,2}\S/.test(line));
86
+ if (end < 0)
87
+ end = lines.length;
88
+ indent = 4;
89
+ }
90
+ else {
91
+ if (fromLock)
92
+ return undefined; // a single-project lockfile only describes its own directory
93
+ start = 0;
94
+ end = lines.length;
95
+ indent = 0;
96
+ }
97
+ const group = new RegExp(`^ {${indent}}(?:dependencies|devDependencies|optionalDependencies):\\s*$`);
98
+ const entry = new RegExp(`^ {${indent + 2}}['"]?${escapeRegExp(name)}['"]?:\\s*(.*?)\\s*$`);
99
+ let inGroup = false;
100
+ for (let index = start; index < end; index++) {
101
+ const line = lines[index];
102
+ if (!line.trim())
103
+ continue;
104
+ const lineIndent = line.match(/^ */)[0].length;
105
+ if (lineIndent <= indent)
106
+ inGroup = group.test(line);
107
+ if (!inGroup)
108
+ continue;
109
+ const match = entry.exec(line);
110
+ if (!match)
111
+ continue;
112
+ const values = match[1]
113
+ ? [match[1]]
114
+ : lines.slice(index + 1, index + 4).map((next) => next.match(/^\s+version:\s*(.*?)\s*$/)?.[1]).filter((value) => Boolean(value));
115
+ for (const raw of values) {
116
+ const value = raw.replace(/^['"]|['"]$/g, '');
117
+ const version = value.match(SEMVER_PREFIX)?.[1];
118
+ if (version)
119
+ return { version };
120
+ // pnpm writes an aliased dependency's version as `/@fork/pkg/1.2.3`, `@fork/pkg@1.2.3`, or `npm:@fork/pkg@1.2.3`.
121
+ const alias = value.match(/^(?:npm:|\/)?((?:@[^/@]+\/)?[^/@\s]+)[/@](\d+\.\d+\.\d+[^\s_('"]*)/);
122
+ if (alias)
123
+ return { alias: alias[1], version: alias[2] };
124
+ }
125
+ }
126
+ return undefined;
127
+ }
128
+ /** The version a lockfile in `lockDir` resolved for dependency `name` (declared as `declared`) of the package in `packageDir`. */
129
+ export async function lockedPackageVersion(name, packageDir, lockDir, declared) {
130
+ const fromLock = path.relative(lockDir, packageDir).replace(/\\/g, '/');
131
+ const declaredAlias = aliasTarget(declared);
132
+ const npmLock = await readText(path.join(lockDir, 'package-lock.json'));
133
+ if (npmLock) {
134
+ try {
135
+ const lock = JSON.parse(npmLock);
136
+ const keys = [`${fromLock ? `${fromLock}/` : ''}node_modules/${name}`, `node_modules/${name}`];
137
+ for (const key of keys) {
138
+ const entry = lock.packages?.[key];
139
+ if (typeof entry?.version !== 'string')
140
+ continue;
141
+ // An aliased install records the real package's name next to its version.
142
+ const installed = typeof entry.name === 'string' && entry.name !== name ? entry.name : declaredAlias;
143
+ return { version: entry.version, lockfile: 'package-lock.json', ...(installed ? { aliasOf: installed } : {}) };
144
+ }
145
+ const legacy = lock.dependencies?.[name]?.version;
146
+ if (typeof legacy === 'string') {
147
+ const legacyAlias = aliasTarget(legacy) ?? declaredAlias;
148
+ const version = legacy.replace(/^npm:(?:@[^/@]+\/)?[^@]+@/, '');
149
+ return { version, lockfile: 'package-lock.json', ...(legacyAlias ? { aliasOf: legacyAlias } : {}) };
150
+ }
151
+ }
152
+ catch {
153
+ // Malformed lockfile: fall through to the next source.
154
+ }
155
+ }
156
+ const pnpmLock = await readText(path.join(lockDir, 'pnpm-lock.yaml'));
157
+ if (pnpmLock) {
158
+ const locked = pnpmLockedVersion(pnpmLock, name, fromLock);
159
+ if (locked?.alias)
160
+ return { version: locked.version ?? '', lockfile: 'pnpm-lock.yaml', aliasOf: locked.alias };
161
+ if (locked?.version) {
162
+ return { version: locked.version, lockfile: 'pnpm-lock.yaml', ...(declaredAlias ? { aliasOf: declaredAlias } : {}) };
163
+ }
164
+ }
165
+ const yarnLock = await readText(path.join(lockDir, 'yarn.lock'));
166
+ if (yarnLock) {
167
+ const header = new RegExp(`(?:^|[\\s",])${escapeRegExp(name)}@(?:npm:)?${escapeRegExp(declared)}(?=["',:]|$)`);
168
+ const blocks = yarnLock.split(/\r?\n(?=\S)/);
169
+ for (const block of blocks) {
170
+ const [first] = block.split(/\r?\n/, 1);
171
+ if (!header.test(first))
172
+ continue;
173
+ const version = block.match(/^\s+version:?\s+"?(\d+\.\d+\.\d+[^\s"]*)/m)?.[1];
174
+ if (!version)
175
+ continue;
176
+ // Berry: `resolution: "@fork/pkg@npm:1.2.3"`; v1: `resolved "https://registry…/@fork/pkg/-/pkg-1.2.3.tgz"`.
177
+ const berry = block.match(/^\s+resolution:\s+"?((?:@[^/@"]+\/)?[^@"]+)@/m)?.[1];
178
+ const classic = block.match(/^\s+resolved\s+"?https?:\/\/[^"\s]*?\/((?:@[^/]+\/)?[^/]+)\/-\//m)?.[1];
179
+ const installed = [berry, classic].find((candidate) => candidate && decodeURIComponent(candidate) !== name);
180
+ const aliasOf = installed ? decodeURIComponent(installed) : declaredAlias;
181
+ return { version, lockfile: 'yarn.lock', ...(aliasOf ? { aliasOf } : {}) };
182
+ }
183
+ }
184
+ return undefined;
185
+ }
@@ -3,7 +3,8 @@ const COPY = {
3
3
  title: 'Mimi Seed Release Doctor — 로그인 없는 출시 점검',
4
4
  platforms: '감지 플랫폼',
5
5
  summary: (blockers, warnings) => `결과: 블로커 ${blockers} · 경고 ${warnings}`,
6
- labels: { blocker: '블로커', warning: '경고', info: '확인' },
6
+ // `확인` alone reads as "confirmed"; an undecided check must say it still needs checking.
7
+ labels: { blocker: '블로커', warning: '경고', info: '정보', unresolved: '확인 필요' },
7
8
  evidence: '근거',
8
9
  action: '조치',
9
10
  source: '출처',
@@ -15,26 +16,31 @@ const COPY = {
15
16
  '심사 제출과 단계적 출시 상태',
16
17
  ],
17
18
  ready: '로컬 검사에서 제출 차단 요인을 찾지 못했습니다.',
19
+ incomplete: (count, codes) => `블로커는 찾지 못했지만 로컬 검사가 끝나지 않았습니다: ${count}개 항목을 확정하지 못했습니다(${codes}). 위의 [확인 필요]·[경고] 항목을 직접 확인하기 전에는 제출 가능하다고 보지 마세요.`,
18
20
  partial: '이 결과는 저장소의 결정적 증거만 검사합니다. 스토어 상태는 연결 후 별도로 확인합니다.',
19
21
  },
20
22
  en: {
21
23
  title: 'Mimi Seed Release Doctor — no-login release check',
22
24
  platforms: 'Platforms',
23
25
  summary: (blockers, warnings) => `Result: ${blockers} blockers · ${warnings} warnings`,
24
- labels: { blocker: 'BLOCKER', warning: 'WARNING', info: 'INFO' },
26
+ labels: { blocker: 'BLOCKER', warning: 'WARNING', info: 'INFO', unresolved: 'NEEDS CHECK' },
25
27
  evidence: 'Evidence',
26
28
  action: 'Action',
27
29
  source: 'Source',
28
30
  connected: 'Checked after connecting stores',
29
31
  connectedChecks: undefined,
30
32
  ready: 'No submission blocker was found by the local checks.',
33
+ incomplete: (count, codes) => `No blocker was found, but the local check is incomplete: ${count} item(s) could not be resolved (${codes}). Do not treat this as clear until you have checked the NEEDS CHECK and WARNING items above.`,
31
34
  partial: 'This report checks deterministic repository evidence only. Store state is checked separately after connection.',
32
35
  },
33
36
  };
34
- function renderFinding(finding, copy, lang) {
37
+ function renderFinding(finding, copy, lang, unresolved) {
35
38
  const localized = lang === 'ko' ? finding.ko : undefined;
39
+ const label = finding.severity === 'info' && unresolved.has(finding.code)
40
+ ? copy.labels.unresolved
41
+ : copy.labels[finding.severity];
36
42
  const lines = [
37
- `[${copy.labels[finding.severity]}] ${localized?.title ?? finding.title}`,
43
+ `[${label}] ${localized?.title ?? finding.title}`,
38
44
  ` ${localized?.detail ?? finding.detail}`,
39
45
  ];
40
46
  if (finding.file)
@@ -48,6 +54,8 @@ function renderFinding(finding, copy, lang) {
48
54
  }
49
55
  export function renderReleaseDoctor(report, lang) {
50
56
  const copy = COPY[lang];
57
+ // Reports from older versions (and mocks) may not carry `coverage.unresolved`.
58
+ const unresolved = new Set(report.coverage.unresolved ?? []);
51
59
  const lines = [
52
60
  '',
53
61
  copy.title,
@@ -57,9 +65,12 @@ export function renderReleaseDoctor(report, lang) {
57
65
  '',
58
66
  ];
59
67
  for (const finding of report.findings)
60
- lines.push(...renderFinding(finding, copy, lang), '');
61
- if (report.counts.blocker === 0)
62
- lines.push(`✓ ${copy.ready}`, '');
68
+ lines.push(...renderFinding(finding, copy, lang, unresolved), '');
69
+ if (report.counts.blocker === 0) {
70
+ lines.push(unresolved.size > 0
71
+ ? `! ${copy.incomplete(unresolved.size, [...unresolved].join(', '))}`
72
+ : `✓ ${copy.ready}`, '');
73
+ }
63
74
  const connectedChecks = copy.connectedChecks ?? report.coverage.requiresStoreConnection;
64
75
  lines.push(copy.partial, '', `${copy.connected}:`, ...connectedChecks.map((item) => ` - ${item}`), '');
65
76
  return lines.join('\n');
@@ -23,11 +23,56 @@ export interface ReleaseDoctorReport {
23
23
  };
24
24
  counts: Record<ReleaseDoctorSeverity, number>;
25
25
  findings: ReleaseDoctorFinding[];
26
+ /** Per judged Android app module: the targetSdk values proven, or why it is unresolved. */
27
+ targetSdkModules?: TargetSdkModuleVerdict[];
28
+ /**
29
+ * Every targetSdk token in the scanned Gradle scripts and how it was classified (the fail-closed net): an
30
+ * unrecognised in-scope token, or an unattributable one below the minimum, keeps the verdict from TARGET_SDK_OK.
31
+ */
32
+ targetSdkTokens?: TargetSdkToken[];
26
33
  coverage: {
27
34
  checked: string[];
35
+ /**
36
+ * Codes of findings for checks that ran but could not reach a verdict (a blocker could hide behind them). The
37
+ * report is not an all-clear while this is non-empty.
38
+ */
39
+ unresolved: string[];
28
40
  requiresStoreConnection: string[];
29
41
  };
30
42
  }
43
+ /** How one judged module's targetSdk was resolved (also in the JSON report as `targetSdkModules`). */
44
+ export interface TargetSdkModuleVerdict {
45
+ module: string;
46
+ /** Values the module was proven to use (several with flavors or inherited every-project settings). */
47
+ values: Array<{
48
+ value: number;
49
+ file: string;
50
+ }>;
51
+ /** False when any targetSdk assignment of the module could not be evaluated, or none was found. */
52
+ resolved: boolean;
53
+ /** Why the module is unresolved, as written in the build script. */
54
+ unresolved: string[];
55
+ }
56
+ /**
57
+ * One `targetSdk` / `targetSdkVersion` / `setTargetSdkVersion` token in a scanned script — the fail-closed net's
58
+ * inventory (in the JSON report as `targetSdkTokens`).
59
+ */
60
+ export interface TargetSdkToken {
61
+ file: string;
62
+ line: number;
63
+ /**
64
+ * `assign`: sets targetSdk. `define`: defines an extra property or local of that name (`ext.targetSdkVersion = 36`,
65
+ * `def targetSdk = 36`). `read`: reads it on a right-hand side or as an argument. `other`: lint's or the test
66
+ * runner's own targetSdk. `unknown`: a shape the scanner does not model, which keeps the verdict from TARGET_SDK_OK.
67
+ */
68
+ kind: 'assign' | 'define' | 'read' | 'other' | 'unknown';
69
+ /** The value an `assign` token resolved to, when it did. */
70
+ value?: number;
71
+ /** Evaluated as part of a judged app module (its own scripts or its build root's every-project blocks). */
72
+ attributed: boolean;
73
+ /** Why the script is out of the net (`sample`, `library`, `specialized`, `template`, `outside`). */
74
+ excluded?: string;
75
+ }
31
76
  export interface ReleaseDoctorScanOptions {
32
77
  /** Upper bound on source files read for FCM evidence (tests lower it). */
33
78
  maxSourceFiles?: number;