C を入れているのは、Firebase Crashlytics の dSYM アップロードをビルドフェーズに仕込んだときの経験からです。あの手のスクリプトは、走らなくてもビルドは成功します。走っていないことに気づくのは、クラッシュレポートがシンボル化されないまま届いた数日後です。
D は、Firebase Apple SDK を CocoaPods から Swift Package Manager へ移した作業で欲しくなりました。パッケージ参照とターゲットへのリンクは pbxproj の別の場所に書かれていて、片方だけが入った状態でもプロジェクトは開きますし、ビルドも通ります。AdMob 側のアダプタを同じ流れで整理したときにも同じ形を踏みました。依存の追加が半分だけ通っている状態は、次にそのモジュールを使う日まで表に出てきません。
#!/usr/bin/env python3"""project.pbxproj の構造監査。ビルドの成否では分からない編集事故を検出する。使い方: python3 pbxaudit.py <path/to/project.pbxproj>終了コード: 0=検出なし / 1=検出あり / 2=パース不能"""import reimport sys# 複数行ブロック(ビルドフェーズ・パッケージ参照など)OBJ_BLOCK = re.compile( r'^\t\t([0-9A-F]{24})\s*(?:/\*(.*?)\*/)?\s*=\s*\{(.*?)^\t\t\};', re.M | re.S,)# 1行オブジェクト(PBXBuildFile / PBXFileReference はこの形で書かれる)OBJ_LINE = re.compile( r'^\t\t([0-9A-F]{24})\s*(?:/\*(.*?)\*/)?\s*=\s*\{(.*)\};\s*$', re.M,)KV = re.compile(r'(\w+)\s*=\s*([^;]+);')# ヘッダと成果物はターゲット未所属が正常なので検査Aから外すSKIP_UNOWNED = ('.h', '.hpp', '.pch', '.app', '.xcodeproj', '.framework')RESOURCE_EXT = ('.png', '.jpg', '.json', '.xcassets', '.storyboard', '.xib', '.strings', '.plist', '.mp3', '.m4a', '.html')def parse(text): """UUID -> {isa, comment, 属性, 生テキスト} を返す。""" objects = {} for uid, comment, body in (list(OBJ_BLOCK.findall(text)) + list(OBJ_LINE.findall(text))): attrs = {} for k, v in KV.findall(body): attrs[k] = v.strip() objects[uid] = { 'isa': attrs.get('isa', ''), 'comment': (comment or '').strip(), 'attrs': attrs, 'body': body, } return objectsdef uuids_in(body, key): """files = ( A /* x */, B /* y */, ); のような配列から UUID を抜く。""" m = re.search(key + r'\s*=\s*\((.*?)\);', body, re.S) if not m: return [] return re.findall(r'([0-9A-F]{24})', m.group(1))def paths_in(body, key): m = re.search(key + r'\s*=\s*\((.*?)\);', body, re.S) if not m: return [] return [x for x in re.findall(r'"?([^",\s][^",]*)"?\s*,', m.group(1))]
ここまでが読み取り側です。検査本体は、集めたオブジェクトを突き合わせるだけになります。
def audit(path): text = open(path, encoding='utf-8', errors='replace').read() objects = parse(text) if not objects: print('parse failed: no objects found', file=sys.stderr) return 2 by_isa = {} for uid, o in objects.items(): by_isa.setdefault(o['isa'], []).append(uid) # PBXBuildFile から参照されている fileRef を集める referenced = set() buildfile_of = {} for uid in by_isa.get('PBXBuildFile', []): ref = objects[uid]['attrs'].get('fileRef', '').split('/')[0].strip() if ref: referenced.add(ref) buildfile_of.setdefault(ref, []).append(uid) # 各ビルドフェーズが実際に抱えている PBXBuildFile phase_files = {} for isa in ('PBXResourcesBuildPhase', 'PBXSourcesBuildPhase', 'PBXFrameworksBuildPhase'): s = set() for uid in by_isa.get(isa, []): s.update(uuids_in(objects[uid]['body'], 'files')) phase_files[isa] = s findings = [] # 検査A: どのターゲットにも入っていないファイル参照 for uid in by_isa.get('PBXFileReference', []): name = (objects[uid]['comment'] or objects[uid]['attrs'].get('path', '')).strip('"') if uid in referenced: continue etype = objects[uid]['attrs'].get('explicitFileType', '') if 'wrapper.application' in etype or name.endswith(SKIP_UNOWNED): continue findings.append(('A', 'ターゲット未所属', name)) # 検査B: リソース拡張子なのに Resources フェーズへ届いていない for uid in by_isa.get('PBXFileReference', []): name = (objects[uid]['comment'] or objects[uid]['attrs'].get('path', '')).strip('"') if not name.lower().endswith(RESOURCE_EXT): continue bfs = buildfile_of.get(uid, []) if not bfs: continue # 検査Aで報告済み if not any(b in phase_files['PBXResourcesBuildPhase'] for b in bfs): findings.append(('B', 'Resources フェーズ外', name)) # 検査C: 入出力パスのない Run Script for uid in by_isa.get('PBXShellScriptBuildPhase', []): o = objects[uid] name = (o['attrs'].get('name', '') or o['comment']).strip('"') ins = paths_in(o['body'], 'inputPaths') outs = paths_in(o['body'], 'outputPaths') if not outs and o['attrs'].get('alwaysOutOfDate', '') != '1': findings.append(('C', '出力パス未宣言の Run Script', name or uid)) elif not ins and outs: findings.append(('C', '入力パス未宣言の Run Script', name or uid)) # 検査D: パッケージは足したがターゲットにリンクされていない linked = set() for uid in by_isa.get('XCSwiftPackageProductDependency', []): pkg = objects[uid]['attrs'].get('package', '').split('/')[0].strip() if pkg: linked.add(pkg) for uid in by_isa.get('XCRemoteSwiftPackageReference', []): if uid not in linked: findings.append(('D', 'ターゲット未リンクのパッケージ', objects[uid]['comment'] or uid)) for code, label, name in findings: print(f'[{code}] {label}: {name}') print(f'findings={len(findings)}') return 1 if findings else 0if __name__ == '__main__': if len(sys.argv) != 2: print('usage: pbxaudit.py <project.pbxproj>', file=sys.stderr) sys.exit(2) sys.exit(audit(sys.argv[1]))
# audit() の findings 生成後に挟むdef load_allow(path='tools/pbxaudit-allow.txt'): try: lines = open(path, encoding='utf-8').read().splitlines() except FileNotFoundError: return set() return {l.strip() for l in lines if l.strip() and not l.startswith('#')}allow = load_allow()findings = [f for f in findings if f[2] not in allow]