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.
- qa_agent_kit-0.6.0.dist-info/METADATA +157 -0
- qa_agent_kit-0.6.0.dist-info/RECORD +32 -0
- qa_agent_kit-0.6.0.dist-info/WHEEL +4 -0
- qa_agent_kit-0.6.0.dist-info/entry_points.txt +6 -0
- qa_agent_kit-0.6.0.dist-info/licenses/LICENSE +21 -0
- qakit/AGENTS.md +48 -0
- qakit/__init__.py +21 -0
- qakit/_cli.py +46 -0
- qakit/docs/README.md +14 -0
- qakit/docs/agents.md +40 -0
- qakit/docs/case-studies.md +146 -0
- qakit/docs/device-qa-protocol.md +426 -0
- qakit/docs/getting-started.md +100 -0
- qakit/docs/ko/device-qa-protocol.md +433 -0
- qakit/docs/manifest.md +96 -0
- qakit/docs/mutation-testing.md +69 -0
- qakit/docs/prose-qa.md +49 -0
- qakit/docs/pypi.md +40 -0
- qakit/docs/report.md +64 -0
- qakit/skills/README.md +51 -0
- qakit/skills/device-qa/SKILL.md +87 -0
- qakit/skills/prose-qa/SKILL.md +49 -0
- qakit/skills/qa-rigor/SKILL.md +105 -0
- qakit/tools/device_lock.py +151 -0
- qakit/tools/device_qa.py +838 -0
- qakit/tools/install_skills.py +120 -0
- qakit/tools/mutate.py +684 -0
- qakit/tools/prose_check.py +291 -0
- qakit/tools/recording_frames.swift +78 -0
- qakit/tools/release_checks.py +287 -0
- qakit/tools/screenshot_contrast.swift +42 -0
- qakit/tools/shots_gif.py +81 -0
|
@@ -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
|