qa-agent-kit 0.6.0__py3-none-any.whl

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.
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic checks for prose (a memo, a README, a paper draft) before a reviewer or a model reads it.
3
+
4
+ python3 tools/prose_check.py draft.md # findings, defects first; exit 1 when a DEFECT is found
5
+ python3 tools/prose_check.py draft.md --json # the same as a list
6
+ python3 tools/prose_check.py draft.md --claims claims.csv # also write the claims ledger skeleton (docs/prose-qa.md)
7
+
8
+ Gates (each a function `gate_*` returning findings; tests/test_prose_check.py pins them):
9
+ outline heading levels that skip (H1 to H3), a heading with no body, a document with one H2
10
+ sentences sentences over --max-words (default 35), paragraphs over --max-sentences (default 7)
11
+ duplicates the same sentence twice
12
+ claims a number, percentage, year, currency or "study/report/survey" sentence with no source marker nearby
13
+ acronyms an acronym used without its expansion on first use
14
+ links a Markdown link with an empty target, a bare "http" in prose, two links with the same text and different targets
15
+ tells em dashes, "In conclusion", "Firstly", sentences in a row that start with the same word (three or more)
16
+
17
+ Markdown only; code blocks and front matter are skipped. Language-agnostic where it can be (sentence splits on . ! ? and
18
+ the Korean/Japanese full stop); the lexical tells are English.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import csv
25
+ import json
26
+ import re
27
+ import sys
28
+ from dataclasses import asdict, dataclass
29
+ from pathlib import Path
30
+
31
+ DEFECT, WARN = "DEFECT", "WARN"
32
+ SENTENCE_END = re.compile(r"(?<=[.!?。])\s+")
33
+ NUMBER = re.compile(r"(?<![\w/.-])(\d{1,3}(,\d{3})+|\d+(\.\d+)?)\s*(%|percent|x\b|억|만|천|k\b|m\b|bn\b|million|billion|USD|KRW|\$|€|£)?", re.IGNORECASE)
34
+ YEAR = re.compile(r"\b(19|20)\d{2}\b")
35
+ CLAIM_WORDS = re.compile(r"\b(study|studies|report|survey|research|according to|data show|evidence|benchmark|measured)\b", re.IGNORECASE)
36
+ SOURCE_MARKER = re.compile(r"\[\d+\]|\[\^[^\]]+\]|\]\(https?://|https?://|\(20\d\d\)|\bet al\.|\bsource:|\bper\s+[A-Z]|\baccording to\b|\bp\.\s?\d|\bTable\s+\d|\bFigure\s+\d|\bFig\.\s?\d", re.IGNORECASE)
37
+ ACRONYM = re.compile(r"\(([A-Z]{3,6})\)|\b([A-Z]{3,6})s?\b") # group 1: an expansion "(ACR)", group 2: a use
38
+ ACRONYM_SKIP = {"README", "HTTP", "HTTPS", "JSON", "HTML", "CSS", "URL", "URLS", "API", "APIS", "PDF", "CSV", "IPA", "CLI", "USA", "UK", "EU", "AND", "THE", "FOR", "NOT", "TODO", "FIXME", "MIT", "ASCII", "UTF", "ISO", "IEEE", "ACM", "GPU", "CPU", "RAM", "SSD", "USB", "SDK", "IDE", "OSS", "FAQ", "LLM", "LLMS", "AI", "UI", "UX", "QA", "CEO", "CTO", "CFO", "OK"}
39
+ LINK = re.compile(r"\[([^\]]+)\]\(([^)]*)\)")
40
+ INLINE_CODE = re.compile(r"`[^`]*`")
41
+ TELLS = [(re.compile(r"—"), "em dash"), (re.compile(r"\bIn conclusion\b", re.IGNORECASE), "'In conclusion'"),
42
+ (re.compile(r"\b(Firstly|Secondly|Thirdly)\b"), "'Firstly/Secondly/Thirdly'"),
43
+ (re.compile(r"\bIt is (worth noting|important to note)\b", re.IGNORECASE), "'It is worth noting'"),
44
+ (re.compile(r"\bdelve\b", re.IGNORECASE), "'delve'")]
45
+
46
+
47
+ @dataclass
48
+ class Finding:
49
+ gate: str
50
+ severity: str
51
+ line: int
52
+ message: str
53
+ excerpt: str = ""
54
+
55
+
56
+ @dataclass
57
+ class Block:
58
+ """A prose unit of the document: a heading or a paragraph, with the 1-based line of its first line."""
59
+ kind: str # "heading" | "paragraph" | "code"
60
+ level: int # heading level, 0 for a paragraph
61
+ line: int
62
+ text: str
63
+
64
+
65
+ def parse(text: str) -> list[Block]:
66
+ """Headings and paragraphs; fenced code, front matter, tables, list markers and HTML comments are left out."""
67
+ blocks: list[Block] = []
68
+ lines = text.splitlines()
69
+ in_code = in_front = False
70
+ para: list[str] = []
71
+ start = 0
72
+ for number, raw in enumerate(lines, 1):
73
+ line = raw.rstrip()
74
+ if number == 1 and line == "---":
75
+ in_front = True
76
+ continue
77
+ if in_front:
78
+ in_front = line != "---"
79
+ continue
80
+ if line.startswith("```") or line.startswith("~~~"):
81
+ if not in_code:
82
+ if para:
83
+ blocks.append(Block("paragraph", 0, start, " ".join(para)))
84
+ para = []
85
+ blocks.append(Block("code", 0, number, "")) # a code block is a body for the outline gate, nothing else
86
+ in_code = not in_code
87
+ continue
88
+ if in_code or line.startswith("|") or line.startswith("<!--"):
89
+ continue
90
+ heading = re.match(r"^(#{1,6})\s+(.*)$", line)
91
+ if heading or not line.strip():
92
+ if para:
93
+ blocks.append(Block("paragraph", 0, start, " ".join(para)))
94
+ para = []
95
+ if heading:
96
+ blocks.append(Block("heading", len(heading.group(1)), number, heading.group(2).strip()))
97
+ continue
98
+ if not para:
99
+ start = number
100
+ para.append(re.sub(r"^\s*([-*+]|\d+\.)\s+", "", line).strip())
101
+ if para:
102
+ blocks.append(Block("paragraph", 0, start, " ".join(para)))
103
+ return blocks
104
+
105
+
106
+ def sentences(text: str) -> list[str]:
107
+ return [s.strip() for s in SENTENCE_END.split(text) if s.strip()]
108
+
109
+
110
+ def words(sentence: str) -> int:
111
+ return len(sentence.split())
112
+
113
+
114
+ # ---- gates ----
115
+
116
+ def gate_outline(blocks: list[Block]) -> list[Finding]:
117
+ out = []
118
+ headings = [b for b in blocks if b.kind == "heading"]
119
+ previous = 0
120
+ for index, h in enumerate(headings):
121
+ if previous and h.level > previous + 1:
122
+ out.append(Finding("outline", WARN, h.line, f"heading level jumps from H{previous} to H{h.level}", h.text))
123
+ previous = h.level
124
+ position = blocks.index(h)
125
+ following = blocks[position + 1] if position + 1 < len(blocks) else None
126
+ if following is None or (following.kind == "heading" and following.level <= h.level):
127
+ out.append(Finding("outline", WARN, h.line, "heading with no body under it", h.text))
128
+ if sum(1 for h in headings if h.level == 2) == 1:
129
+ out.append(Finding("outline", WARN, headings[0].line, "only one H2: either drop the section or add the others", ""))
130
+ return out
131
+
132
+
133
+ def gate_sentences(blocks: list[Block], max_words: int, max_sentences: int) -> list[Finding]:
134
+ out = []
135
+ for b in blocks:
136
+ if b.kind != "paragraph":
137
+ continue
138
+ parts = sentences(b.text)
139
+ if len(parts) > max_sentences:
140
+ out.append(Finding("sentences", WARN, b.line, f"paragraph has {len(parts)} sentences (> {max_sentences})", parts[0][:80]))
141
+ for s in parts:
142
+ n = words(s)
143
+ if n > max_words:
144
+ out.append(Finding("sentences", DEFECT if n > max_words * 1.5 else WARN, b.line, f"sentence has {n} words (> {max_words})", s[:100]))
145
+ return out
146
+
147
+
148
+ def gate_duplicates(blocks: list[Block]) -> list[Finding]:
149
+ seen: dict[str, int] = {}
150
+ out = []
151
+ for b in blocks:
152
+ if b.kind != "paragraph":
153
+ continue
154
+ for s in sentences(b.text):
155
+ key = re.sub(r"\W+", " ", s.lower()).strip()
156
+ if words(s) < 6:
157
+ continue
158
+ if key in seen:
159
+ out.append(Finding("duplicates", DEFECT, b.line, f"sentence already said on line {seen[key]}", s[:100]))
160
+ else:
161
+ seen[key] = b.line
162
+ return out
163
+
164
+
165
+ def is_claim(sentence: str) -> bool:
166
+ return bool(NUMBER.search(sentence) or YEAR.search(sentence) or CLAIM_WORDS.search(sentence))
167
+
168
+
169
+ def claim_sentences(blocks: list[Block]) -> list[tuple[int, str, bool]]:
170
+ """(line, sentence, has_source) for every sentence that states a number, a year or cites evidence."""
171
+ found = []
172
+ for b in blocks:
173
+ if b.kind != "paragraph":
174
+ continue
175
+ parts = sentences(b.text)
176
+ for index, s in enumerate(parts):
177
+ if not is_claim(s):
178
+ continue
179
+ window = " ".join(parts[max(0, index - 1): index + 2])
180
+ found.append((b.line, s, bool(SOURCE_MARKER.search(window))))
181
+ return found
182
+
183
+
184
+ def gate_claims(blocks: list[Block]) -> list[Finding]:
185
+ return [Finding("claims", WARN, line, "number or evidence with no source marker in this or the neighbouring sentence", s[:100])
186
+ for line, s, has_source in claim_sentences(blocks) if not has_source]
187
+
188
+
189
+ def gate_acronyms(blocks: list[Block]) -> list[Finding]:
190
+ """In reading order: "(ACR)" expands ACR from there on; a use before that is reported once. Headings neither expand nor count."""
191
+ expanded: set[str] = set()
192
+ reported: set[str] = set()
193
+ out = []
194
+ for b in blocks:
195
+ if b.kind == "heading":
196
+ continue
197
+ text = INLINE_CODE.sub("", b.text) # `AGENTS.md` in a code span is a file name, not an acronym
198
+ for m in ACRONYM.finditer(text):
199
+ if m.group(1):
200
+ expanded.add(m.group(1))
201
+ continue
202
+ token = m.group(2)
203
+ if token in ACRONYM_SKIP or token in expanded or token in reported:
204
+ continue
205
+ reported.add(token)
206
+ out.append(Finding("acronyms", WARN, b.line, f"{token} used before it is expanded", text[max(0, m.start() - 40): m.end() + 40]))
207
+ return out
208
+
209
+
210
+ def gate_links(blocks: list[Block]) -> list[Finding]:
211
+ out = []
212
+ targets: dict[str, tuple[str, int]] = {}
213
+ for b in blocks:
214
+ for text, target in LINK.findall(b.text):
215
+ if not target.strip():
216
+ out.append(Finding("links", DEFECT, b.line, "link with an empty target", text))
217
+ earlier = targets.get(text.lower())
218
+ if earlier and earlier[0] != target:
219
+ out.append(Finding("links", WARN, b.line, f"link text '{text}' points at {target} here and {earlier[0]} on line {earlier[1]}", ""))
220
+ targets.setdefault(text.lower(), (target, b.line))
221
+ stripped = LINK.sub("", b.text)
222
+ if re.search(r"(?<!\()https?://\S+", stripped):
223
+ out.append(Finding("links", WARN, b.line, "bare URL in prose; give it link text or a footnote", stripped[:100]))
224
+ return out
225
+
226
+
227
+ def gate_tells(blocks: list[Block]) -> list[Finding]:
228
+ out = []
229
+ for b in blocks:
230
+ if b.kind != "paragraph":
231
+ continue
232
+ for pattern, label in TELLS:
233
+ if pattern.search(b.text):
234
+ out.append(Finding("tells", WARN, b.line, f"{label} reads as machine-written", b.text[:100]))
235
+ starts = [s.split()[0].lower().strip(",.") for s in sentences(b.text) if s.split()]
236
+ run = 1
237
+ for a, c in zip(starts, starts[1:]):
238
+ run = run + 1 if a == c else 1
239
+ if run == 3:
240
+ out.append(Finding("tells", WARN, b.line, f"three sentences in a row start with '{a}'", b.text[:100]))
241
+ break
242
+ return out
243
+
244
+
245
+ def run(text: str, max_words: int = 35, max_sentences: int = 7, gates: set[str] | None = None) -> list[Finding]:
246
+ blocks = parse(text)
247
+ table = {"outline": lambda: gate_outline(blocks), "sentences": lambda: gate_sentences(blocks, max_words, max_sentences),
248
+ "duplicates": lambda: gate_duplicates(blocks), "claims": lambda: gate_claims(blocks),
249
+ "acronyms": lambda: gate_acronyms(blocks), "links": lambda: gate_links(blocks), "tells": lambda: gate_tells(blocks)}
250
+ findings = [f for name, gate in table.items() if not gates or name in gates for f in gate()]
251
+ findings.sort(key=lambda f: (f.severity != DEFECT, f.line))
252
+ return findings
253
+
254
+
255
+ def write_ledger(blocks: list[Block], path: Path) -> int:
256
+ """Claims ledger skeleton: one row per claim sentence, columns the reviewer fills (docs/prose-qa.md)."""
257
+ rows = claim_sentences(blocks)
258
+ with path.open("w", newline="", encoding="utf-8") as handle:
259
+ writer = csv.writer(handle)
260
+ writer.writerow(["line", "claim", "source_marker_nearby", "source", "quote", "verdict"])
261
+ for line, sentence, has_source in rows:
262
+ writer.writerow([line, sentence, "yes" if has_source else "no", "", "", ""])
263
+ return len(rows)
264
+
265
+
266
+ def main(argv: list[str] | None = None) -> int:
267
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
268
+ parser.add_argument("file")
269
+ parser.add_argument("--max-words", type=int, default=35)
270
+ parser.add_argument("--max-sentences", type=int, default=7)
271
+ parser.add_argument("--gate", action="append", help="run only these gates (repeatable)")
272
+ parser.add_argument("--claims", help="write the claims ledger skeleton CSV here")
273
+ parser.add_argument("--json", action="store_true")
274
+ args = parser.parse_args(argv)
275
+ text = Path(args.file).read_text(encoding="utf-8")
276
+ findings = run(text, args.max_words, args.max_sentences, set(args.gate) if args.gate else None)
277
+ if args.claims:
278
+ count = write_ledger(parse(text), Path(args.claims))
279
+ print(f"claims ledger: {count} rows -> {args.claims}", file=sys.stderr)
280
+ if args.json:
281
+ print(json.dumps([asdict(f) for f in findings], ensure_ascii=False, indent=2))
282
+ else:
283
+ for f in findings:
284
+ print(f"{f.severity:6} {f.gate:10} L{f.line:<4} {f.message}" + (f" · {f.excerpt}" if f.excerpt else ""))
285
+ defects = sum(f.severity == DEFECT for f in findings)
286
+ print(f"{defects} DEFECT / {len(findings)} findings")
287
+ return 1 if any(f.severity == DEFECT for f in findings) else 0
288
+
289
+
290
+ if __name__ == "__main__":
291
+ sys.exit(main())
@@ -0,0 +1,78 @@
1
+ // Pull still frames out of a UI-test screen recording (.mp4 attachment in an xcresult / device_qa *-shots folder)
2
+ // and tile them into one contact sheet, so a reviewer can see what the phone actually did between two assertions.
3
+ // The Mac that holds the phone may have no ffmpeg; AVFoundation is enough.
4
+ //
5
+ // DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift tools/recording_frames.swift <video.mp4> <out-dir> [frames=12] [width=500]
6
+ // -> <out-dir>/f00_0.0.png ... and <out-dir>/contact-sheet.png (6 columns)
7
+ // swift tools/recording_frames.swift <a.png> <b.png> ... <out-dir> [width=500]
8
+ // -> the screenshots side by side in <out-dir>/contact-sheet.png (every page of a scrolled walk on one image)
9
+ //
10
+ // Why it exists (2026-10-28): twice a device UI failure was read as an app defect before anyone
11
+ // looked at the recording (댕오늘 date chips; Elsewhen "rename loses the name", which was the test re-tapping a focused
12
+ // field so the edit menu covered Done). Look at the frames before deciding app vs test.
13
+ import AVFoundation
14
+ import AppKit
15
+
16
+ let args = Array(CommandLine.arguments.dropFirst())
17
+ let shots = args.filter { $0.lowercased().hasSuffix(".png") }, rest = args.filter { !$0.lowercased().hasSuffix(".png") }
18
+ guard rest.count >= (shots.isEmpty ? 2 : 1) else {
19
+ print("usage: recording_frames.swift <video.mp4> <out-dir> [frames] [width] | <a.png> <b.png> ... <out-dir> [width]"); exit(2)
20
+ }
21
+ let outDir = URL(fileURLWithPath: rest[shots.isEmpty ? 1 : 0], isDirectory: true)
22
+ let options = Array(rest.dropFirst(shots.isEmpty ? 2 : 1))
23
+ let count = shots.isEmpty && !options.isEmpty ? max(Int(options[0]) ?? 12, 1) : 12
24
+ let widthArg = shots.isEmpty ? options.dropFirst().first : options.first
25
+ let width = CGFloat(Double(widthArg ?? "") ?? 500)
26
+ try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
27
+
28
+ /// `cg` drawn at `width` points wide.
29
+ func scaled(_ cg: CGImage) -> NSImage {
30
+ let size = NSSize(width: width, height: CGFloat(cg.height) * width / CGFloat(cg.width))
31
+ let image = NSImage(size: size)
32
+ image.lockFocus()
33
+ NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height)).draw(in: NSRect(origin: .zero, size: size))
34
+ image.unlockFocus()
35
+ return image
36
+ }
37
+
38
+ var tiles: [NSImage] = []
39
+ if !shots.isEmpty {
40
+ for path in shots {
41
+ guard let cg = NSImage(contentsOfFile: path)?.cgImage(forProposedRect: nil, context: nil, hints: nil) else { print("unreadable: \(path)"); continue }
42
+ tiles.append(scaled(cg))
43
+ }
44
+ } else {
45
+ let video = URL(fileURLWithPath: args[0])
46
+ let asset = AVURLAsset(url: video)
47
+ let generator = AVAssetImageGenerator(asset: asset)
48
+ generator.appliesPreferredTrackTransform = true
49
+ generator.requestedTimeToleranceBefore = .zero
50
+ generator.requestedTimeToleranceAfter = .zero
51
+ let duration = CMTimeGetSeconds(asset.duration)
52
+ guard duration.isFinite, duration > 0 else { print("no video track or zero length: \(video.path)"); exit(1) }
53
+ for index in 0..<count {
54
+ let seconds = duration * Double(index) / Double(count)
55
+ guard let cg = try? generator.copyCGImage(at: CMTime(seconds: seconds, preferredTimescale: 600), actualTime: nil) else { continue }
56
+ let image = scaled(cg)
57
+ let name = String(format: "f%02d_%.1f.png", index, seconds)
58
+ if let tiff = image.tiffRepresentation, let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) {
59
+ try png.write(to: outDir.appendingPathComponent(name))
60
+ }
61
+ tiles.append(image)
62
+ }
63
+ }
64
+ guard let first = tiles.first else { print("no frames decoded"); exit(1) }
65
+ let columns = shots.isEmpty ? 6 : min(tiles.count, 8), rows = (tiles.count + columns - 1) / columns
66
+ let tile = first.size
67
+ let sheet = NSImage(size: NSSize(width: tile.width * CGFloat(min(columns, tiles.count)), height: tile.height * CGFloat(rows)))
68
+ sheet.lockFocus()
69
+ for (index, image) in tiles.enumerated() {
70
+ let x = CGFloat(index % columns) * tile.width
71
+ let y = CGFloat(rows - 1 - index / columns) * tile.height
72
+ image.draw(in: NSRect(x: x, y: y, width: tile.width, height: tile.height))
73
+ }
74
+ sheet.unlockFocus()
75
+ if let tiff = sheet.tiffRepresentation, let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) {
76
+ try png.write(to: outDir.appendingPathComponent("contact-sheet.png"))
77
+ }
78
+ print("\(tiles.count) frames -> \(outDir.path)/contact-sheet.png")
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env python3
2
+ """Static pre-submission checks for an App Store app (the `script` rows of docs/device-qa-protocol.md).
3
+
4
+ Reads the "release" block of the app's device_qa.json manifest (docs/manifest.md). Each check prints PASS, WARN or FAIL
5
+ with the evidence; any FAIL exits 1. Network checks (links, Apple's AASA CDN) are skipped with --offline.
6
+
7
+ python3 tools/release_checks.py --app myapp # <apps-root>/myapp/release/testing/device_qa.json
8
+ python3 tools/release_checks.py --manifest path/to/device_qa.json --ipa /tmp/Export/MyApp.ipa # + exported entitlements
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import plistlib
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import urllib.error
22
+ import urllib.request
23
+ import zipfile
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from device_qa import add_manifest_args, manifest_path_for # same folder: on sys.path as a script and through the tests
28
+
29
+ # Field limits App Store Connect enforces (characters).
30
+ LIMITS = {"Name": 30, "Subtitle": 30, "Promotional Text": 170, "Description": 4000, "Keywords": 100, "What's New": 4000}
31
+ REVIEW_NOTES_LIMIT = 4000 # ASC rejects longer notes with 409 (2026-10-26, Pawday build 35)
32
+ FIRST_RELEASE_WORDS = re.compile(r"첫 버전|첫 출시|first release|initial release|first version", re.IGNORECASE)
33
+ PLACEHOLDERS = re.compile(r"lorem ipsum|placeholder|\bTODO\b|\bFIXME\b|\bTBD\b|example\.com|localhost|127\.0\.0\.1|test@test", re.IGNORECASE)
34
+ MARKUP = re.compile(r"<[^<>\n]+>") # ASC refuses What's New / description text "that contains markup language" — even `<library>` (2026-10-28, MotionBook 3.8)
35
+ AGE_RATING_REQUIRED = ("socialMedia",) # PATCH ageRatingDeclarations → 409 ENTITY_ERROR.ATTRIBUTE.REQUIRED without it (2026-10-28: Elsewhen, HanyakNote, MotionBook)
36
+ # Required-reason API families (Apple TN "Describing use of required reason API"): source pattern -> manifest category.
37
+ REASON_APIS = {
38
+ "NSPrivacyAccessedAPICategoryUserDefaults": re.compile(r"\bUserDefaults\b|@AppStorage|NSUserDefaults"),
39
+ # File timestamps only: URL resource keys, attribute dictionaries, FileManager, the libc calls. A bare `.modificationDate`
40
+ # also matches CKRecord and PHAsset properties, which are not file APIs (Daynest's notification service, 2026-10-29).
41
+ "NSPrivacyAccessedAPICategoryFileTimestamp": re.compile(r"\.(creationDate|modificationDate|contentModificationDate|attributeModificationDate)Key\b|attributesOfItem\(|\bfileModificationDate\b|\[\.(creationDate|modificationDate)\]|getattrlist|\b[fl]?stat\("),
42
+ "NSPrivacyAccessedAPICategorySystemBootTime": re.compile(r"systemUptime|mach_absolute_time|kern\.boottime"),
43
+ "NSPrivacyAccessedAPICategoryDiskSpace": re.compile(r"volumeAvailableCapacity|systemFreeSize|systemSize\b|statfs"),
44
+ "NSPrivacyAccessedAPICategoryActiveKeyboards": re.compile(r"activeInputModes"),
45
+ }
46
+ # Protected resources: source pattern -> Info.plist purpose key.
47
+ PURPOSE_APIS = {
48
+ "NSLocationWhenInUseUsageDescription": re.compile(r"requestWhenInUseAuthorization|CLLocationUpdate|CLServiceSession"),
49
+ "NSLocationAlwaysAndWhenInUseUsageDescription": re.compile(r"requestAlwaysAuthorization"),
50
+ "NSCalendarsFullAccessUsageDescription": re.compile(r"requestFullAccessToEvents"),
51
+ "NSCalendarsWriteOnlyAccessUsageDescription": re.compile(r"requestWriteOnlyAccessToEvents"),
52
+ "NSCameraUsageDescription": re.compile(r"AVCaptureDevice\.requestAccess\(for: *\.video|UIImagePickerController\.SourceType\.camera"),
53
+ "NSPhotoLibraryAddUsageDescription": re.compile(r"UIImageWriteToSavedPhotosAlbum|PHAccessLevel\.addOnly"),
54
+ "NSMicrophoneUsageDescription": re.compile(r"requestRecordPermission|AVAudioApplication\.requestRecordPermission"),
55
+ "NSContactsUsageDescription": re.compile(r"CNContactStore\(\)\.requestAccess"),
56
+ "NSMotionUsageDescription": re.compile(r"CMMotionActivityManager|CMPedometer"),
57
+ }
58
+
59
+
60
+ def result(check: str, status: str, detail: str) -> dict[str, str]:
61
+ return {"check": check, "status": status, "detail": detail}
62
+
63
+
64
+ def swift_files(dirs: list[Path]) -> list[Path]:
65
+ return [p for d in dirs if d.exists() for p in sorted(d.rglob("*.swift")) if "/Tests/" not in str(p) and "Tests/" not in p.parent.name]
66
+
67
+
68
+ def metadata_sections(text: str) -> dict[str, str]:
69
+ """`## Field` sections of a store metadata/<locale>.md file, bodies stripped."""
70
+ sections: dict[str, str] = {}
71
+ current = None
72
+ for line in text.splitlines():
73
+ if line.startswith("## "):
74
+ current = line[3:].strip()
75
+ sections[current] = ""
76
+ elif current is not None:
77
+ sections[current] += line + "\n"
78
+ return {k: v.strip() for k, v in sections.items()}
79
+
80
+
81
+ # ---- checks (pure where possible; tests/test_release_checks.py) ----
82
+
83
+ def check_metadata(locale: str, text: str, version: str) -> list[dict[str, str]]:
84
+ out = []
85
+ sections = metadata_sections(text)
86
+ for field, limit in LIMITS.items():
87
+ body = sections.get(field, "")
88
+ if field in ("Name", "Description", "What's New", "Keywords") and not body:
89
+ out.append(result("SUB-12", "FAIL", f"{locale}: {field} is empty"))
90
+ elif len(body) > limit:
91
+ out.append(result("SUB-12", "FAIL", f"{locale}: {field} is {len(body)} chars > {limit}"))
92
+ whats_new = sections.get("What's New", "")
93
+ if version not in ("1.0", "1.0.0") and FIRST_RELEASE_WORDS.search(whats_new):
94
+ out.append(result("SUB-10", "FAIL", f"{locale}: What's New for {version} still announces a first release: {whats_new[:80]}"))
95
+ for field, body in sections.items():
96
+ if PLACEHOLDERS.search(body) and "Review" not in field:
97
+ out.append(result("SUB-01", "FAIL", f"{locale}: placeholder text in {field}: {PLACEHOLDERS.search(body).group(0)}"))
98
+ if field in LIMITS and MARKUP.search(body):
99
+ out.append(result("SUB-12", "FAIL", f"{locale}: {field} contains markup App Store Connect rejects: {MARKUP.search(body).group(0)}"))
100
+ return out or [result("SUB-12", "PASS", f"{locale}: field limits, What's New, placeholders and markup ok")]
101
+
102
+
103
+ def check_age_rating(config: dict[str, Any]) -> dict[str, str]:
104
+ """SUB-15: a per-app `age_rating` block must carry every attribute App Store Connect now requires; a block with only a
105
+ `note` means the runner sends its defaults (which include them)."""
106
+ block = config.get("age_rating") or {}
107
+ answers = {k: v for k, v in block.items() if k != "note"}
108
+ if not answers:
109
+ return result("SUB-15", "PASS", "age rating: runner defaults")
110
+ missing = [k for k in AGE_RATING_REQUIRED if k not in answers]
111
+ return result("SUB-15", "FAIL" if missing else "PASS",
112
+ f"age rating block lacks {', '.join(missing)} (ASC answers 409)" if missing else f"age rating block has {', '.join(AGE_RATING_REQUIRED)}")
113
+
114
+
115
+ def check_review_notes(text: str) -> dict[str, str]:
116
+ n = len(text)
117
+ return result("SUB-08", "PASS" if n <= REVIEW_NOTES_LIMIT else "FAIL", f"review notes {n}/{REVIEW_NOTES_LIMIT} chars")
118
+
119
+
120
+ def check_purpose_strings(info: dict[str, Any], strings: dict[str, dict[str, str]], sources: dict[str, str]) -> list[dict[str, str]]:
121
+ """Every protected API in the sources has its purpose key, and every listed localization translates every purpose key."""
122
+ out = []
123
+ corpus = "\n".join(sources.values())
124
+ for key, pattern in PURPOSE_APIS.items():
125
+ if pattern.search(corpus) and not info.get(key):
126
+ out.append(result("PERM-04", "FAIL", f"{key} missing but the code calls {pattern.pattern.split('|')[0]}"))
127
+ purpose_keys = [k for k in info if k.endswith("UsageDescription")]
128
+ development = str(info.get("CFBundleDevelopmentRegion", "")).replace("$(DEVELOPMENT_LANGUAGE)", "")
129
+ for lang in info.get("CFBundleLocalizations", []):
130
+ if lang == development or (not development and lang == info.get("CFBundleLocalizations", [lang])[0]):
131
+ continue
132
+ missing = [k for k in purpose_keys if k not in strings.get(lang, {})]
133
+ if missing:
134
+ out.append(result("PERM-04", "FAIL", f"{lang}: no translated {', '.join(missing)} (add {lang}.lproj/InfoPlist.strings); "
135
+ f"{lang} devices see the {development or 'development'} text"))
136
+ return out or [result("PERM-04", "PASS", f"purpose strings: {', '.join(purpose_keys) or 'none needed'}")]
137
+
138
+
139
+ def check_required_reasons(bundle: str, sources: dict[str, str], declared: set[str]) -> list[dict[str, str]]:
140
+ out = []
141
+ for category, pattern in REASON_APIS.items():
142
+ hits = [f"{Path(p).name}:{text[:m.start()].count(chr(10)) + 1}" for p, text in sources.items() for m in [pattern.search(text)] if m]
143
+ if hits and category not in declared:
144
+ out.append(result("SUB-03", "FAIL", f"{bundle}: {category} used ({', '.join(hits[:4])}) but not declared in its PrivacyInfo.xcprivacy"))
145
+ return out or [result("SUB-03", "PASS", f"{bundle}: required-reason APIs declared ({', '.join(sorted(declared)) or 'none used'})")]
146
+
147
+
148
+ def check_aasa(payload: dict[str, Any], app_id: str, paths: list[str]) -> dict[str, str]:
149
+ details = payload.get("applinks", {}).get("details", [])
150
+ for item in details:
151
+ ids = item.get("appIDs") or ([item["appID"]] if "appID" in item else [])
152
+ if app_id in ids:
153
+ served = {c.get("/") for c in item.get("components", [])} | set(item.get("paths", []))
154
+ missing = [p for p in paths if p not in served]
155
+ return result("ENTRY-02", "FAIL" if missing else "PASS", f"{app_id}: " + (f"paths missing {missing}" if missing else f"serves {paths}"))
156
+ return result("ENTRY-02", "FAIL", f"{app_id} not in the AASA Apple's CDN serves")
157
+
158
+
159
+ def check_entitlements(ents: dict[str, Any]) -> list[dict[str, str]]:
160
+ out = []
161
+ if ents.get("get-task-allow"):
162
+ out.append(result("BUILD-07", "FAIL", "get-task-allow is true: a development-signed binary"))
163
+ env = ents.get("com.apple.developer.icloud-container-environment")
164
+ if ents.get("com.apple.developer.icloud-services") and env not in ("Production", ["Production"]):
165
+ out.append(result("BUILD-07", "FAIL", f"CloudKit environment is {env!r}, expected Production"))
166
+ if "aps-environment" in ents and ents["aps-environment"] != "production":
167
+ out.append(result("BUILD-07", "FAIL", f"aps-environment is {ents['aps-environment']}"))
168
+ return out or [result("BUILD-07", "PASS", "exported entitlements are production")]
169
+
170
+
171
+ # ---- I/O ----
172
+
173
+ def load_strings(resources: Path) -> dict[str, dict[str, str]]:
174
+ found: dict[str, dict[str, str]] = {}
175
+ for strings in resources.glob("*.lproj/InfoPlist.strings"):
176
+ text = strings.read_text(encoding="utf-8", errors="replace")
177
+ found[strings.parent.stem] = dict(re.findall(r'"?([A-Za-z0-9_]+)"?\s*=\s*"((?:[^"\\]|\\.)*)"\s*;', text))
178
+ for catalog in resources.glob("InfoPlist.xcstrings"):
179
+ data = json.loads(catalog.read_text(encoding="utf-8"))
180
+ for key, entry in data.get("strings", {}).items():
181
+ for lang in entry.get("localizations", {}):
182
+ found.setdefault(lang, {})[key] = "x"
183
+ return found
184
+
185
+
186
+ def fetch(url: str, timeout: float = 15) -> tuple[int, bytes]:
187
+ request = urllib.request.Request(url, headers={"User-Agent": "qakit-release-checks"})
188
+ try:
189
+ with urllib.request.urlopen(request, timeout=timeout) as response:
190
+ return response.status, response.read()
191
+ except urllib.error.HTTPError as error:
192
+ return error.code, b""
193
+ except (urllib.error.URLError, TimeoutError) as error:
194
+ return 0, str(error).encode()
195
+
196
+
197
+ def link_urls(texts: dict[str, str], ignore: list[str]) -> list[str]:
198
+ """User-reachable https URLs: interpolated strings and `ignore` prefixes (API bases, folders the app appends to) left out."""
199
+ found = {u.rstrip(").,") for text in texts.values() for u in re.findall(r"https://[^\s\"'<>)\]`]+", text)}
200
+ return sorted(u for u in found if "\\(" not in u and "{" not in u and not any(u.startswith(p) for p in ignore))
201
+
202
+
203
+ def check_links(texts: dict[str, str], ignore: list[str]) -> list[dict[str, str]]:
204
+ urls = link_urls(texts, ignore)
205
+ bad, pending = [], []
206
+ for url in urls:
207
+ status, _ = fetch(url)
208
+ if 200 <= status < 400:
209
+ continue
210
+ # The store page 404s until the first version is released; everything else must answer.
211
+ (pending if url.startswith("https://apps.apple.com/") and status == 404 else bad).append(f"{status} {url}")
212
+ out = [result("SUB-02", "FAIL" if bad else "PASS", f"{len(urls)} links; " + ("; ".join(bad[:8]) if bad else "all reachable"))]
213
+ if pending:
214
+ out.append(result("SUB-02", "WARN", "store page not live yet: " + "; ".join(pending)))
215
+ return out
216
+
217
+
218
+ def ipa_entitlements(ipa: Path) -> dict[str, Any]:
219
+ with tempfile.TemporaryDirectory() as tmp:
220
+ with zipfile.ZipFile(ipa) as archive:
221
+ archive.extractall(tmp)
222
+ app = next(Path(tmp, "Payload").glob("*.app"))
223
+ raw = subprocess.run(["codesign", "-d", "--entitlements", "-", "--xml", str(app)], capture_output=True, check=False).stdout
224
+ return plistlib.loads(raw) if raw.strip() else {}
225
+
226
+
227
+ def run(manifest_path: Path, offline: bool, ipa: str | None) -> list[dict[str, str]]:
228
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
229
+ if "release" not in manifest:
230
+ raise SystemExit(f"{manifest_path}: no \"release\" block (docs/manifest.md)")
231
+ cfg = manifest["release"]
232
+ at = lambda rel: (manifest_path.parent / rel).resolve() # noqa: E731
233
+ config = json.loads((manifest_path.parent / manifest["appstore_config"]).resolve().read_text(encoding="utf-8")) if manifest.get("appstore_config") else {}
234
+ version = str(cfg.get("version") or config.get("app_store_version", ""))
235
+ out: list[dict[str, str]] = []
236
+ for md in sorted(at(cfg["metadata_dir"]).glob("*.md")):
237
+ out += check_metadata(md.stem, md.read_text(encoding="utf-8"), version)
238
+ notes = at(cfg["review_notes"]).read_text(encoding="utf-8")
239
+ out.append(check_review_notes(notes))
240
+ out.append(check_age_rating(config))
241
+ info = plistlib.loads(at(cfg["info_plist"]).read_bytes())
242
+ all_sources = {str(p): p.read_text(encoding="utf-8", errors="replace") for p in swift_files([at(d) for d in cfg["source_dirs"]])}
243
+ out += check_purpose_strings(info, load_strings(at(cfg["resources_dir"])), all_sources)
244
+ for bundle in cfg["bundles"]:
245
+ sources = {str(p): p.read_text(encoding="utf-8", errors="replace") for p in swift_files([at(d) for d in bundle["sources"]])}
246
+ privacy = at(bundle["privacy"]) if bundle.get("privacy") else None
247
+ declared = set()
248
+ if privacy and privacy.is_file():
249
+ data = plistlib.loads(privacy.read_bytes())
250
+ declared = {item.get("NSPrivacyAccessedAPIType", "") for item in data.get("NSPrivacyAccessedAPITypes", [])}
251
+ out += check_required_reasons(bundle["name"], sources, declared)
252
+ if not offline:
253
+ texts = {md.name: md.read_text(encoding="utf-8") for md in at(cfg["metadata_dir"]).glob("*.md")}
254
+ texts["review-notes"] = notes
255
+ texts.update({k: v for k, v in all_sources.items() if "https://" in v})
256
+ out += check_links(texts, cfg.get("link_ignore", []))
257
+ if cfg.get("aasa_domain"):
258
+ status, body = fetch(f"https://app-site-association.cdn-apple.com/a/v1/{cfg['aasa_domain']}")
259
+ out.append(check_aasa(json.loads(body or b"{}"), cfg["app_id"], cfg.get("aasa_paths", [])) if status == 200
260
+ else result("ENTRY-02", "FAIL", f"Apple CDN returned {status} for {cfg['aasa_domain']}"))
261
+ if ipa and not shutil.which("codesign"):
262
+ out.append(result("BUILD-07", "WARN", "codesign is not on this machine; the IPA's entitlements were not checked"))
263
+ elif ipa:
264
+ out += check_entitlements(ipa_entitlements(Path(ipa)))
265
+ return out
266
+
267
+
268
+ def main(argv: list[str] | None = None) -> int:
269
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
270
+ add_manifest_args(parser)
271
+ parser.add_argument("--offline", action="store_true", help="skip link and AASA checks")
272
+ parser.add_argument("--ipa", help="exported .ipa to check production entitlements")
273
+ parser.add_argument("--json", action="store_true")
274
+ args = parser.parse_args(argv)
275
+ results = run(manifest_path_for(args), args.offline, args.ipa)
276
+ if args.json:
277
+ print(json.dumps(results, ensure_ascii=False, indent=2))
278
+ else:
279
+ for r in results:
280
+ print(f"{r['status']:4} {r['check']:9} {r['detail']}")
281
+ failed = sum(r["status"] == "FAIL" for r in results)
282
+ print(f"{failed} FAIL / {len(results)} checks", file=sys.stderr if args.json else sys.stdout) # keep --json output parseable
283
+ return 1 if failed else 0
284
+
285
+
286
+ if __name__ == "__main__":
287
+ sys.exit(main()) # mutation-ok: entry point, pytest calls main() directly