appstore-precheck 1.8.0 → 1.10.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,83 @@ All notable changes to this project are documented here. Versioning follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.10.0] - 2026-07-02
9
+
10
+ Roadmap #4: **SARIF output + opt-in GitHub PR annotations** — read-only, no auto-fix.
11
+
12
+ The scanner can now emit its deterministic findings as **SARIF 2.1.0**
13
+ (`scan.sh --format sarif`, also `npx appstore-precheck --format sarif`), built from
14
+ the same structured findings as `--format json` with pure `jq` (no new dependency).
15
+ `results[]` contains the non-suppressed FAIL and WARN findings (FAIL → `error`,
16
+ WARN → `warning`); PASS and suppressed findings are excluded; findings that carry a
17
+ `file`/`line` become SARIF `physicalLocation`s so GitHub can anchor annotations. Only
18
+ the deterministic scan findings are included — the agent-mode Pierre deep-review
19
+ findings are not (SARIF is a deterministic CI artifact).
20
+
21
+ The GitHub Action gains two **opt-in** inputs (both default `false`, so existing
22
+ usage is unchanged): `sarif` uploads the SARIF to code-scanning via `upload-sarif`
23
+ (PR annotations + Security tab; needs `permissions: security-events: write`), and
24
+ `annotations` emits inline `::error`/`::warning` PR annotations. Both run even when
25
+ the scan verdict fails, and neither modifies the project.
26
+
27
+ ### Added
28
+ - **`skills/appstore-precheck/scripts/sarif.sh`** — `render_sarif()`, a SARIF 2.1.0
29
+ writer over the findings buffer (pure `jq`).
30
+ - **`scan.sh --format sarif`** (and `bin/cli.js --format text|json|sarif`).
31
+ - **`action.yml`** opt-in inputs `sarif` and `annotations` (default off): SARIF
32
+ upload via `github/codeql-action/upload-sarif@v3` and inline PR annotations.
33
+ - Tests: `tests/test-sarif.sh` and `tests/test-action-sarif.sh`.
34
+
35
+ ### Notes
36
+ - Read-only preserved: the scanner writes only to stdout; the Action redirects SARIF
37
+ to a CI workspace file. No auto-fix. Default `--format text`/`--format json` output
38
+ and the Action's default behavior are byte-identical to before.
39
+
40
+ ## [1.9.0] - 2026-07-02
41
+
42
+ Smarter analysis (roadmap #2a): **screenshot vision**, in two layers.
43
+
44
+ **Layer 1 (deterministic, offline, zero-dependency).** The static scanner now
45
+ reads each in-repo screenshot's file format and, for PNGs, its pixel dimensions —
46
+ a new `screenshot-dimensions` check (catalog vector 42) under guideline 2.3.3. It
47
+ WARNs on a file whose content does not match its extension, a truncated PNG, or a
48
+ PNG whose dimensions match no known App Store screenshot size (either orientation).
49
+ WARN-only: it never forces a RED verdict, and the offline scan stays
50
+ byte-identical on inputs without real screenshots. Parsing uses only `bash`+`od`+
51
+ `awk` — no new runtime dependency.
52
+
53
+ **Layer 2 (agent-mode, non-blocking).** A dedicated structured screenshot vision
54
+ review that uses the host model's vision capability to check screenshots for
55
+ placeholder / dev-debug / empty-state content, text overflow / truncation, wrong
56
+ device frame / aspect, misleading marketing (2.3.3 "show the app in use" / 2.3.10),
57
+ and metadata mismatches. Like Pierre deep-review it is advisory (`REVIEW-FINDING`)
58
+ and never changes the GREEN/YELLOW/RED verdict. It runs only in agent-skill mode
59
+ (host vision model), never in the CLI / npx / GitHub-Action path.
60
+
61
+ ### Added
62
+ - **`skills/appstore-precheck/scripts/image-dims.sh`** — zero-dependency PNG
63
+ magic-byte + IHDR pixel-dimension parser (pure `bash`+`od`+`awk`) and the Apple
64
+ accepted-screenshot-size table (verified against Apple's screenshot
65
+ specifications page).
66
+ - **`scan.sh` §7b** — screenshot format + PNG-dimension validation (rule
67
+ `screenshot-dimensions`, catalog vector 42), WARN-only.
68
+ - **`skills/appstore-precheck/references/screenshot-vision-review.md`** — agent-mode
69
+ structured screenshot vision checklist (5 checks), non-blocking, wired into
70
+ Pierre deep-review check #8 and SKILL.md Phase 4.
71
+ - Tests: `tests/test-image-dims.sh` (parser unit tests) and
72
+ `tests/fixtures/screenshots-app/` (end-to-end fixture); `tests/make-png.py`
73
+ fixture generator.
74
+
75
+ ### Changed
76
+ - Advertised deterministic rejection-vector count **41 → 42** (the new
77
+ screenshot-dimensions check) across README, SKILL.md, and methodology.
78
+ - Existing screenshot test fixtures: replaced 1-byte placeholders with real
79
+ accepted-size PNGs (scan output byte-identical).
80
+
81
+ ### Notes
82
+ - JPEG dimensions are not parsed in this release; JPEGs are format-validated
83
+ (magic bytes) and counted only.
84
+
8
85
  ## [1.8.0] - 2026-07-02
9
86
 
10
87
  Smarter analysis (roadmap #2c): a deterministic **semantic guideline-drift**
package/README.md CHANGED
@@ -44,7 +44,7 @@ each, divided by horizontal rules), not one compressed sentence. The breakdown b
44
44
 
45
45
  ## What it checks
46
46
 
47
- 41 rejection vectors across code, fastlane metadata, screenshots, `PrivacyInfo.xcprivacy`, and the paywall:
47
+ 42 rejection vectors across code, fastlane metadata, screenshots, `PrivacyInfo.xcprivacy`, and the paywall:
48
48
 
49
49
  | Guideline | Check |
50
50
  |-----------|-------|
@@ -56,6 +56,7 @@ each, divided by horizontal rules), not one compressed sentence. The breakdown b
56
56
  | **2.3.1** | Metadata length limits (name, subtitle, keywords, promo, description) |
57
57
  | **2.3.1** | Misleading marketing claims (iOS virus / malware scanners, fake speed boosters) in metadata |
58
58
  | **2.3.3** | At least one screenshot per locale |
59
+ | **2.3.3** | Screenshot format + PNG dimensions match a known App Store screenshot size *(advisory, WARN-only)* |
59
60
  | **2.3.7** | Localized metadata parity across every locale |
60
61
  | **2.3.8** | "For Kids" / "For Children" wording outside the Kids Category |
61
62
  | **2.3.10** | No other-platform / competitor names in metadata |
@@ -146,7 +147,7 @@ how the app is built:
146
147
 
147
148
  | App type | Coverage |
148
149
  |----------|----------|
149
- | 🟢 **Native Swift / SwiftUI** | **Full.** All 41 vectors apply. |
150
+ | 🟢 **Native Swift / SwiftUI** | **Full.** All 42 vectors apply. |
150
151
  | 🟡 **React Native / Flutter** | Metadata, privacy manifest, screenshots, and export compliance apply in full. The Swift-source checks (ATT, paywall links, private API, SDK detection, navigation) **under-detect rather than misfire**: that logic lives in JS/Dart, so they stay quiet instead of blocking. |
151
152
 
152
153
  ## Quick start
@@ -263,12 +264,35 @@ job on a RED verdict; set `fail-on: YELLOW` to be stricter:
263
264
  Both inputs are optional: with none set, the action scans the repo root and fails the job only on
264
265
  a RED verdict. No App Store Connect credentials are needed; the action runs the static scan only.
265
266
 
267
+ ### SARIF & PR annotations (opt-in)
268
+
269
+ The Action can surface findings as GitHub code-scanning results and inline PR annotations. Both are
270
+ off by default; enable either or both:
271
+
272
+ ```yaml
273
+ permissions:
274
+ contents: read
275
+ security-events: write # required for SARIF upload
276
+ jobs:
277
+ precheck:
278
+ runs-on: ubuntu-latest
279
+ steps:
280
+ - uses: actions/checkout@v4
281
+ - uses: berkayturk/appstore-precheck@v1
282
+ with:
283
+ sarif: true # upload SARIF to the Security tab + PR annotations
284
+ annotations: true # also emit inline ::error/::warning annotations
285
+ ```
286
+
287
+ Locally / via npx: `npx appstore-precheck --format sarif > results.sarif`. The scan stays read-only;
288
+ nothing is auto-fixed.
289
+
266
290
  ## How it works
267
291
 
268
292
  | Phase | Step |
269
293
  |-------|------|
270
294
  | **0** | **Guideline drift**: diff the live App Store Review Guidelines against a tracked baseline. Never blocks. |
271
- | **1** | **Static scan**: `scan.sh` over the 41 vectors above. |
295
+ | **1** | **Static scan**: `scan.sh` over the 42 vectors above. |
272
296
  | **2** | **`fastlane precheck`**: Apple's own metadata rule engine. |
273
297
  | **3** | **Pierre commentary**: explains **every** FAIL and WARN from Phases 0–2 in 2–3 sentences each. |
274
298
  | **4** | **Pierre deep review**: 28 semantic checks (22 Tier A + 6 Tier B v1 heuristic). Advisory only. |
package/bin/cli.js CHANGED
@@ -36,6 +36,7 @@ function printHelp() {
36
36
  `Options:\n` +
37
37
  ` --dir <path> Directory to scan (default: current directory)\n` +
38
38
  ` --fail-on <level> Exit non-zero at RED (default) or YELLOW\n` +
39
+ ` --format <fmt> Output format: text (default), json, or sarif\n` +
39
40
  ` -v, --version Print the version and exit\n` +
40
41
  ` -h, --help Show this help and exit\n` +
41
42
  `\n` +
@@ -53,7 +54,7 @@ function fail(message, code) {
53
54
  }
54
55
 
55
56
  function parseArgs(argv) {
56
- const opts = { dir: process.cwd(), failOn: 'RED' };
57
+ const opts = { dir: process.cwd(), failOn: 'RED', format: 'text' };
57
58
  for (let i = 0; i < argv.length; i++) {
58
59
  const a = argv[i];
59
60
  if (a === '-h' || a === '--help') { printHelp(); process.exit(0); }
@@ -69,6 +70,12 @@ function parseArgs(argv) {
69
70
  opts.failOn = v;
70
71
  continue;
71
72
  }
73
+ if (a === '--format') {
74
+ const v = (argv[++i] || '').toLowerCase();
75
+ if (v !== 'text' && v !== 'json' && v !== 'sarif') fail('--format must be text, json, or sarif', 64);
76
+ opts.format = v;
77
+ continue;
78
+ }
72
79
  fail(`unknown option: ${a} (try --help)`, 64);
73
80
  }
74
81
  return opts;
@@ -84,7 +91,8 @@ function main() {
84
91
  fail(`not a directory: ${opts.dir}`, 64);
85
92
  }
86
93
 
87
- const scan = spawnSync('bash', [SCAN], {
94
+ const scanArgs = opts.format === 'text' ? [SCAN] : [SCAN, '--format', opts.format];
95
+ const scan = spawnSync('bash', scanArgs, {
88
96
  cwd: opts.dir,
89
97
  encoding: 'utf8',
90
98
  maxBuffer: 32 * 1024 * 1024,
@@ -97,6 +105,10 @@ function main() {
97
105
  const scanOut = scan.stdout || '';
98
106
  process.stdout.write(scanOut);
99
107
 
108
+ if (opts.format !== 'text') {
109
+ process.exit(scan.status === 0 ? 0 : (scan.status || 0));
110
+ }
111
+
100
112
  const verdict = spawnSync('bash', [VERDICT], { input: scanOut, encoding: 'utf8' });
101
113
  if (verdict.error) fail(`failed to compute the verdict: ${verdict.error.message}`, 70);
102
114
  const summary = verdict.stdout || '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appstore-precheck",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Read-only iOS App Store pre-submission check, packaged as a portable Agent Skill with native Claude Code, Cursor, and Codex plugins, plus an npx CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Berkay Turk (https://github.com/berkayturk)",
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: appstore-precheck
3
- description: Read-only pre-submission check for an iOS app before App Store review. Scans Swift code, fastlane metadata, screenshots, PrivacyInfo.xcprivacy, and the paywall for 41 rejection vectors, wraps Apple's official `fastlane precheck`, watches for live App Store Review Guideline drift, has Pierre explain every FAIL and WARN, then runs 22 semantic deep-review checks (Tier A) plus 6 heuristic checks (Tier B v1) — 28 total. Emits a GREEN/YELLOW/RED verdict and a `.precheck-pass` token an upload guard can gate on. Use when preparing an iOS App Store submission (before Archive, before "Submit for Review", before TestFlight, or before any `fastlane deliver/pilot/release`), or when the user mentions App Store rejection, app review, or fastlane upload.
3
+ description: Read-only pre-submission check for an iOS app before App Store review. Scans Swift code, fastlane metadata, screenshots, PrivacyInfo.xcprivacy, and the paywall for 42 rejection vectors, wraps Apple's official `fastlane precheck`, watches for live App Store Review Guideline drift, has Pierre explain every FAIL and WARN, then runs 22 semantic deep-review checks (Tier A) plus 6 heuristic checks (Tier B v1) — 28 total. Emits a GREEN/YELLOW/RED verdict and a `.precheck-pass` token an upload guard can gate on. Use when preparing an iOS App Store submission (before Archive, before "Submit for Review", before TestFlight, or before any `fastlane deliver/pilot/release`), or when the user mentions App Store rejection, app review, or fastlane upload.
4
4
  license: MIT
5
5
  metadata:
6
6
  author: Berkay Turk
7
- version: 1.8.0
7
+ version: 1.10.0
8
8
  allowed-tools: Bash Read Grep Glob WebFetch
9
9
  ---
10
10
 
@@ -116,7 +116,7 @@ check(s). It is non-blocking and never runs in the offline user scan; the finger
116
116
  bash skills/appstore-precheck/scripts/scan.sh
117
117
  ```
118
118
 
119
- Emits `FAIL:` / `WARN:` / `PASS:` lines covering 41 rejection vectors: Privacy Manifest /
119
+ Emits `FAIL:` / `WARN:` / `PASS:` lines covering 42 rejection vectors: Privacy Manifest /
120
120
  Required Reason API parity (5.1.1), purpose strings (5.1.1), ATT (5.1.2), other-platform mentions
121
121
  (2.3.10), metadata limits (2.3.1), localized parity (2.3.7), screenshots (2.3.3), trial &
122
122
  auto-renew disclosures (3.1.2), Restore/Terms/Privacy links (3.1.2), private API (2.5.1), minimum
@@ -133,9 +133,10 @@ NetworkExtension usage (5.4), a demo account for a login-gated app (2.1), execut
133
133
  / native hot-patching (2.5.2), unused background modes (2.5.4), cryptocurrency wallet/mining
134
134
  (3.1.5(a)), thin WKWebView wrappers (4.2.3), remote-desktop apps (4.2.7), Safari extensions
135
135
  (4.4.2), account creation without in-app deletion (5.1.1(v) Account Sign-In), kids audience with
136
- third-party ads/analytics (5.1.4), real-money gambling copy (5.3.4), and MDM signals (5.5). The
137
- IAP checks (8–10) are skipped automatically when no in-app-purchase signals are present, and the
138
- signal-gated advisory checks (16–41) stay silent unless their triggering signal is found. The
136
+ third-party ads/analytics (5.1.4), real-money gambling copy (5.3.4), MDM signals (5.5), and
137
+ screenshot format/dimension mismatches (2.3.3). The IAP checks (8–10) are skipped automatically
138
+ when no in-app-purchase signals are present, and the signal-gated advisory checks (16–42) stay
139
+ silent unless their triggering signal is found. The
139
140
  full check table is in
140
141
  [`references/methodology.md`](references/methodology.md#phase-1-rejection-vectors).
141
142
 
@@ -226,7 +227,9 @@ After Phase 3, Pierre runs the **Review Simulator**: 28 read-only, evidence-base
226
227
  static scanner cannot fully judge (**22 Tier A** + **6 Tier B v1** heuristic — marked † below).
227
228
  The full checklist, per-check procedure, and output format live in
228
229
  [`references/pierre-deep-review.md`](references/pierre-deep-review.md) — read it before starting
229
- Phase 4.
230
+ Phase 4. When screenshots are present, also run the structured screenshot vision review in
231
+ [`references/screenshot-vision-review.md`](references/screenshot-vision-review.md) (non-blocking;
232
+ host vision model; never changes the verdict).
230
233
 
231
234
  **What Pierre does:** read Swift, metadata, entitlements, xcstrings, paywall views, review notes,
232
235
  screenshot/preview assets; fetch privacy and support URLs; cross-check claims vs code, policy vs
@@ -11,6 +11,7 @@ whole file to run the skill.
11
11
  - [Phase 4: Pierre deep review (28 checks)](#phase-4-pierre-deep-review-28-semantic-checks)
12
12
  - [Auto-detection rules](#auto-detection-rules)
13
13
  - [Verdict thresholds](#verdict-thresholds)
14
+ - [SARIF output](#sarif-output---format-sarif)
14
15
  - [Pre-submit manual checklist](#pre-submit-manual-checklist)
15
16
 
16
17
  ---
@@ -108,11 +109,31 @@ baseline the same deliberate way, with `guideline-drift.sh --reconcile`.
108
109
  | 39 | **5.1.4 Kids** *(advisory)* | Metadata targets a child audience **and** a third-party ads/analytics SDK is linked; Kids Category apps may not include third-party ads/analytics and need a parental gate |
109
110
  | 40 | **5.3.4 Gambling** *(advisory)* | Real-money gaming language in metadata (casino, sportsbook, real money, wager); real-money gambling needs licensing, geo-restriction, and must be free on the store |
110
111
  | 41 | **5.5 MDM** *(advisory)* | A Mobile Device Management signal (`DeviceManagement`, managed-app-config, `com.apple.mdm`); MDM apps need a commercial enterprise/education entity and purpose-limited data use |
112
+ | 42 | **2.3.3 Screenshot format/dimensions** *(advisory)* | Reads each in-repo screenshot's magic bytes and, for PNGs, its IHDR pixel dimensions; WARNs on a file whose content does not match its extension, a truncated PNG, or a PNG whose size matches no known App Store screenshot size (either orientation). JPEG dimensions are not parsed (format-checked only). WARN-only — never forces a RED verdict. |
111
113
 
112
114
  Vectors 8–10 only run when in-app-purchase signals are detected (StoreKit / RevenueCat import,
113
- or a paywall view). Otherwise the scanner emits a single PASS and skips them. Vectors 16–41 are
115
+ or a paywall view). Otherwise the scanner emits a single PASS and skips them. Vectors 16–42 are
114
116
  signal-gated advisory WARNs: each emits nothing unless its triggering signal is present.
115
117
 
118
+ ### Screenshot format + dimensions (§7b, 2.3.3)
119
+
120
+ Vector 7 (above) checks that every locale has at least one screenshot. §7b goes one layer deeper,
121
+ purely with zero-dependency byte reads (`image-dims.sh`, pure bash + `od`/`awk`): for every
122
+ in-repo screenshot it reads the file's magic bytes to confirm the content actually matches its
123
+ extension (`.png`/`.jpg`/`.jpeg`), and for PNGs it also parses the IHDR chunk for pixel width and
124
+ height and checks that pair against a table of known App Store screenshot sizes (tried in both
125
+ orientations, sourced from Apple's [screenshot specifications
126
+ page](https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications/)).
127
+ JPEG dimensions are not parsed — JPEGs are format-checked only (magic-byte match), not size-checked.
128
+
129
+ This is **WARN-only, never FAIL**: the accepted-size table can drift as Apple adds device sizes,
130
+ and the scanner has no way to know which display slot (iPhone 6.9", iPad 13", …) a given file is
131
+ meant to target, so a size that matches nothing in the table is a prompt to verify against the
132
+ current spec, not proof of a rejection. A mismatched-format file (e.g. a renamed JPEG saved as
133
+ `.png`) or an unreadable/truncated PNG WARNs the same way. Findings surface under
134
+ `rule_id == "screenshot-dimensions"` (catalog vector 42) with messages beginning
135
+ `WARN: 2.3.3 Screenshot …`.
136
+
116
137
  **Scope by app type.** The metadata, privacy-manifest, screenshots, and export-compliance checks
117
138
  apply to any iOS app regardless of how it is built. The code-level checks grep the app's Swift
118
139
  source (`*.swift`, plus `*.m`/`*.h` and `*.entitlements` where relevant), so they are most accurate
@@ -201,6 +222,18 @@ blocks, but it can be the fifth WARN that tips GREEN into YELLOW.
201
222
 
202
223
  ---
203
224
 
225
+ ## SARIF output (`--format sarif`)
226
+
227
+ `scan.sh --format sarif` emits a SARIF 2.1.0 log built from the same structured findings as
228
+ `--format json` (pure `jq`, no new dependency). `results[]` contains the non-suppressed FAIL and
229
+ WARN findings (FAIL → `error`, WARN → `warning`); PASS and suppressed findings are excluded. Findings
230
+ that carry a `file`/`line` become SARIF `physicalLocation`s so GitHub can anchor PR annotations. Only
231
+ the deterministic scan findings are included — the agent-mode Pierre deep-review findings are not
232
+ (SARIF is a deterministic CI artifact). The GitHub Action uploads this via `upload-sarif` and/or
233
+ emits inline `::error`/`::warning` annotations, both opt-in.
234
+
235
+ ---
236
+
204
237
  ## Pre-submit manual checklist
205
238
 
206
239
  Things the scanner cannot verify; confirm by hand before you submit:
@@ -147,6 +147,9 @@ REVIEW-PASS: <guideline> — not applicable (<reason>)
147
147
  1. Open ≥1 screenshot per primary locale.
148
148
  2. List visible features (tabs, paywall, login, maps, etc.).
149
149
  3. Flag screenshots showing features absent from the build or metadata.
150
+ 4. For the full structured screenshot vision review (placeholder/empty-state, text overflow,
151
+ wrong device frame, misleading marketing, metadata mismatch), follow
152
+ `references/screenshot-vision-review.md`.
150
153
 
151
154
  ### 9 — 2.3.6 Pricing language
152
155
 
@@ -0,0 +1,69 @@
1
+ # Screenshot vision review (agent-mode, non-blocking)
2
+
3
+ Deepens Pierre deep-review check #8 (2.3.5) into a dedicated, structured screenshot review.
4
+ This is the **vision layer** of the Review Simulator: the host model reads the actual screenshot
5
+ images and cross-checks their content against the metadata and the shipped app.
6
+
7
+ **Identity:** this runs ONLY in agent-skill mode (Claude Code / Codex / …), using the host LLM's
8
+ vision capability — exactly like Pierre reads code and the drift phase fetches URLs. It is NOT a
9
+ bundled dependency and does NOT run in the offline CLI / npx / GitHub-Action path.
10
+
11
+ **This phase does not change the GREEN/YELLOW/RED verdict** (verdict comes only from scan
12
+ `FAIL:`/`WARN:` counts). It emits `REVIEW-PASS:` / `REVIEW-FINDING: … WARN` lines, like Pierre
13
+ deep-review.
14
+
15
+ ## Rules
16
+
17
+ - Read-only. Never modify project files.
18
+ - Evidence-based: cite the screenshot **filename** (and locale) for every finding. If you cannot
19
+ read an image, say so — do not invent findings.
20
+ - Read at least one screenshot per primary locale; when several are present, scan them all.
21
+ - All five checks, every run: report each as `REVIEW-PASS:` or `REVIEW-FINDING: … WARN`.
22
+ - If there are no in-repo screenshots, report each check as
23
+ `REVIEW-PASS: <guideline> — not applicable (no in-repo screenshots; managed in App Store Connect)`.
24
+ - Severity is always WARN (advisory). Cautious language ("may trigger review questions").
25
+ - Write Pierre's 2–3 sentence explanations in the user's conversation language.
26
+
27
+ ## The 5 checks
28
+
29
+ | # | Guideline | Question |
30
+ |---|-----------|----------|
31
+ | S1 | 2.3.3 / 2.3.7 | Placeholder / dev-debug / empty-state content: Lorem ipsum, debug overlays or logs, visible TODO/FIXME, empty lists or skeleton loaders shown as content, simulator status bar with placeholder carrier/time. |
32
+ | S2 | 2.3.3 | Text overflow / truncation / clipping: clipped or overlapping labels, cut-off buttons, text running off-screen. |
33
+ | S3 | 2.3.5 | Wrong device frame / aspect: an iPad screenshot in an iPhone slot (or vice-versa), letterboxing, obviously stretched/squished aspect. |
34
+ | S4 | 2.3.3 / 2.3.10 | Misleading marketing: 2.3.3 "show the app in use" — the shot is a splash/title/logo/pure marketing art, not actual app UI; 2.3.10 — a feature is depicted that the app does not ship. |
35
+ | S5 | 2.3.5 | Metadata ↔ screenshot claim mismatch: visible UI text/features contradict the description, keywords, or promo text. |
36
+
37
+ ## Per-check procedure
38
+
39
+ ### S1 — Placeholder / dev-debug / empty-state
40
+ 1. Open each screenshot; read visible text and UI state.
41
+ 2. Flag Lorem ipsum, debug HUDs, log text, "TODO"/"FIXME", empty/skeleton content presented as real, or a simulator status bar with placeholder carrier/time.
42
+ 3. Cite the filename. Not applicable if no screenshots.
43
+
44
+ ### S2 — Text overflow / truncation
45
+ 1. Inspect labels, buttons, and headings for clipping, overlap, or off-screen text.
46
+ 2. Flag any truncation that suggests an unfinished or broken layout.
47
+
48
+ ### S3 — Wrong device frame / aspect
49
+ 1. Compare each screenshot's aspect to its locale/slot (iPhone vs iPad).
50
+ 2. Flag an iPad-aspect image in an iPhone slot (or vice-versa), letterboxing, or stretched aspect.
51
+
52
+ ### S4 — Misleading marketing
53
+ 1. Determine whether each screenshot shows the app actually in use (real UI) vs pure title/splash/logo art.
54
+ 2. Flag shots that are marketing art rather than the app in use (2.3.3), or that depict a feature absent from the build (2.3.10).
55
+
56
+ ### S5 — Metadata ↔ screenshot mismatch
57
+ 1. Read the visible UI text/features and compare to the description, keywords, and promo text.
58
+ 2. Flag direct contradictions (a feature/claim in the screenshot not in metadata, or vice-versa).
59
+
60
+ ## Output format
61
+
62
+ ```
63
+ REVIEW-PASS: <guideline> — <one-line why it looks OK, with screenshot filename>
64
+ ```
65
+ or
66
+ ```
67
+ REVIEW-FINDING: <guideline> WARN — <one-line concrete issue, with screenshot filename>
68
+ Pierre: <2–3 sentences: why Apple cares, what you saw, what to fix or verify>
69
+ ```
@@ -26,7 +26,8 @@ rule_slug() {
26
26
  35) echo webview-wrapper ;; 36) echo remote-desktop ;;
27
27
  37) echo safari-extension ;; 38) echo account-no-delete ;;
28
28
  39) echo kids-ads-analytics ;; 40) echo realmoney-gambling ;;
29
- 41) echo mdm ;; *) echo "" ;;
29
+ 41) echo mdm ;; 42) echo screenshot-dimensions ;;
30
+ *) echo "" ;;
30
31
  esac
31
32
  }
32
33
 
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # image-dims.sh — zero-dependency image format + PNG dimension helpers for scan.sh.
3
+ # Pure bash + od + awk; bash 3.2 compatible; sourced, no side effects, no output on load.
4
+
5
+ # img_format <file> -> "png" | "jpeg" | "unknown"
6
+ img_format() {
7
+ local f="$1" sig
8
+ [[ -r "$f" ]] || { echo unknown; return; }
9
+ sig="$(od -An -tx1 -N8 "$f" 2>/dev/null | tr -d ' \n')"
10
+ case "$sig" in
11
+ 89504e470d0a1a0a*) echo png ;;
12
+ ffd8ff*) echo jpeg ;;
13
+ *) echo unknown ;;
14
+ esac
15
+ }
16
+
17
+ # png_dims <file> -> "W H" (decimal) and return 0, or return 1 with no output.
18
+ # IHDR width = big-endian bytes 16-19, height = 20-23 (8-byte signature + 4-byte
19
+ # length + "IHDR" tag precede the 8 dimension bytes at offset 16).
20
+ png_dims() {
21
+ local f="$1"
22
+ [[ "$(img_format "$f")" == png ]] || return 1
23
+ local b
24
+ read -r -a b < <(od -An -tu1 -j16 -N8 "$f" 2>/dev/null)
25
+ [[ "${#b[@]}" -ge 8 ]] || return 1
26
+ local w=$(( b[0]*16777216 + b[1]*65536 + b[2]*256 + b[3] ))
27
+ local h=$(( b[4]*16777216 + b[5]*65536 + b[6]*256 + b[7] ))
28
+ printf '%s %s\n' "$w" "$h"
29
+ }
30
+
31
+ # ACCEPTED_SIZES — Apple App Store screenshot pixel sizes (portrait "W H").
32
+ # The matcher tries both orientations. This table is intentionally generous
33
+ # (includes current + legacy device sizes) because this check is WARN-only.
34
+ # Verified against Apple's official page:
35
+ # https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications/
36
+ ACCEPTED_SIZES="
37
+ 1260 2736
38
+ 1290 2796
39
+ 1320 2868
40
+ 1284 2778
41
+ 1242 2688
42
+ 1179 2556
43
+ 1206 2622
44
+ 1170 2532
45
+ 1125 2436
46
+ 1080 2340
47
+ 1242 2208
48
+ 750 1334
49
+ 640 1096
50
+ 640 1136
51
+ 640 920
52
+ 640 960
53
+ 2064 2752
54
+ 2048 2732
55
+ 1488 2266
56
+ 1668 2420
57
+ 1668 2388
58
+ 1640 2360
59
+ 1668 2224
60
+ 1536 2008
61
+ 1536 2048
62
+ 768 1004
63
+ 768 1024
64
+ "
65
+
66
+ # dims_match_accepted <W> <H> -> return 0 if W×H or H×W is an accepted size.
67
+ dims_match_accepted() {
68
+ local w="$1" h="$2" aw ah
69
+ while read -r aw ah; do
70
+ [[ -z "$aw" ]] && continue
71
+ if { [[ "$w" == "$aw" && "$h" == "$ah" ]]; } || { [[ "$w" == "$ah" && "$h" == "$aw" ]]; }; then
72
+ return 0
73
+ fi
74
+ done <<< "$ACCEPTED_SIZES"
75
+ return 1
76
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ # sarif.sh — SARIF 2.1.0 output layer for scan.sh. Sourced; pure jq over the
3
+ # findings buffer. No side effects, no output on load. bash 3.2 compatible.
4
+
5
+ : "${PRECHECK_VERSION:=dev}"
6
+
7
+ # render_sarif -> prints a SARIF 2.1.0 log built from the FINDINGS_TMP buffer.
8
+ # results[] = non-suppressed FAIL/WARN only (PASS + suppressed excluded);
9
+ # level: FAIL->error, WARN->warning. rules[] = distinct non-empty ruleIds present.
10
+ render_sarif() {
11
+ local buf="${FINDINGS_TMP:-/dev/null}"
12
+ local uri="https://github.com/berkayturk/appstore-precheck"
13
+ local help="https://github.com/berkayturk/appstore-precheck/blob/main/skills/appstore-precheck/references/methodology.md"
14
+ if [[ ! -s "$buf" ]]; then
15
+ jq -nc --arg v "$PRECHECK_VERSION" --arg u "$uri" '
16
+ {"$schema":"https://json.schemastore.org/sarif-2.1.0.json", "version":"2.1.0",
17
+ "runs":[{"tool":{"driver":{"name":"appstore-precheck","version":$v,"informationUri":$u,"rules":[]}},"results":[]}]}'
18
+ return 0
19
+ fi
20
+ jq -s --arg v "$PRECHECK_VERSION" --arg u "$uri" --arg help "$help" '
21
+ (map(select(.suppressed==false and (.severity=="FAIL" or .severity=="WARN")))) as $issues
22
+ | ($issues
23
+ | map({id:.rule_id, text:.guideline})
24
+ | map(select(.id != "" and .id != null))
25
+ | unique_by(.id)
26
+ | map({id:.id, name:.id, shortDescription:{text:.text}, helpUri:$help})) as $rules
27
+ | ($issues | map(
28
+ ( (if (.rule_id // "") == "" then {} else {ruleId:.rule_id} end)
29
+ + {level:(if .severity=="FAIL" then "error" else "warning" end),
30
+ message:{text:.message}}
31
+ + (if .file != null
32
+ then {locations:[{physicalLocation:(
33
+ {artifactLocation:{uri:.file}}
34
+ + (if .line != null then {region:{startLine:.line}} else {} end))}]}
35
+ else {locations:[]} end) )
36
+ )) as $results
37
+ | {"$schema":"https://json.schemastore.org/sarif-2.1.0.json", "version":"2.1.0",
38
+ "runs":[{"tool":{"driver":{"name":"appstore-precheck","version":$v,"informationUri":$u,"rules":$rules}},"results":$results}]}
39
+ ' "$buf"
40
+ }
@@ -15,6 +15,8 @@ cd "$ROOT" || { echo "FAIL: repo-root — could not enter repository root"; exit
15
15
  source "$(dirname "${BASH_SOURCE[0]}")/findings.sh"
16
16
  source "$(dirname "${BASH_SOURCE[0]}")/suppress.sh"
17
17
  source "$(dirname "${BASH_SOURCE[0]}")/project-model.sh"
18
+ source "$(dirname "${BASH_SOURCE[0]}")/image-dims.sh"
19
+ source "$(dirname "${BASH_SOURCE[0]}")/sarif.sh"
18
20
  FINDINGS_TMP="$(mktemp)"; export FINDINGS_TMP
19
21
  trap 'rm -f "$FINDINGS_TMP"' EXIT
20
22
  FORMAT="text"
@@ -27,13 +29,13 @@ PRECHECK_VERSION="$(grep -m1 -E '^[[:space:]]*version:' "$(dirname "${BASH_SOURC
27
29
  while [[ $# -gt 0 ]]; do
28
30
  case "$1" in
29
31
  --format)
30
- if [[ $# -lt 2 ]]; then echo "scan.sh: --format needs a value (text|json)" >&2; exit 64; fi
32
+ if [[ $# -lt 2 ]]; then echo "scan.sh: --format needs a value (text|json|sarif)" >&2; exit 64; fi
31
33
  FORMAT="$2"; shift 2 ;;
32
34
  --format=*) FORMAT="${1#*=}"; shift ;;
33
35
  *) shift ;;
34
36
  esac
35
37
  done
36
- [[ "$FORMAT" == json || "$FORMAT" == text ]] || { echo "scan.sh: --format must be text|json" >&2; exit 64; }
38
+ [[ "$FORMAT" == json || "$FORMAT" == text || "$FORMAT" == sarif ]] || { echo "scan.sh: --format must be text|json|sarif" >&2; exit 64; }
37
39
 
38
40
  CONFIG="${APPSTORE_PRECHECK_CONFIG:-.appstore-precheck.json}"
39
41
  have_jq() { command -v jq >/dev/null 2>&1; }
@@ -201,7 +203,7 @@ SUB_KEY="$(cfg '.disclosureKeys.subscription' 'subscription_disclosure')"
201
203
  TRIAL_KEY="$(cfg '.disclosureKeys.trial' 'subscription_trial_disclosure')"
202
204
  CHECK_FAMILY="$(cfg_bool '.optionalChecks.familyControls')"
203
205
 
204
- if [[ "$FORMAT" == json ]]; then exec 4>&1 1>/dev/null; fi
206
+ if [[ "$FORMAT" != text ]]; then exec 4>&1 1>/dev/null; fi
205
207
 
206
208
  echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='${XCSTRINGS:-?}' locales=${#LOCALES[@]}"
207
209
 
@@ -395,6 +397,42 @@ else
395
397
  pass "2.3.3 Screenshots — no in-repo screenshots dir; assumed managed in App Store Connect (set .screenshotsDir to check in-repo)"
396
398
  fi
397
399
 
400
+ # ===================================================================
401
+ # §7b — 2.3.3 Screenshot format + PNG dimensions (deterministic; PNG dims,
402
+ # JPEG format only). WARN-only: the accepted-size table can drift, and we
403
+ # cannot know which display slot a file targets, so mismatches never FAIL.
404
+ # ===================================================================
405
+ if [[ -n "$SCREEN_DIR" && -d "$SCREEN_DIR" ]]; then
406
+ set_rule "screenshot-dimensions"
407
+ while IFS= read -r img; do
408
+ [[ -z "$img" ]] && continue
409
+ fmt="$(img_format "$img")"
410
+ case "$img" in
411
+ *.png)
412
+ if [[ "$fmt" != png ]]; then
413
+ warn "2.3.3 Screenshot $img is not a valid PNG (file content does not match .png extension)" "$img"
414
+ continue
415
+ fi
416
+ dims="$(png_dims "$img")"
417
+ if [[ -z "$dims" ]]; then
418
+ warn "2.3.3 Screenshot $img — could not read PNG dimensions (possibly truncated)" "$img"
419
+ else
420
+ w="${dims% *}"; h="${dims#* }"
421
+ if ! dims_match_accepted "$w" "$h"; then
422
+ warn "2.3.3 Screenshot $img is ${w}x${h}, which matches no known App Store screenshot size — verify against the current spec" "$img"
423
+ fi
424
+ fi
425
+ ;;
426
+ *.jpg|*.jpeg)
427
+ if [[ "$fmt" != jpeg ]]; then
428
+ warn "2.3.3 Screenshot $img is not a valid JPEG (file content does not match extension)" "$img"
429
+ fi
430
+ ;;
431
+ esac
432
+ done < <(find "$SCREEN_DIR" -maxdepth 3 -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' \) 2>/dev/null | sort)
433
+ set_rule ""
434
+ fi
435
+
398
436
  # ===================================================================
399
437
  # In-app purchase gate — only run 3.1.2 checks if IAP signals exist
400
438
  # ===================================================================
@@ -403,7 +441,7 @@ grep -rqE 'SKPaymentQueue|SKProduct|SKMutablePayment|Product\.products|Product\(
403
441
  [[ -n "$SUB_VIEW" ]] && iap_detected=1
404
442
 
405
443
  if [[ -z "$iap_detected" ]]; then
406
- # Not one of the 41 catalog sections — clear the rule_id so this PASS doesn't
444
+ # Not one of the 42 catalog sections — clear the rule_id so this PASS doesn't
407
445
  # inherit §7's "screenshots-per-locale" slug in the JSON output.
408
446
  set_rule ""
409
447
  pass "3.1.2 IAP — no in-app purchase / subscription signals detected, skipping paywall checks"
@@ -974,4 +1012,5 @@ echo "---END-OF-SCAN---"
974
1012
  if [[ "$FORMAT" == text && "${_SUPPRESSED_COUNT:-0}" -gt 0 ]]; then
975
1013
  printf '(%s finding(s) suppressed via .precheck-ignore)\n' "$_SUPPRESSED_COUNT"
976
1014
  fi
977
- if [[ "$FORMAT" == json ]]; then exec 1>&4 4>&-; render_json; fi
1015
+ if [[ "$FORMAT" == json ]]; then exec 1>&4 4>&-; render_json;
1016
+ elif [[ "$FORMAT" == sarif ]]; then exec 1>&4 4>&-; render_sarif; fi