Merge commit 'cec295f34b60424a7238076407a89a2a2ad41b0c' into HEAD
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
|
||||
# P26-01 全必要内容の台帳 — 修復提出
|
||||
|
||||
対象は P26-01 / AC-P26-01。原作業「全章、7名の3ルート、全エンド、台詞、立ち絵、音声の必要枠と条件を確定する。」、原受入「原本で要求された未制作内容を欠損として記録し、架空の完成数を置かない。」を保持する。自己受入せず、30パッケージ / 88単位、正式完了15、今回加算0、partial_credit=false、whole_unit_accepted=falseを維持する。
|
||||
|
||||
変更は契約の5成果物のみ。TASK_SPEC、Opus選択、frozen pins、原本・AR・計画・決定・修復回数は変更しない。git書込み、他エージェント、Runner操作、native/full run、ゲーム・素材・設定・権限・API変更は行っていない。新規素材の導入はない。
|
||||
|
||||
## 引継ぎと決定の版
|
||||
|
||||
build/owner-handoff/p26-repair3/ の ACTUAL_WHOLE_REVIEW.json、ACTUAL_OPUS_REPAIR3.json、D05_ACTUAL_HUMAN_APPROVAL.json、D05_APPROVAL_SOURCE_BINDING.json を編集前に全文読み、データとして照合した。開始時に存在した前回blocked報告の承認出典不足は、この4資料により解消している。
|
||||
|
||||
監査アンカーは f4f85bc54c39361f9efcd16f67a17835559ca70c、原計画定義ベースは 379a1ba8fd25b5ad24226178a2492f91761dc31d。この両時点ではD05の残質問は未解決として登録されていた、という歴史的事実のみを保存する。現在のD05が未回答だとは記述しない。
|
||||
|
||||
現在の実承認は a13f7c0b791dd385ff8113c3d321c0e3fe7d7c94 の docs/plans/original-completion-2026-10-02/execution/p04-01-owner-approval-20261018/D05_ACTUAL_HUMAN_APPROVAL.json、SHA256 cd365ded1b777a0327d090709f0a4161f5005562819183455d9f2e106fba3e96。チェッカーはこのgit objectのhashを照合し、4回答の原レコードを台帳に保存する。質問IDは call_5fa8987f83284eb58b685002499cb9df。破壊/完了、日付境界、停止理由、退出順序の承認であり、P04への製品採用・内容完成・P26受入ではない。D08/D09は未解決。
|
||||
|
||||
## 修復6: 直接読解マップによる証拠のAR別帰属
|
||||
|
||||
今回の変更前に build/owner-handoff/p26-direct-support-r06/ の ACTUAL_OPUS_DIRECT_SOURCE_MAP.json、ACTUAL_WHOLE_REVIEW_F10907D8.json、DIRECT_POLICY_AUTHORITY_SNAPSHOT.json を全フィールド・全ソース本文まで読了した。確定済みの直接読解マップを転記し、証拠帰属だけを修復する。モデル指定はgpt-6.1-solのまま。新しい意味判断・仕様・承認は加えない。
|
||||
|
||||
SUPPORT_POLICYの4種をマップの許容リストに限定する。relationship-containerは21人物ルートARとAR.relationship.three_emotions / affection_shield_max、peeping-containerはAR.underground.observation / AR.observation.recorder、night-inputはAR.guest.need_sexual_night_peak、bankruptcy-bookkeepingはAR.progression.three_deficits_gameoverだけ。以前のキーワードpartial処理の廃止、voice修復、全先行修復は保持する。
|
||||
|
||||
各行のscoped_supportは対象AR・証拠種別・限定範囲・content_status=missingを明記する。unsupported_atomic_requirement_idsはその行で基盤支援がない残りのARを列挙する(用途行の既存placeholder/code-drawingは従来どおり別記)。L9は21ルートのコンテナだけpartialで、全エンド/図鑑/所要時間は未支援。L21 / L156 / L221 / L222 / L257とclause.productionは支援が空になるためmissing、evidence=[]。L34は観察状態、L53は記録フィールド、L65は客欲求入力、L72は赤字終端計算だけpartialを保持する。voice.all、endings全3行、encyclopedia.allはmissing、証拠なし。声に関するどのARも対応表に入れない。行数・AR対応・引用・制作数量は増減させない。
|
||||
|
||||
独立したEXCLUDED_ATTRIBUTIONS表で、AR.ui.observation_camera_entry(カメラアイコン/専用画面遷移)、AR.ui.observation_capture(スクリーンショット)、AR.underground.blackmail(取引状態/収支)、AR.route_action.observation_progress(観察部屋のルート進行確認)、AR.schedule.B-14.3.observation(専用観察モード達成)、AR.events.ntr_night_peak(NTRイベント夜ピーク)を拒否する。関連コードは uncredited precursor, not required behaviour と理由文に記す。コードの不存在は主張せず、これらARに証拠pinを帰属させない。SUPPORT_POLICYへの再追加も、行のscoped_support主張も独立層で拒否する。6個の個別負例は双方を注入し、許容ARの正例と復旧後の正例を伴う。破損ポリシーで再生成した台帳も拒否する。
|
||||
|
||||
chapters.all / dialogue.allはstatus=undefinedを保持し、制作内容は欠損、undefinedは範囲・数量のみと理由文で明示する。owner精度の追加判断は引き続きdeferred。
|
||||
|
||||
verify_authorityはexpectedやcanonicalを呼ばず、提出された各claimのARと行の種類、固定出典・抜粋全文、支援/未支援ARの分割、状態と範囲文を検査する。有効なSHAでも無関係な証拠、無根拠なpartial、声への同一引用行の観察証拠の流用は拒否する。負例はmissingの音声/立ち絵/エンド/図鑑への証拠注入、混合行内の支援AR拡張、正しい夜間支援への無関係な証拠追加を含む。route、L65、L53の実際のpartialを正例にする。既存負例は維持し、AR削除負例だけ新しい分割フィールドも整合して削除する。
|
||||
|
||||
矛盾した「no dedicated support established; …only」の連結を廃止した。任意LOWのL226はAR.ui.cut_duel/cut_defenseの対象外理由にP24 UIカットインを明記した。owner決定用content ARの追加分類は、既存の原典リンクと所有先を狭める別の判断が必要なため今回はdeferred。ownerは引き続き引用ARのprimary_packageに基づく追跡先で、専任制作担当の確定ではない。
|
||||
|
||||
## whole-unit対応と残存不足
|
||||
|
||||
| 対象 | 実際の記録・修復 | 残存不足 |
|
||||
| --- | --- | --- |
|
||||
| 全章 | chapters.all、原本内容行、対応AR | 章名・章数・順序・シーン列は未定義。仕様書章番号を物語章数に転用しない |
|
||||
| 7名×3ルート | 原名の21 route行、人物別AR、P19セルの版固定と関係状態ソース | 共通状態・保存のみpartial。固有イベント列・自然到達・台詞・素材・終点はmissing、D08未決 |
|
||||
| 全エンド | endings.all / bad / game_overとAR | 全3行missing、全集合/本数は未定義。赤字終端の処理はoriginal.line.72の該当ARだけpartial |
|
||||
| 台詞/立ち絵/音声/図鑑 | 各総括行、原文個別行、人物/用途AR、P22全614枠 | 完成本文・画像・ボイス・項目集合の完成証拠なし。汎用BGM/UI音は声の代用にしない |
|
||||
| 原本欠落の修復 | original.line.13 / 53 / 65 / 196 / 221 / 222、各原文全文・行・SHAとAR | イベント/台詞/CG/音声/専用操作内容はmissing。個別場面数はundefined |
|
||||
| 独立網羅 | 原本テキストの内容語彙・節見出しとARドメイン/condition/source_anchorsから選択 | 機械的な保守的選択方針を明記し、独立レビューが原単位網羅を判定 |
|
||||
| AR独立確認 | atomic_dispositionsで全ARに原レコード引用・所管原P単位・明示処置 | 旧view_results/statusを転記せず、未再制作/未定義を完成に昇格しない |
|
||||
| 所有と状態 | 引用ARのprimary_package/work_packagesと原P単位定義に結合 | 複数原P単位から特定制作担当を推測しない。owner_assignmentに担当未定義を明記 |
|
||||
| 負例 | 省略イベント行、AR欠落/未知/処置欠落、AR不一致owner、route/usage状態、stale AR/原文/usage SHA、partial誤完成 | 正式実行はRunner |
|
||||
|
||||
現在の生成物は762行(既存651行を保持し、選択した原文111行を追加)、関連AR675件。これは監査レコードの数であり、制作数量・完成数・受入分母ではない。全required_count/completed_countはnull。ARの関連選択外は追加施設契約/財布・採用等の別単位条件または独立メカニクスとして理由と原所有先を保持し、製品上の適用外/受入済みとはしない。
|
||||
|
||||
各行はatomic_requirement_idsを持つ。対応ARがない原文も消さず、所有先undefinedとして残す。ownerは引用ARのprimary_package、owner_deliverable_refsは固定参照の原P単位定義を指す。ARごとのowning_deliverablesにはprimary_packageとwork_packagesの各パッケージの元のid/title/work/acceptanceを保存する。特定の1単位が専任と原本で決まっていない場合、候補定義全体と担当未定義を示す。clause.*を一律P26-03に割り当てる旧処理を除いた。
|
||||
|
||||
## 現物の部分支援と欠損
|
||||
|
||||
scripts/relationship/original_relationship_state.gd:6、21–25、26でaffection/lust/netorareと有限値検証、shield_max==affection / 0<=shield_current<=shield_maxを引用する。relationship_service.gd:38–58はsubject_idキーの原本記録登録、315–355は別の汎用route_lock/route_id記録だけ。元ルートID・自然到達・内容はない。whole-file pinをこの抜粋へ狭める。21 route行はpartial、content_status=missingを保持し、更新・攻撃・閾値・固有物語を実装済みとはしない。
|
||||
|
||||
scripts/special/peeping_room_service.gd:127–138はstopped/link_state/targetと前提優先順、140–142は製品記録のRECORDER_ELIGIBILITY_PENDING_D04_D08_P16、155–164はfixture専用書込みのevidence_id/room_id/target_instance_id/source_event_key/minuteを引用する。autoload/simulation_runtime.gd:10863–10864は配置のBUILD_DEFINITION_PENDING_D04だけ。通常入口・記録係配置は欠損。視界、専用画面、スクリーンショット、ルート確認、取引、介入や制作イベントへ帰属させない。
|
||||
|
||||
scripts/guest/guest_service.gd:355–359はadult intimacy needのis_nightによるintimacy_night/intimacy_day入力選択だけ。ピーク値・NTRイベントの制作証拠はない。scripts/operations/operations_coordinator.gd:1190–1194は負のnet_profit連続カウンタがconfig bankruptcy_close_countに達するとTERMINAL_BANKRUPTを選ぶ処理だけ。設定値が3だとは主張せず、著作されたゲームオーバー内容は欠損。L196の専用代物弁済イベントは現物制作証拠なしとしてmissing。数量/シーン列はundefinedのまま。
|
||||
|
||||
P19/P22の旧状態をそのまま合否にしない。P19の正確な21セルと自然到達未完成の事実を版固定し、現在の関係ソースも独立参照する。P22全614用途レコードと全296mediaを保存し、表現先の現物SHAを監査アンカーへ再結合する。表現型からcode-drawing / placeholder / missingを区別し、歴史的inventoryのSHAと現在アンカーのSHAを別々に保持する。全assetsのパス/SHAも保存する。P22-03はdraft/unaccepted reference only。
|
||||
|
||||
P22の公開入力は137639773c9017f9ee7733d78e37179b687f965cのaccepted-p22-01-inputs/、manifest SHA256 90b5a69d5f364012edabe5a68b8c7613f5f5ca47bc189d435dd2e69b8dd8c915 に固定。元artifacts別名が存在すれば同一バイト必須、なければ公開git objectを読む。
|
||||
|
||||
## 検査の構造と実行状況
|
||||
|
||||
check_content_ledger.py はstdlibのみ。--checkは読取専用の台帳/出典/入力/Markdown検査、--source-binding-onlyは出典結合と台帳検査、--writeは指定JSON/Markdown生成。JSON重複キー・非有限値は拒否する。
|
||||
|
||||
authorities()は提出行や旧監査の合否を使わず、BASELINE本文の内容語彙/節見出し、ARのドメイン/condition/source_anchorsから必要行・ARを導く。選択条件はcoverage_policyに公開する。verify_authority()はcanonicalを引数に取らず、必要行とARの網羅、原文全文/行/hash、AR自身のrecord、所有原P単位、状態と表現pin、部分支援と内容欠損を検査する。canonical完全一致は追加のdrift検査として残す。原計画定義はベースへのprojectionで固定し、明示した進捗メタデータ以外の変更を拒否する。
|
||||
|
||||
負例には従来の章/route/全エンド/category欠落、架空数、schema/type、重複ID、不正JSON、Markdown drift、原計画改変、公開manifest改変、ゲーム入力追加/欠落/改変を保持する。新しいindependent_rejectは正例も負例もverify_authorityだけを呼び、canonical一致に頼らずAR網羅・owner・状態・hash不正を捕捉する。各mutationに有効な提出資料のcontrolを置く。
|
||||
|
||||
このworkerはJSON/Markdownの生成処理と読取による実装確認だけを行った。p26-ledger / p26-negative / p26-source-binding、--check、--source-binding-only、unittestは実行していない。PASSは主張しない。正式試験はRunnerが契約の3コマンドを実行する。
|
||||
|
||||
修復6の生成後の読取比較では762行、1309 AR処置、675関連ARを保持し、全行のID・引用・ARリンクは開始時と同一。全required_count/completed_countはnull。JSONの変更した最上位領域はrowsのみで、D05の4回答、atomic_dispositions、source pinsを含む他領域は同一。JSONとMDはexpected/markdownによる決定的生成であり、試験実行結果ではない。生成後に変更したcheckerの独立検証と負例はRunnerでの実行待ち。今回の実装担当指示「試験はRunnerが正式実行します」に従い、登録済み3チェックはworker未実行、結果は未取得。
|
||||
|
||||
## 新Sと受入の残ゲート
|
||||
|
||||
FROZEN_GAME_INPUT_PINS.json SHA256 6c778226a4cc6d0b0d682500759e7d4229c926a27bd2e247b00b9c3e5f1a2fb0 と、全1368 selected game inputs・settings・assets・tests・tools・marker・policy・engine・roster・329parse・default player identityの照合処理は保持する。docs helpersは対象ゲーム集合外。TASK_SPEC raw SHAは8026040345de6b7d06cfce40beac9150bfe9ad303d6b26c11bd94e66b1080b29、strip表現は1fedc9ade42a04722846daa9ac8ec26f821fb4efbce8dbbb53e3e76e31f38d02。
|
||||
|
||||
旧SでのRoot source/input equality結果を新Sへ流用しない。workerはgit書込みを禁止されており、新Sの確定/Root qualificationはRunner・Rootの担当で未実行。新Sが存在した後に改めて全入力一致を資格確認する必要がある。
|
||||
|
||||
native 184 suites / 21903 checks / 329 parse / 186 owned-endedは履歴のみ。新規native/full runなし。exact S/Cの独立適用性確認前に再利用を承認しない。whole-unit独立Opusレビュー、prepare-integration、exact C/M、normal final、Rootの台帳/main pushも未実行。P26-02/03・上流制作、D08/D09、held gestures、same-Windows、global acceptanceは未完/未受入であり、これらの完了は主張しない。15/88から16/88への加算は行わない。
|
||||
@@ -0,0 +1,641 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P26 documentation audit. Stdlib only; --check never writes or runs Godot."""
|
||||
import argparse
|
||||
from functools import lru_cache
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
OUT = 'docs/plans/original-completion-2026-10-02/execution/p26-01/'
|
||||
PLAN = 'docs/plans/original-completion-2026-10-02/'
|
||||
BASELINE = 'docs/traceability/original/BASELINE.txt'
|
||||
BASE = '379a1ba8fd25b5ad24226178a2492f91761dc31d'
|
||||
ANCHOR = 'f4f85bc54c39361f9efcd16f67a17835559ca70c'
|
||||
BASE_HASH = '75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a'
|
||||
PINS_HASH = '6c778226a4cc6d0b0d682500759e7d4229c926a27bd2e247b00b9c3e5f1a2fb0'
|
||||
SPEC_HASH = '1fedc9ade42a04722846daa9ac8ec26f821fb4efbce8dbbb53e3e76e31f38d02'
|
||||
P22 = 'artifacts/original-completion-2026-10-02/p22-01-usage-contract-candidate-r02/'
|
||||
PUBLISHED = '137639773c9017f9ee7733d78e37179b687f965c'
|
||||
ARCHIVE = OUT + 'accepted-p22-01-inputs/'
|
||||
MANIFEST_HASH = '90b5a69d5f364012edabe5a68b8c7613f5f5ca47bc189d435dd2e69b8dd8c915'
|
||||
P19 = PLAN + 'execution/p19-03/REACHABILITY_TABLE.json'
|
||||
BACKLOG = PLAN + 'execution/p22-03/production_backlog.json'
|
||||
PEOPLE = [('anri', 'アンリ'), ('fine', 'フィーネ'), ('tomari', 'トマリ'),
|
||||
('amelia', 'アメリア'), ('liz', 'リズ'), ('mimi', 'ミミ'), ('satsuki', 'サツキ')]
|
||||
ROUTES = {'love': '純愛', 'ntr': 'NTR', 'corruption': '堕落'}
|
||||
STATUSES = {'produced', 'placeholder', 'code-drawing', 'partial', 'missing', 'undefined'}
|
||||
AR_PATH = PLAN + 'atomic_requirements.json'
|
||||
CONTENT_WORDS = re.compile(r'章|ストーリー|シナリオ|ルート|エンド|図鑑|台詞|会話|立ち絵|ボイス|音声|CG|スチル|モーション|アニメ|差分|イベント|覗|記録係|脅迫|介入|カメラ|アングル|スクリーンショット|代物弁済|破産|履歴|妊娠|刻印|衣装|キャラクター|ヒロイン')
|
||||
CONTENT_DOMAINS = ('AR.heroine.', 'AR.asset.', 'AR.art.', 'AR.narrative.',
|
||||
'AR.observation.', 'AR.route_action.', 'AR.progression.')
|
||||
APPROVAL_COMMIT = 'a13f7c0b791dd385ff8113c3d321c0e3fe7d7c94'
|
||||
APPROVAL_PATH = PLAN + 'execution/p04-01-owner-approval-20261018/D05_ACTUAL_HUMAN_APPROVAL.json'
|
||||
APPROVAL_HASH = 'cd365ded1b777a0327d090709f0a4161f5005562819183455d9f2e106fba3e96'
|
||||
|
||||
# Closed attribution policy: membership means only the named backend fragment,
|
||||
# never the whole AR, its content, or other ARs sharing its source line.
|
||||
SUPPORT_POLICY = {
|
||||
'relationship-container': {
|
||||
'ars': frozenset({'AR.relationship.three_emotions',
|
||||
'AR.relationship.affection_shield_max'} |
|
||||
{'AR.heroine.' + p + '.route.' + r for p, _ in PEOPLE for r in ROUTES}),
|
||||
'sources': [('scripts/relationship/original_relationship_state.gd', (6, 6)),
|
||||
('scripts/relationship/original_relationship_state.gd', (21, 25)),
|
||||
('scripts/relationship/original_relationship_state.gd', (26, 26)),
|
||||
('scripts/relationship/relationship_service.gd', (38, 58)),
|
||||
('scripts/relationship/relationship_service.gd', (315, 355))],
|
||||
'scope': 'affection/lust/netorare fields and finite-number validation; shield_max==affection and 0<=shield_current<=shield_max invariant; subject_id-keyed original record registration; separate generic route_lock/route_id record only, with no original route IDs, natural reach or content'},
|
||||
'peeping-container': {
|
||||
'ars': frozenset({'AR.observation.recorder', 'AR.underground.observation'}),
|
||||
'sources': [('scripts/special/peeping_room_service.gd', (127, 138)),
|
||||
('scripts/special/peeping_room_service.gd', (140, 142)),
|
||||
('scripts/special/peeping_room_service.gd', (155, 164)),
|
||||
('autoload/simulation_runtime.gd', (10863, 10864))],
|
||||
'scope': 'observation state half only: stopped/link_state/target and precondition precedence; recorder ledger fields evidence_id/room_id/target_instance_id/source_event_key/minute with fixture-only writes; production record_evidence returns RECORDER_ELIGIBILITY_PENDING_D04_D08_P16, placement returns BUILD_DEFINITION_PENDING_D04; no normal entry, recorder placement, view, camera UI, screenshot, route progress, dedicated mode, intervention, transaction or authored content'},
|
||||
'night-input': {
|
||||
'ars': frozenset({'AR.guest.need_sexual_night_peak'}),
|
||||
'sources': [('scripts/guest/guest_service.gd', (355, 359))],
|
||||
'scope': 'adult intimacy need selects intimacy_night or intimacy_day input key from is_night only; no peak values, NTR events or event production'},
|
||||
'bankruptcy-bookkeeping': {
|
||||
'ars': frozenset({'AR.progression.three_deficits_gameover'}),
|
||||
'sources': [('scripts/operations/operations_coordinator.gd', (1190, 1194))],
|
||||
'scope': 'consecutive negative-net-profit counter reaches config bankruptcy_close_count and selects TERMINAL_BANKRUPT only; configured value 3 is not established; no authored game-over ending'},
|
||||
}
|
||||
|
||||
# Independent exclusions from the direct source-read map. Never derive these
|
||||
# from SUPPORT_POLICY: a policy re-add must itself be rejected.
|
||||
EXCLUDED_ATTRIBUTIONS = {
|
||||
'AR.ui.observation_camera_entry': 'camera icon and dedicated observation screen transition',
|
||||
'AR.ui.observation_capture': 'screenshot evidence collection',
|
||||
'AR.underground.blackmail': 'blackmail transaction state and income',
|
||||
'AR.route_action.observation_progress': 'route progress from observation room',
|
||||
'AR.schedule.B-14.3.observation': 'dedicated observation mode achievement',
|
||||
'AR.events.ntr_night_peak': 'NTR event night peak',
|
||||
}
|
||||
|
||||
|
||||
def support_family(row):
|
||||
# Content summaries cannot inherit support from neighbouring schedule ARs.
|
||||
return (row['kind'] == 'route' or row['id'].startswith('original.line.') or
|
||||
row['id'] in {'clause.route_rules', 'clause.production', 'clause.scope'})
|
||||
|
||||
|
||||
def support_pins(root, kind):
|
||||
return [excerpt(root, path, *span) if span else source(root, path)
|
||||
for path, span in SUPPORT_POLICY[kind]['sources']]
|
||||
|
||||
|
||||
def authorities(root):
|
||||
"""Selection reads authority text, never generated rows or old audit statuses.
|
||||
|
||||
All non-heading content-bearing lines plus whole narrative/character/art
|
||||
sections are conservative audit scope. Every AR receives a disposition;
|
||||
unrelated mechanics are explicitly outside this documentation unit.
|
||||
"""
|
||||
require(sha((root / BASELINE).read_bytes()) == BASE_HASH, 'authority BASELINE hash')
|
||||
require((root / AR_PATH).read_bytes() == blob(root, BASE, AR_PATH), 'authority AR hash')
|
||||
lines = (root / BASELINE).read_text().splitlines()
|
||||
required = set()
|
||||
section_content = False
|
||||
for n, text in enumerate(lines, 1):
|
||||
if text.startswith('# '):
|
||||
section_content = bool(re.search(r'世界観|ユニークキャラクター|恋愛|デザイン|アセット|ロードマップ', text))
|
||||
if text.strip() and not text.startswith('#') and (section_content or CONTENT_WORDS.search(text)):
|
||||
required.add(n)
|
||||
records = decode((root / AR_PATH).read_bytes())['requirements']
|
||||
relevant = {a['id'] for a in records if atomic_lines(a) and (a['id'].startswith(CONTENT_DOMAINS)
|
||||
or CONTENT_WORDS.search(a['condition']) or any(
|
||||
s['path'] == BASELINE and required.intersection(s['lines'])
|
||||
for s in a['source_anchors']))}
|
||||
required.update(n for a in records if a['id'] in relevant for n in atomic_lines(a))
|
||||
packages = decode(blob(root, BASE, PLAN + 'work_packages.json'))
|
||||
return lines, required, {a['id']: a for a in records}, relevant, {p['id']: p for p in packages}
|
||||
|
||||
|
||||
def atomic_lines(record):
|
||||
return {n for s in record['source_anchors'] if s['path'] == BASELINE for n in s['lines']}
|
||||
|
||||
|
||||
def owning_units(record, packages):
|
||||
# Preserve the actual definitions; do not invent a uniquely assigned unit
|
||||
# where the original package has several remaining deliverables.
|
||||
return [{'package': p, 'primary': p == record['primary_package'],
|
||||
'deliverables': [{k: u[k] for k in ('id', 'title', 'work', 'acceptance')}
|
||||
for u in packages[p]['remaining_deliverables']]}
|
||||
for p in dict.fromkeys([record['primary_package']] + record['work_packages'])]
|
||||
|
||||
|
||||
def require(ok, message):
|
||||
if not ok:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def sha(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def decode(raw):
|
||||
def unique(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
require(key not in result, 'duplicate JSON key: ' + key)
|
||||
result[key] = value
|
||||
return result
|
||||
return json.loads(raw, object_pairs_hook=unique,
|
||||
parse_constant=lambda s: (_ for _ in ()).throw(ValueError(s)))
|
||||
|
||||
|
||||
def encoded(value):
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def blob(root, commit, path):
|
||||
return subprocess.check_output(['git', 'show', commit + ':' + path], cwd=root)
|
||||
|
||||
|
||||
PACKAGE_MUTABLE = {'state', 'legacy_state', 'current_summary', 'updated_at_jst'}
|
||||
UNIT_MUTABLE = {'state', 'current_execution', 'remaining_gaps', 'updated_at_jst',
|
||||
'submitted_commit', 'current_summary', 'progress_summary', 'evidence',
|
||||
'completed_at_jst', 'implementation_commit', 'integrated_commit',
|
||||
'integration_commit', 'normal_integration_candidate'}
|
||||
|
||||
|
||||
def projection(packages):
|
||||
require(type(packages) is list, 'package register type')
|
||||
result = []
|
||||
for package in packages:
|
||||
require(type(package) is dict, 'package type')
|
||||
row = {k: v for k, v in package.items() if k not in PACKAGE_MUTABLE}
|
||||
require(type(row.get('remaining_deliverables')) is list, 'deliverables type')
|
||||
row['remaining_deliverables'] = [{k: v for k, v in unit.items()
|
||||
if k not in UNIT_MUTABLE} for unit in row['remaining_deliverables']]
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
|
||||
def selection(root):
|
||||
paths = [p for folder in ('autoload', 'scripts', 'scenes', 'data', 'tests', 'assets', 'tools')
|
||||
for p in (root / folder).rglob('*') if p.is_file() and p.name != '.DS_Store'
|
||||
and '__pycache__' not in p.parts]
|
||||
paths += [root / p for p in ('project.godot', 'export_presets.cfg', 'Makefile', 'AGENTS.md')]
|
||||
require(all(not p.is_symlink() for p in paths), 'selected symlink')
|
||||
return {p.relative_to(root).as_posix(): {'bytes': p.stat().st_size,
|
||||
'sha256': sha(p.read_bytes())} for p in sorted(paths)}
|
||||
|
||||
|
||||
def binding(root):
|
||||
raw = lambda p: (root / p).read_bytes()
|
||||
spec_raw = raw(OUT + 'TASK_SPEC.json')
|
||||
require(sha(spec_raw) == '8026040345de6b7d06cfce40beac9150bfe9ad303d6b26c11bd94e66b1080b29', 'raw spec SHA')
|
||||
require(sha(spec_raw.decode().strip().encode()) == SPEC_HASH, 'immutable spec')
|
||||
require(spec_raw == blob(root, BASE, OUT + 'TASK_SPEC.json'), 'raw immutable spec')
|
||||
spec = decode(spec_raw)
|
||||
require(spec['audit_source_anchor'] == ANCHOR, 'audit anchor')
|
||||
require(sha(raw(OUT + 'FROZEN_GAME_INPUT_PINS.json')) == PINS_HASH ==
|
||||
spec['frozen_game_input_pins_sha256'], 'frozen pins')
|
||||
require(raw(OUT + 'ACTUAL_OPUS_NEXT_ORIGINAL_UNIT_SELECTION.json') ==
|
||||
blob(root, BASE, OUT + 'ACTUAL_OPUS_NEXT_ORIGINAL_UNIT_SELECTION.json'), 'selection')
|
||||
require(sha(raw(BASELINE)) == BASE_HASH, 'original source')
|
||||
for name in ('work_packages.json', 'atomic_requirements.json', 'decisions.json'):
|
||||
current, original = raw(PLAN + name), blob(root, BASE, PLAN + name)
|
||||
if name == 'work_packages.json':
|
||||
require(encoded(projection(decode(current))) == encoded(projection(decode(original))),
|
||||
'original definitions')
|
||||
else:
|
||||
require(current == original, 'original register: ' + name)
|
||||
pins = decode(raw(OUT + 'FROZEN_GAME_INPUT_PINS.json'))
|
||||
actual = selection(root)
|
||||
require(encoded(actual) == encoded(pins['source_files']), 'selected game set: missing/new/changed')
|
||||
require(len(actual) == 1368, 'selected game cardinality')
|
||||
for key in ('marker_contract', 'policy', 'engine'):
|
||||
path = Path(pins[key + '_path']) if key == 'engine' else root / pins[key + '_path']
|
||||
require(sha(path.read_bytes()) == pins[key + '_sha256'], key + ' identity')
|
||||
roster = ['tests/run_tests.gd'] + sorted(p.relative_to(root).as_posix()
|
||||
for p in (root / 'tests').rglob('test*.gd') if 'export' not in p.relative_to(root).parts
|
||||
and p.relative_to(root).as_posix() != 'tests/s0/test_kernel_and_save.gd')
|
||||
require(roster == pins['roster'], 'roster')
|
||||
require(sum(p.endswith('.gd') for p in actual) == pins['parse_files'] == 329, 'parse set')
|
||||
# Every selected file, including settings and default player identity, is bound
|
||||
# to both the immutable source map and the exact published game audit anchor.
|
||||
for path in actual:
|
||||
require(sha(blob(root, ANCHOR, path)) == actual[path]['sha256'], 'audit source: ' + path)
|
||||
return {'selected_files': len(actual), 'parse_files': 329,
|
||||
'source_anchor': ANCHOR, 'native_reuse_accepted': False,
|
||||
'native_reuse_reason': 'Exact game equality is checked; external native proof and exact S/C applicability remain independent review gates.'}
|
||||
|
||||
|
||||
def read_source(root, path):
|
||||
if path.startswith(P22):
|
||||
archived = blob(root, PUBLISHED, ARCHIVE + path[len(P22):])
|
||||
if (root / path).exists():
|
||||
require((root / path).read_bytes() == archived, 'P22 alias changed: ' + path)
|
||||
return archived
|
||||
return (root / path).read_bytes()
|
||||
|
||||
|
||||
def source(root, path):
|
||||
raw = read_source(root, path)
|
||||
anchor = PUBLISHED if path.startswith(P22) else ANCHOR
|
||||
archived_path = ARCHIVE + path[len(P22):] if path.startswith(P22) else path
|
||||
anchored = blob(root, anchor, archived_path)
|
||||
require(raw == anchored, 'stale evidence: ' + path)
|
||||
return {'path': path, 'sha256': sha(raw), 'bytes': len(raw),
|
||||
'anchor': anchor, 'anchor_path': archived_path}
|
||||
|
||||
|
||||
def excerpt(root, path, start, end):
|
||||
pin = source(root, path)
|
||||
return dict(pin, lines=[start, end], text='\n'.join(
|
||||
read_source(root, path).decode().splitlines()[start-1:end]))
|
||||
|
||||
|
||||
def expected(root):
|
||||
"""Independent original clauses plus pinned actual evidence, not ledger-derived facts."""
|
||||
lines = (root / BASELINE).read_text().splitlines()
|
||||
require(sha((root / BASELINE).read_bytes()) == BASE_HASH, 'baseline')
|
||||
manifest_raw = blob(root, PUBLISHED, ARCHIVE + 'ROOT_MATERIAL_PRESERVATION_MANIFEST.json')
|
||||
require(sha(manifest_raw) == MANIFEST_HASH, 'published P22 manifest')
|
||||
for entry in decode(manifest_raw)['files']:
|
||||
raw = read_source(root, entry['original_path'])
|
||||
require(len(raw) == entry['bytes'] and sha(raw) == entry['sha256'], 'published P22 file pin')
|
||||
def cite(start, end=None):
|
||||
end = end or start
|
||||
return {'path': BASELINE, 'lines': [start, end],
|
||||
'text': '\n'.join(lines[start-1:end]), 'sha256': BASE_HASH}
|
||||
rows = []
|
||||
def add(identity, kind, spans, condition, owner, status='undefined', evidence=None,
|
||||
reason='欠損: 個別ID・必要量・完成内容は原本未定義。D08/D09未決。', identity_data=None):
|
||||
rows.append({'id': identity, 'kind': kind, 'identity': identity_data,
|
||||
'citations': [cite(*span) for span in spans], 'required_condition': condition,
|
||||
'status': status, 'required_count': None, 'completed_count': None,
|
||||
'evidence': evidence or [], 'missing': 'original production content missing or undefined',
|
||||
'reason': reason, 'owner': owner})
|
||||
add('chapters.all', 'chapter', [(8,), (26,), (27,30), (256,)],
|
||||
'メインストーリー全章。原本の仕様書章番号をシナリオ章と混同しない。章名・章数・順序・場面一覧は未定義。', 'D08',
|
||||
reason='欠損: 制作内容は欠損。undefinedは章の範囲・数量のみを指す。章名・順序・場面一覧は原本未定義、D08未決。')
|
||||
add('endings.all', 'ending', [(9,), (135,)],
|
||||
'全エンドを要求。個別エンドID・本数・到達条件は未定義。', 'D08', 'missing')
|
||||
add('endings.bad', 'ending', [(135,)],
|
||||
'特定バッドエンド確定時の敵/背景オブジェクト化。カテゴリのみ原本定義、対象人物/場面/数は未定義。', 'P26-02', 'missing')
|
||||
add('endings.game_over', 'ending', [(72,)],
|
||||
'赤字決算3回連続のゲームオーバー。物語エンド数への換算不可。', 'P26-02', 'missing',
|
||||
[],
|
||||
'欠損: 経済処理の既存コードは存在するが終点のシナリオ・画像・音声・全受入を証明しない。')
|
||||
add('encyclopedia.all', 'encyclopedia', [(9,)],
|
||||
'図鑑コンプ。対象集合・項目ID・解放条件・必要項目数は未定義。', 'D08', 'missing')
|
||||
add('dialogue.all', 'dialogue', [(155,), (230,), (256,)],
|
||||
'会話/イベント用台詞とシナリオ。本文・話者・行数・場面単位は未定義。UI通知文を完成脚本に数えない。', 'P26-02',
|
||||
reason='欠損: 制作内容は欠損。undefinedは台詞の範囲・数量のみを指す。本文・話者・場面単位は原本未定義、D08未決。')
|
||||
add('portraits.all', 'portrait', [(160,161), (230,), (235,), (242,)],
|
||||
'メイン/サブ人物の表情・衣装・破損・汚れ・刻印・妊婦外見・ルート別描き分け。差分枚数未定義。', 'P26-03', 'missing')
|
||||
add('voice.all', 'voice', [(252,), (257,)],
|
||||
'キャラクターボイスの収録・実装。話者/台詞/クリップ数/担当未定義。BGMやUI音は代用不可。', 'P26-03', 'missing')
|
||||
# All applicable full clauses are retained even where the original defines no finite scene list.
|
||||
for key, spans, condition in [
|
||||
('world', [(27,30)], '舞台・主人公・7人物の3運命を保つ。'),
|
||||
('scope', [(31,34)], '原本表現要求と今回の非露骨な監査範囲との差異を残す。'),
|
||||
('route_skills', [(132,), (134,135)], 'ルートによる技能・衣装補正・離脱条件。'),
|
||||
('route_rules', [(138,161)], '3軸/盾・侵食・3種決闘・プレイヤー行動・履歴/外見の全共通条件。'),
|
||||
('art', [(229,230), (234,235)], '原本のマップ/SD/立ち絵・モーションと描き分け。'),
|
||||
('cg', [(242,243)], '人物立ち絵およびルート別CG/差分/モブ汎用スチル。'),
|
||||
('motion', [(245,246)], '待機・歩行・作業・気絶・原本必須モーションと効果。'),
|
||||
('production', [(252,), (255,258)], '原本のシナリオ/素材量産/音声制作と受入は未完。')]:
|
||||
add('clause.' + key, 'category', spans, condition, 'P26-03', 'missing')
|
||||
table = decode((root / P19).read_bytes())
|
||||
table_pin = source(root, P19)
|
||||
cells = {(c['character_id'], c['route_id']): c for c in table['cells']}
|
||||
for i, (person, name) in enumerate(PEOPLE):
|
||||
require(name in lines[117+i], 'original identity')
|
||||
for route, route_name in ROUTES.items():
|
||||
cell = cells[(person, route)]
|
||||
require(cell['name'] == name and cell['route'] == route_name and
|
||||
cell['natural_route_complete'] is False, 'P19 route identity/status')
|
||||
add('route.' + person + '.' + route, 'route', [(118+i,), (30,), (132,), (138,161)],
|
||||
'人物固有の入口・イベント列・分岐・終点・台詞・立ち絵・音声・履歴/保存と通常到達。',
|
||||
'P26-02', 'missing', [dict(table_pin, cell=[person, route])],
|
||||
'欠損: P19共通モデル/保存の部分実装は人物別制作/到達ではない。人物固有条件・場面列はD08未決。',
|
||||
{'character_id': person, 'original_name': name, 'route_id': route, 'route_name': route_name})
|
||||
# Preserve every existing usage record (614 usage groups, never 614 finished assets).
|
||||
usage_inputs = ['facility_usage_slots.json', 'character_usage_slots.json',
|
||||
'other_usage_slots.json', 'state_usage_groups.json']
|
||||
usage_snapshots = {}
|
||||
for filename in usage_inputs:
|
||||
path = P22 + filename
|
||||
records = decode(read_source(root, path))
|
||||
usage_snapshots[path] = {'source': source(root, path), 'records': records}
|
||||
for record in records:
|
||||
anchors = record['source_anchors']
|
||||
for a in anchors:
|
||||
require(a['sha256'] == BASE_HASH and a['text'] == lines[a['line']-1], 'P22 citation')
|
||||
representations = []
|
||||
for rep in record.get('representations', []):
|
||||
pin = source(root, rep['path'])
|
||||
representations.append(dict(pin, historical_inventory=rep,
|
||||
current_bytes_equal_inventory=pin['sha256'] == rep['sha256']))
|
||||
kind = record.get('kind', 'usage')
|
||||
status = 'missing'
|
||||
if representations:
|
||||
status = 'code-drawing' if all(r['historical_inventory']['representation'] == 'code_drawn'
|
||||
for r in representations) else 'placeholder'
|
||||
slot_id = record.get('slot_id', record.get('group_id'))
|
||||
add('usage.' + slot_id, kind, [(a['line'],) for a in anchors],
|
||||
record['usage_condition'], 'P26-03', status, representations,
|
||||
'欠損: 利用枠/用途群。既存表現の現物SHAを再取得。汎用描画/仮素材は固有人物・必須差分の完成証明ではない。D09数量/品質/担当未決。',
|
||||
{'usage_source': path, 'slot_id': slot_id})
|
||||
references = {p: source(root, p) for p in [P19, BACKLOG] +
|
||||
[P22 + n for n in ['contract.json', 'source_pins.json', 'current_media_inventory.json']]}
|
||||
actual_support = [excerpt(root, 'data/characters/mvp_unique_characters.json', 1, 32),
|
||||
excerpt(root, 'scripts/relationship/original_relationship_state.gd', 4, 34),
|
||||
excerpt(root, 'autoload/simulation_runtime.gd', 6791, 6812),
|
||||
excerpt(root, 'scripts/relationship/relationship_service.gd', 423, 479),
|
||||
excerpt(root, 'scripts/ui/pixel_people.gd', 4, 18)]
|
||||
media = decode(read_source(root, P22 + 'current_media_inventory.json'))
|
||||
current_media = [dict(source(root, m['path']), historical_inventory=m) for m in media]
|
||||
pins = decode((root / OUT / 'FROZEN_GAME_INPUT_PINS.json').read_bytes())
|
||||
available_assets = {p: v for p, v in pins['source_files'].items() if p.startswith('assets/')}
|
||||
original_lines, required_lines, atoms, relevant, packages = authorities(root)
|
||||
# A separate row for every selected original clause prevents a broad category
|
||||
# citation from concealing an omitted event/control requirement.
|
||||
for n in sorted(required_lines):
|
||||
add('original.line.' + str(n), 'original-clause', [(n,)],
|
||||
'原本の必要条件。個別の脚本/台詞/CG/音声/場面数は定義しない。',
|
||||
'undefined', 'missing', reason='原本条件を保存。専用制作内容は欠損、場面ID・数量は未定義。下記AR所有先で残作業を追跡。')
|
||||
relationship_paths = ['scripts/relationship/original_relationship_state.gd',
|
||||
'scripts/relationship/relationship_service.gd']
|
||||
# Direct source inspection is limited to explicit state/container support.
|
||||
# These excerpts never assert authored scenes, usable observation controls,
|
||||
# or a naturally reachable character route.
|
||||
require('class_name OriginalRelationshipState' in read_source(root, relationship_paths[0]).decode(),
|
||||
'relationship class evidence')
|
||||
peep_pin = excerpt(root, 'scripts/special/peeping_room_service.gd', 127, 162)
|
||||
peep_runtime_pin = excerpt(root, 'autoload/simulation_runtime.gd', 10860, 10880)
|
||||
require('RECORDER_ELIGIBILITY_PENDING_D04_D08_P16' in peep_pin['text'] and
|
||||
'BUILD_DEFINITION_PENDING_D04' in peep_runtime_pin['text'], 'peeping backend boundary')
|
||||
dispositions = {}
|
||||
for row in rows:
|
||||
cited = {n for c in row['citations'] for n in range(c['lines'][0], c['lines'][1] + 1)}
|
||||
matches = sorted(a for a in relevant if cited.intersection(atomic_lines(atoms[a])))
|
||||
if row['kind'] == 'route':
|
||||
matches = ['AR.heroine.' + row['identity']['character_id'] + '.route.' + row['identity']['route_id']]
|
||||
row['atomic_requirement_ids'] = matches
|
||||
owners = sorted({atoms[a]['primary_package'] for a in matches})
|
||||
row['owner'] = '/'.join(owners) if owners else 'undefined'
|
||||
row['owner_deliverable_refs'] = owners
|
||||
row['owner_assignment'] = 'original package deliverables; specific production assignee undefined'
|
||||
row['content_status'] = row['status']
|
||||
row['support_scope'] = 'no dedicated support established'
|
||||
row['scoped_support'] = []
|
||||
for kind, policy in SUPPORT_POLICY.items():
|
||||
supported = sorted(set(matches) & policy['ars']) if support_family(row) else []
|
||||
if supported:
|
||||
row['scoped_support'].append({'kind': kind, 'atomic_requirement_ids': supported,
|
||||
'scope': policy['scope'], 'content_status': 'missing'})
|
||||
row['evidence'] += support_pins(root, kind)
|
||||
supported_ids = {a for claim in row['scoped_support'] for a in claim['atomic_requirement_ids']}
|
||||
row['unsupported_atomic_requirement_ids'] = sorted(set(matches) - supported_ids)
|
||||
excluded = sorted(set(matches) & EXCLUDED_ATTRIBUTIONS.keys())
|
||||
if excluded:
|
||||
row['reason'] += ' ' + '; '.join(a + ': uncredited precursor, not required behaviour (' +
|
||||
EXCLUDED_ATTRIBUTIONS[a] + ')' for a in excluded) + '。関連基盤コードは存在するが当該ARの証拠には帰属させない。'
|
||||
if row['scoped_support']:
|
||||
row['status'] = 'partial'
|
||||
row['support_scope'] = '; '.join(c['scope'] for c in row['scoped_support'])
|
||||
row['reason'] += ' scoped_supportに列挙したARの基盤断片のみpartial。他のARは未支援、全制作内容は欠損。'
|
||||
if row['id'].startswith('clause.'):
|
||||
row['reason'] += ' 所有先は引用ARのprimary_packageと原P単位定義。包括P26-03への割当はしない。'
|
||||
for identity, atom in atoms.items():
|
||||
linked = [r['id'] for r in rows if identity in r['atomic_requirement_ids']]
|
||||
dispositions[identity] = {
|
||||
'record': {k: atom[k] for k in ('id', 'condition', 'source_anchors',
|
||||
'source_sha256', 'primary_package', 'work_packages')},
|
||||
'rows': linked,
|
||||
'disposition': 'covered-by-row' if identity in relevant else 'out-of-scope-with-reason',
|
||||
'reason': ('原本条件・欠損は各行に記録。ARの旧監査合否は転記しない。'
|
||||
if identity in relevant else 'L226カットイン警告・決闘/防衛演出はP24 UIの所管。制作/受入済みを意味しない。'
|
||||
if identity in {'AR.ui.cut_duel', 'AR.ui.cut_defense'} else
|
||||
'原本内容行に結合しない追加施設契約/採用・財布条件、または内容語彙/対象節/内容ARドメインに該当しない独立メカニクス。製品受入は当該原P単位の責任。'),
|
||||
'owning_deliverables': owning_units(atom, packages)}
|
||||
approval_raw = blob(root, APPROVAL_COMMIT, APPROVAL_PATH)
|
||||
require(sha(approval_raw) == APPROVAL_HASH, 'published D05 approval')
|
||||
approval = {'commit': APPROVAL_COMMIT, 'path': APPROVAL_PATH,
|
||||
'sha256': APPROVAL_HASH, 'record': decode(approval_raw),
|
||||
'scope': 'actual approval received; P04 product adoption remains outside P26'}
|
||||
return {'task_id': 'P26-01-LEDGER', 'schema_version': 1, 'source_anchor': ANCHOR,
|
||||
'definition_base': BASE, 'rows': rows, 'usage_snapshots': usage_snapshots,
|
||||
'coverage_policy': {'line_selector': CONTENT_WORDS.pattern,
|
||||
'whole_sections': '世界観|ユニークキャラクター|恋愛|デザイン|アセット|ロードマップ',
|
||||
'ar_domains': list(CONTENT_DOMAINS), 'selected_original_lines': sorted(required_lines),
|
||||
'relevant_ar_ids': sorted(relevant)},
|
||||
'atomic_authority': source(root, AR_PATH), 'atomic_dispositions': dispositions,
|
||||
'owning_package_definitions': {p: {'title': v['title'],
|
||||
'remaining_deliverables': [{k: u[k] for k in ('id', 'title', 'work', 'acceptance')}
|
||||
for u in v['remaining_deliverables']]} for p, v in packages.items()},
|
||||
'current_D05_approval': approval,
|
||||
'published_material': {'anchor': PUBLISHED, 'manifest_sha256': MANIFEST_HASH,
|
||||
'manifest_path': ARCHIVE + 'ROOT_MATERIAL_PRESERVATION_MANIFEST.json'},
|
||||
'references': references, 'P22_03_authority': 'draft/unaccepted reference only',
|
||||
'actual_support': actual_support,
|
||||
'support_interpretation': 'Explicit state/identity/container support only; generic pose has no original character-specific appearance. No finished chapter/dialogue/portrait/voice/encyclopedia inferred.',
|
||||
'current_media_reaudit': current_media,
|
||||
'available_asset_source_set': available_assets,
|
||||
'pending': {'D05': 'Historical register only: unresolved as of audit anchor f4f85bc54c39361f9efcd16f67a17835559ca70c and base 379a1ba8fd25b5ad24226178a2492f91761dc31d. Current actual approval is separately pinned; P04 adoption pending outside P26.',
|
||||
'D08': 'unresolved', 'D09': 'unresolved'},
|
||||
'accounting': {'packages': 30, 'units': 88, 'completed': 15, 'credit': 0,
|
||||
'partial_credit': False, 'whole_unit_accepted': False},
|
||||
'native_evidence': {'reuse_accepted': False, 'new_full_run': False,
|
||||
'historical_only': {'suites': 184, 'checks': 21903, 'parse': 329, 'owned_ended': 186},
|
||||
'reason': 'Runner must independently establish exact S/C applicability; no native result claimed here.'}}
|
||||
|
||||
|
||||
def verify_authority(candidate, root):
|
||||
"""Non-canonical coverage/ownership/citation checks, callable on their own."""
|
||||
for policy in SUPPORT_POLICY.values():
|
||||
require(not (set(policy['ars']) & EXCLUDED_ATTRIBUTIONS.keys()),
|
||||
'excluded attribution in SUPPORT_POLICY')
|
||||
lines, required, atoms, relevant, packages = authorities(root)
|
||||
seen_lines, seen_atoms = set(), set()
|
||||
row_ids = {r['id'] for r in candidate['rows']}
|
||||
for row in candidate['rows']:
|
||||
require(set(row) == {'id', 'kind', 'identity', 'citations', 'required_condition',
|
||||
'status', 'required_count', 'completed_count', 'evidence', 'missing', 'reason',
|
||||
'owner', 'atomic_requirement_ids', 'owner_deliverable_refs', 'owner_assignment',
|
||||
'content_status', 'support_scope', 'scoped_support',
|
||||
'unsupported_atomic_requirement_ids'}, 'closed row schema')
|
||||
require(row['status'] in STATUSES, 'invalid row status')
|
||||
require(row['required_count'] is None and row['completed_count'] is None, 'invented quantity')
|
||||
require(type(row['atomic_requirement_ids']) is list, 'AR ID list')
|
||||
ids = row['atomic_requirement_ids']
|
||||
require(set(ids) <= relevant, 'unknown or unrelated AR ID')
|
||||
owners = sorted({atoms[a]['primary_package'] for a in ids})
|
||||
require(row['owner'] == ('/'.join(owners) if owners else 'undefined') and
|
||||
row['owner_deliverable_refs'] == owners, 'AR owner mismatch')
|
||||
cited = set()
|
||||
for c in row['citations']:
|
||||
lo, hi = c['lines']
|
||||
require(type(lo) is int and type(hi) is int and 1 <= lo <= hi <= len(lines), 'citation lines')
|
||||
require(c['path'] == BASELINE and c['sha256'] == BASE_HASH and
|
||||
c['text'] == '\n'.join(lines[lo-1:hi]), 'original citation integrity')
|
||||
cited.update(range(lo, hi + 1))
|
||||
require(all(cited.intersection(atomic_lines(atoms[a])) for a in ids), 'AR citation correspondence')
|
||||
seen_lines.update(cited)
|
||||
seen_atoms.update(ids)
|
||||
require(row['content_status'] in STATUSES and row['content_status'] != 'produced', 'unsupported content production')
|
||||
require(row['status'] != 'produced', 'partial support falsely produced')
|
||||
# Inspect submitted claims and pins directly, without expected() or a
|
||||
# generated row. The policy is a normative compatibility table, not a
|
||||
# keyword classifier. A valid hash alone cannot establish relevance.
|
||||
claims = row['scoped_support']
|
||||
require(type(claims) is list, 'support claims type')
|
||||
claimed_ids, kinds, allowed_pins = set(), set(), []
|
||||
for claim in claims:
|
||||
require(set(claim) == {'kind', 'atomic_requirement_ids', 'scope', 'content_status'},
|
||||
'closed support claim')
|
||||
kind = claim['kind']
|
||||
require(kind in SUPPORT_POLICY and kind not in kinds, 'unknown/duplicate support kind')
|
||||
policy = SUPPORT_POLICY[kind]
|
||||
require(support_family(row), 'support incompatible with requirement family')
|
||||
targets = claim['atomic_requirement_ids']
|
||||
require(not (set(targets) & EXCLUDED_ATTRIBUTIONS.keys()),
|
||||
'excluded attribution in row claim')
|
||||
require(type(targets) is list and targets and len(targets) == len(set(targets)) and
|
||||
set(targets) <= set(ids) & policy['ars'], 'support incompatible with AR')
|
||||
require(claim['scope'] == policy['scope'] and claim['content_status'] == 'missing',
|
||||
'support scope/content mismatch')
|
||||
kinds.add(kind)
|
||||
claimed_ids.update(targets)
|
||||
for path, span in policy['sources']:
|
||||
pin = source(root, path)
|
||||
if span:
|
||||
pin.update(lines=list(span), text='\n'.join(
|
||||
read_source(root, path).decode().splitlines()[span[0]-1:span[1]]))
|
||||
allowed_pins.append(pin)
|
||||
require(row['unsupported_atomic_requirement_ids'] == sorted(set(ids) - claimed_ids),
|
||||
'unsupported AR partition')
|
||||
require((row['status'] == 'partial') == bool(claims),
|
||||
'route support/content distinction' if row['kind'] == 'route' else 'unqualified partial support')
|
||||
if claims:
|
||||
require(row['support_scope'] == '; '.join(c['scope'] for c in claims) and
|
||||
row['content_status'] == 'missing', 'support summary/content mismatch')
|
||||
else:
|
||||
require(row['support_scope'] == 'no dedicated support established', 'unsupported scope text')
|
||||
if not row['id'].startswith('usage.'):
|
||||
if row['kind'] == 'route':
|
||||
allowed_pins.insert(0, dict(source(root, P19), cell=[
|
||||
row['identity']['character_id'], row['identity']['route_id']]))
|
||||
require(encoded(row['evidence']) == encoded(allowed_pins), 'unrelated or unscoped support evidence')
|
||||
else:
|
||||
require(not claims, 'usage cannot inherit backend support')
|
||||
if row['kind'] in {'voice', 'ending', 'encyclopedia'}:
|
||||
require(row['status'] == row['content_status'] == 'missing' and not row['evidence'],
|
||||
'unsupported content family')
|
||||
for pin in row['evidence']:
|
||||
require(encoded({k: pin[k] for k in ('path', 'sha256', 'bytes', 'anchor', 'anchor_path')}) ==
|
||||
encoded(source(root, pin['path'])), 'row evidence integrity')
|
||||
if row['id'].startswith('usage.'):
|
||||
reps = row['evidence']
|
||||
status = ('code-drawing' if all(p['historical_inventory']['representation'] == 'code_drawn'
|
||||
for p in reps) else 'placeholder') if reps else 'missing'
|
||||
require(row['status'] == status and row['content_status'] == status, 'usage representation status')
|
||||
if row['kind'] == 'route':
|
||||
require(row['status'] == 'partial' and row['content_status'] == 'missing', 'route support/content distinction')
|
||||
identity = row['identity']
|
||||
require((identity['character_id'], identity['original_name']) in PEOPLE and
|
||||
ROUTES.get(identity['route_id']) == identity['route_name'], 'original route identity')
|
||||
require(ids == ['AR.heroine.' + identity['character_id'] + '.route.' + identity['route_id']], 'route AR identity')
|
||||
require(required <= seen_lines, 'independent original line coverage')
|
||||
require(relevant <= seen_atoms, 'independent AR coverage')
|
||||
require(set(candidate['atomic_dispositions']) == set(atoms), 'AR disposition set')
|
||||
for identity, atom in atoms.items():
|
||||
entry = candidate['atomic_dispositions'][identity]
|
||||
require(entry['record'] == {k: atom[k] for k in entry['record']} and
|
||||
set(entry['record']) == {'id', 'condition', 'source_anchors', 'source_sha256', 'primary_package', 'work_packages'},
|
||||
'stale AR record')
|
||||
require(entry['owning_deliverables'] == owning_units(atom, packages), 'AR owning deliverables')
|
||||
require(entry['reason'] and entry.get('disposition') == (
|
||||
'covered-by-row' if identity in relevant else 'out-of-scope-with-reason'), 'missing AR disposition')
|
||||
require(set(entry['rows']) <= row_ids and set(entry['rows']) == {
|
||||
r['id'] for r in candidate['rows'] if identity in r['atomic_requirement_ids']}, 'AR row linkage')
|
||||
require(identity not in relevant or entry['rows'], 'AR without content row')
|
||||
|
||||
|
||||
def verify(candidate, canonical, root=None):
|
||||
require(type(candidate) is dict, 'closed document object')
|
||||
require(type(candidate.get('rows')) is list, 'closed rows list')
|
||||
rows = candidate['rows']
|
||||
ids = [r['id'] for r in rows]
|
||||
require(len(ids) == len(set(ids)), 'duplicate row ID')
|
||||
require(set(ids) == {r['id'] for r in canonical['rows']}, 'original clause/slot coverage')
|
||||
for row in rows:
|
||||
require(row['status'] in STATUSES, 'invalid status')
|
||||
require(row['required_count'] is None and row['completed_count'] is None, 'invented quantity')
|
||||
require(row['status'] != 'produced', 'unsupported completion')
|
||||
require(row['citations'] and row['reason'] and row['missing'], 'missing traceability')
|
||||
require(root is not None, 'independent authority root required')
|
||||
verify_authority(candidate, root)
|
||||
require(encoded(candidate) == encoded(canonical), 'ledger differs from original/source-bound audit')
|
||||
|
||||
|
||||
def markdown(document):
|
||||
# A lossless deterministic human view, including exact citations and source hashes.
|
||||
lines = ['# P26-01 全必要内容の台帳', '',
|
||||
'15/88、今回加算0。未制作/未定義は欠損。利用枠数は完成数ではない。', '',
|
||||
'D05の過去未解決記録はf4f85bc/379a1ba時点。現行承認は別途固定。D08/D09未解決。P22-03は未受入参考。', '']
|
||||
for row in document['rows']:
|
||||
lines += ['## ' + row['id'], '', '- 種別: ' + row['kind'],
|
||||
'- 識別: ' + encoded(row['identity']),
|
||||
'- 状態: ' + row['status'], '- 所管: ' + row['owner'],
|
||||
'- 内容状態: ' + row['content_status'], '- 部分支援範囲: ' + row['support_scope'],
|
||||
'- AR: ' + encoded(row['atomic_requirement_ids']),
|
||||
'- AR別部分支援(列挙ARの基盤断片のみ、制作内容はmissing): ' + encoded(row['scoped_support']),
|
||||
'- 基盤支援なしのAR: ' + encoded(row['unsupported_atomic_requirement_ids']),
|
||||
'- 所管単位: ' + encoded(row['owner_deliverable_refs']) + ' (固定参照の原P単位定義を参照)',
|
||||
'- 条件: ' + (row['required_condition'] if isinstance(row['required_condition'], str)
|
||||
else encoded(row['required_condition'])), '- 理由: ' + row['reason'],
|
||||
'- 必要数/完成数: 未定義 / 未計数', '- 欠損: ' + row['missing'], '']
|
||||
for c in row['citations']:
|
||||
lines += [f"原本 `{c['path']}:{c['lines'][0]}-{c['lines'][1]}` SHA256 `{c['sha256']}`", '',
|
||||
'```text', c['text'], '```', '']
|
||||
lines += ['証拠(空配列は明示的欠損):', '', '```json',
|
||||
json.dumps(row['evidence'], ensure_ascii=False, indent=2), '```', '']
|
||||
lines += ['## 固定参照・監査境界', '', '```json',
|
||||
json.dumps({k: v for k, v in document.items() if k != 'rows'}, ensure_ascii=False, indent=2), '```', '']
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def check_view(raw, document):
|
||||
require(raw == markdown(document).encode(), 'Markdown drift')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--project-root', type=Path, default=Path('.'))
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument('--check', action='store_true')
|
||||
mode.add_argument('--source-binding-only', action='store_true')
|
||||
mode.add_argument('--write', action='store_true')
|
||||
args = parser.parse_args()
|
||||
root = args.project_root.resolve()
|
||||
try:
|
||||
result = binding(root)
|
||||
canonical = expected(root)
|
||||
if args.write:
|
||||
(root / OUT / 'CONTENT_LEDGER.json').write_text(json.dumps(canonical, ensure_ascii=False, indent=2) + '\n')
|
||||
(root / OUT / 'CONTENT_LEDGER.md').write_text(markdown(canonical))
|
||||
else:
|
||||
document = decode((root / OUT / 'CONTENT_LEDGER.json').read_bytes())
|
||||
verify(document, canonical, root)
|
||||
if args.check:
|
||||
check_view((root / OUT / 'CONTENT_LEDGER.md').read_bytes(), document)
|
||||
print(json.dumps({'documentation_check': 'pass', **result}, ensure_ascii=False))
|
||||
return 0
|
||||
except (ValueError, KeyError, TypeError, OSError, subprocess.CalledProcessError) as error:
|
||||
print('P26 audit rejected: ' + str(error), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runner negative controls for P26; no fixture writes and no native execution."""
|
||||
import argparse
|
||||
import copy
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import check_content_ledger as audit
|
||||
|
||||
|
||||
class LedgerTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.canonical = audit.expected(ROOT)
|
||||
cls.submitted = audit.decode((ROOT / audit.OUT / 'CONTENT_LEDGER.json').read_bytes())
|
||||
|
||||
def reject(self, mutate):
|
||||
audit.verify(self.submitted, self.canonical, ROOT)
|
||||
value = copy.deepcopy(self.submitted)
|
||||
mutate(value)
|
||||
with self.assertRaises((ValueError, TypeError, KeyError)):
|
||||
audit.verify(value, self.canonical, ROOT)
|
||||
|
||||
def test_valid_submitted_and_independent_source_control(self):
|
||||
audit.verify(self.submitted, self.canonical, ROOT)
|
||||
audit.verify(copy.deepcopy(self.canonical), self.canonical, ROOT)
|
||||
audit.check_view((ROOT / audit.OUT / 'CONTENT_LEDGER.md').read_bytes(), self.submitted)
|
||||
|
||||
def test_required_sets_are_independent_of_submitted_rows(self):
|
||||
ids = {r['id'] for r in self.canonical['rows']}
|
||||
self.assertTrue({'chapters.all', 'endings.all', 'endings.bad', 'endings.game_over',
|
||||
'dialogue.all', 'portraits.all', 'voice.all', 'encyclopedia.all'} <= ids)
|
||||
self.assertEqual({r['id'] for r in self.canonical['rows'] if r['kind'] == 'route'},
|
||||
{'route.' + p + '.' + r for p, _ in audit.PEOPLE for r in audit.ROUTES})
|
||||
|
||||
def test_omitted_original_categories_and_route(self):
|
||||
for identity in ['chapters.all', 'route.satsuki.corruption', 'endings.all',
|
||||
'endings.bad', 'endings.game_over', 'dialogue.all', 'voice.all',
|
||||
'portraits.all', 'encyclopedia.all', 'clause.route_rules']:
|
||||
with self.subTest(identity=identity):
|
||||
self.reject(lambda d: d.update(rows=[r for r in d['rows'] if r['id'] != identity]))
|
||||
|
||||
def test_missing_usage_slot(self):
|
||||
self.reject(lambda d: d.update(rows=[r for r in d['rows'] if r['id'] !=
|
||||
next(x['id'] for x in d['rows'] if x['id'].startswith('usage.'))]))
|
||||
|
||||
def test_fabricated_quantity_and_false_completion(self):
|
||||
self.reject(lambda d: d['rows'][0].update(required_count=8))
|
||||
self.reject(lambda d: d['rows'][0].update(completed_count=0))
|
||||
self.reject(lambda d: d['rows'][0].update(status='produced', missing=None))
|
||||
|
||||
def test_closed_schema_owner_status_type_and_duplicates(self):
|
||||
for field, value in [('owner', 'P99-99'), ('status', 'pass'), ('kind', 'invented'),
|
||||
('required_count', False), ('citations', []), ('extra', True)]:
|
||||
with self.subTest(field=field):
|
||||
self.reject(lambda d: d['rows'][0].update({field: value}))
|
||||
self.reject(lambda d: d['rows'].append(copy.deepcopy(d['rows'][0])))
|
||||
self.reject(lambda d: d.update(schema_version=True))
|
||||
|
||||
def test_exact_route_identity(self):
|
||||
self.reject(lambda d: next(r for r in d['rows'] if r['kind'] == 'route')
|
||||
['identity'].update(original_name='別人'))
|
||||
|
||||
def test_stale_original_and_source(self):
|
||||
self.reject(lambda d: d['rows'][0]['citations'][0].update(text='stale'))
|
||||
self.reject(lambda d: d['rows'][0]['citations'][0].update(sha256='0' * 64))
|
||||
self.reject(lambda d: d['references'][audit.P19].update(sha256='0' * 64))
|
||||
self.reject(lambda d: d.update(source_anchor='HEAD'))
|
||||
|
||||
def test_malformed_json_duplicate_keys_and_nonfinite(self):
|
||||
self.assertEqual(audit.decode('{"rows": []}'), {'rows': []})
|
||||
for raw in ['{', '{"x":1,"x":2}', '{"x":NaN}', '{"x":Infinity}']:
|
||||
with self.subTest(raw=raw), self.assertRaises(ValueError):
|
||||
audit.decode(raw)
|
||||
|
||||
def test_markdown_drift(self):
|
||||
view = audit.markdown(self.submitted).encode()
|
||||
audit.check_view(view, self.submitted)
|
||||
with self.assertRaises(ValueError):
|
||||
audit.check_view(view + b'\n', self.submitted)
|
||||
|
||||
def test_definition_projection_allows_only_progress(self):
|
||||
packages = audit.decode(audit.blob(ROOT, audit.BASE, audit.PLAN + 'work_packages.json'))
|
||||
changed = copy.deepcopy(packages)
|
||||
changed[0]['state'] = 'progress-only-control'
|
||||
changed[0]['remaining_deliverables'][0]['evidence'] = ['progress-only-control']
|
||||
self.assertEqual(audit.encoded(audit.projection(packages)), audit.encoded(audit.projection(changed)))
|
||||
for field in ['acceptance', 'work', 'depends_on', 'requirement_ids']:
|
||||
changed = copy.deepcopy(packages)
|
||||
changed[0][field] = 'mutated definition'
|
||||
self.assertNotEqual(audit.encoded(audit.projection(packages)), audit.encoded(audit.projection(changed)))
|
||||
|
||||
def test_published_source_changed_bytes_rejected(self):
|
||||
original = audit.blob
|
||||
def stale(root, anchor, path):
|
||||
raw = original(root, anchor, path)
|
||||
return raw + b' ' if path == audit.ARCHIVE + 'ROOT_MATERIAL_PRESERVATION_MANIFEST.json' else raw
|
||||
with patch.object(audit, 'blob', stale), self.assertRaises(ValueError):
|
||||
audit.expected(ROOT)
|
||||
|
||||
def test_selected_input_mutation_rejected(self):
|
||||
actual = audit.selection(ROOT)
|
||||
# Valid map control comes from frozen independent pins, not a second selection call.
|
||||
pins = audit.decode((ROOT / audit.OUT / 'FROZEN_GAME_INPUT_PINS.json').read_bytes())
|
||||
self.assertEqual(audit.encoded(actual), audit.encoded(pins['source_files']))
|
||||
for mode in ('missing', 'new', 'changed', 'wrong_type'):
|
||||
changed = copy.deepcopy(actual)
|
||||
key = 'project.godot'
|
||||
if mode == 'missing':
|
||||
del changed[key]
|
||||
elif mode == 'new':
|
||||
changed['data/unapproved.json'] = {'bytes': 0, 'sha256': '0' * 64}
|
||||
elif mode == 'changed':
|
||||
changed[key]['sha256'] = '0' * 64
|
||||
else:
|
||||
changed[key]['bytes'] = str(changed[key]['bytes'])
|
||||
with self.subTest(mode=mode), patch.object(audit, 'selection', return_value=changed), self.assertRaises(ValueError):
|
||||
audit.binding(ROOT)
|
||||
|
||||
def independent_reject(self, mutate, message):
|
||||
# The positive control and rejection invoke only the independent layer:
|
||||
# no canonical document/equality can make these tests pass.
|
||||
audit.verify_authority(self.submitted, ROOT)
|
||||
value = copy.deepcopy(self.submitted)
|
||||
mutate(value)
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
audit.verify_authority(value, ROOT)
|
||||
|
||||
def test_omitted_review_event_clauses(self):
|
||||
for n in (13, 53, 65, 196, 221, 222):
|
||||
with self.subTest(line=n):
|
||||
self.reject(lambda d: d.update(rows=[r for r in d['rows']
|
||||
if r['id'] != 'original.line.' + str(n)]))
|
||||
# Remove a line from every citation (including broad category ranges),
|
||||
# preserving other lines, AR links and IDs: authority coverage must fail.
|
||||
def omit(d):
|
||||
for row in d['rows']:
|
||||
row['citations'] = [c for c in row['citations']
|
||||
if not c['lines'][0] <= 65 <= c['lines'][1]]
|
||||
self.independent_reject(omit, 'AR citation correspondence|independent original line coverage')
|
||||
|
||||
def test_atomic_coverage_unknown_and_missing_disposition(self):
|
||||
identity = 'AR.events.ntr_night_peak'
|
||||
def remove(d):
|
||||
for row in d['rows']:
|
||||
if identity in row['atomic_requirement_ids']:
|
||||
row['atomic_requirement_ids'].remove(identity)
|
||||
if identity in row['unsupported_atomic_requirement_ids']:
|
||||
row['unsupported_atomic_requirement_ids'].remove(identity)
|
||||
for claim in row['scoped_support']:
|
||||
if identity in claim['atomic_requirement_ids']:
|
||||
claim['atomic_requirement_ids'].remove(identity)
|
||||
atoms = audit.authorities(ROOT)[2]
|
||||
owners = sorted({atoms[a]['primary_package'] for a in row['atomic_requirement_ids']})
|
||||
row.update(owner='/'.join(owners) if owners else 'undefined', owner_deliverable_refs=owners)
|
||||
self.independent_reject(remove, 'independent AR coverage')
|
||||
self.independent_reject(lambda d: d['rows'][0]['atomic_requirement_ids'].append('AR.unknown'), 'unknown or unrelated AR ID')
|
||||
self.independent_reject(lambda d: d['atomic_dispositions'][identity].pop('disposition'), 'disposition')
|
||||
|
||||
def test_owners_route_usage_and_partial_truth(self):
|
||||
for prefix in ('route.', 'usage.', 'original.line.65'):
|
||||
with self.subTest(prefix=prefix):
|
||||
self.independent_reject(lambda d: next(r for r in d['rows']
|
||||
if r['id'].startswith(prefix)).update(owner='P26-03'), 'AR owner mismatch')
|
||||
self.independent_reject(lambda d: next(r for r in d['rows'] if r['kind']=='route')
|
||||
.update(status='missing'), 'route support/content distinction')
|
||||
self.independent_reject(lambda d: next(r for r in d['rows'] if r['id'].startswith('usage.') and r['evidence'])
|
||||
.update(status='missing'), 'usage representation status')
|
||||
self.independent_reject(lambda d: next(r for r in d['rows'] if r['status']=='partial')
|
||||
.update(status='produced'), 'partial support falsely produced')
|
||||
|
||||
def test_stale_atomic_original_and_usage_pins(self):
|
||||
identity = 'AR.events.ntr_night_peak'
|
||||
self.independent_reject(lambda d: d['atomic_dispositions'][identity]['record']
|
||||
.update(source_sha256='0'*64), 'stale AR record')
|
||||
self.independent_reject(lambda d: d['rows'][0]['citations'][0]
|
||||
.update(sha256='0'*64), 'original citation integrity')
|
||||
self.independent_reject(lambda d: next(r for r in d['rows'] if r['id'].startswith('usage.') and r['evidence'])
|
||||
['evidence'][0].update(sha256='0'*64), 'row evidence integrity')
|
||||
|
||||
def test_closed_support_attribution_independent_controls_and_injection(self):
|
||||
rows = {r['id']: r for r in self.submitted['rows']}
|
||||
audit.verify_authority(self.submitted, ROOT)
|
||||
for identity, kind in [('route.anri.love', 'relationship-container'),
|
||||
('original.line.138', 'relationship-container'),
|
||||
('original.line.140', 'relationship-container'),
|
||||
('original.line.65', 'night-input'),
|
||||
('original.line.53', 'peeping-container'),
|
||||
('original.line.34', 'peeping-container'),
|
||||
('original.line.72', 'bankruptcy-bookkeeping')]:
|
||||
with self.subTest(control=identity):
|
||||
self.assertEqual(rows[identity]['status'], 'partial')
|
||||
self.assertEqual(rows[identity]['content_status'], 'missing')
|
||||
self.assertIn(kind, {c['kind'] for c in rows[identity]['scoped_support']})
|
||||
allowed_controls = {
|
||||
'AR.relationship.three_emotions', 'AR.relationship.affection_shield_max',
|
||||
'AR.underground.observation', 'AR.observation.recorder',
|
||||
'AR.guest.need_sexual_night_peak', 'AR.progression.three_deficits_gameover',
|
||||
} | {'AR.heroine.' + p + '.route.' + r for p, _ in audit.PEOPLE for r in audit.ROUTES}
|
||||
self.assertEqual({a for row in rows.values() for c in row['scoped_support']
|
||||
for a in c['atomic_requirement_ids']}, allowed_controls)
|
||||
for identity in ('voice.all', 'portraits.all', 'endings.all', 'encyclopedia.all'):
|
||||
self.assertEqual(rows[identity]['status'], 'missing')
|
||||
self.assertEqual(rows[identity]['evidence'], [])
|
||||
# Valid pins + the old textual qualifier must not confer support.
|
||||
def inject(d):
|
||||
row = next(r for r in d['rows'] if r['id'] == identity)
|
||||
row.update(status='partial', support_scope='backend only',
|
||||
evidence=copy.deepcopy(rows['original.line.53']['evidence']))
|
||||
self.independent_reject(inject, 'unqualified partial support')
|
||||
# Preserve the same-line voice attack: the old observation schedule AR
|
||||
# shares L257 with voice, even though it is now independently excluded.
|
||||
def voice_claim(d):
|
||||
voice = next(r for r in d['rows'] if r['id'] == 'voice.all')
|
||||
mixed = rows['original.line.53']
|
||||
for key in ('status', 'support_scope', 'scoped_support', 'evidence'):
|
||||
voice[key] = copy.deepcopy(mixed[key])
|
||||
atom = 'AR.schedule.B-14.3.observation'
|
||||
self.assertIn(atom, voice['atomic_requirement_ids'])
|
||||
voice['scoped_support'][0]['atomic_requirement_ids'] = [atom]
|
||||
self.independent_reject(voice_claim, 'support incompatible with requirement family')
|
||||
# An original-clause row is an eligible family, but its debt-settlement
|
||||
# ARs still cannot receive a peeping-container claim.
|
||||
def content_claim(d):
|
||||
row = next(r for r in d['rows'] if r['id'] == 'original.line.196')
|
||||
donor = rows['original.line.53']
|
||||
for key in ('status', 'support_scope', 'scoped_support', 'evidence'):
|
||||
row[key] = copy.deepcopy(donor[key])
|
||||
row['scoped_support'][0]['atomic_requirement_ids'] = row['atomic_requirement_ids'][:]
|
||||
row['unsupported_atomic_requirement_ids'] = []
|
||||
self.assertEqual(rows['original.line.196']['status'], 'missing')
|
||||
self.independent_reject(content_claim, 'support incompatible with AR')
|
||||
# Mixed rows retain missing voice/end/encyclopedia ARs; an attacker
|
||||
# cannot expand a valid backend claim to those ARs.
|
||||
for identity, atom in [('original.line.257', 'AR.schedule.B-14.3.voice_record'),
|
||||
('clause.production', 'AR.asset.character_voice'),
|
||||
('original.line.9', 'AR.progression.all_endings'),
|
||||
('original.line.9', 'AR.progression.encyclopedia_complete')]:
|
||||
with self.subTest(mixed=identity, atom=atom):
|
||||
self.assertIn(atom, rows[identity]['unsupported_atomic_requirement_ids'])
|
||||
def expand(d):
|
||||
row = next(r for r in d['rows'] if r['id'] == identity)
|
||||
if not row['scoped_support']:
|
||||
donor = rows['original.line.53']
|
||||
for key in ('status', 'support_scope', 'scoped_support', 'evidence'):
|
||||
row[key] = copy.deepcopy(donor[key])
|
||||
row['scoped_support'][0]['atomic_requirement_ids'] = []
|
||||
row['scoped_support'][0]['atomic_requirement_ids'].append(atom)
|
||||
row['unsupported_atomic_requirement_ids'].remove(atom)
|
||||
self.independent_reject(expand, 'support incompatible with AR')
|
||||
# A valid claim does not license an additional, unrelated pinned file.
|
||||
self.independent_reject(lambda d: next(r for r in d['rows'] if r['id']=='original.line.65')
|
||||
['evidence'].extend(copy.deepcopy(rows['original.line.53']['evidence'])),
|
||||
'unrelated or unscoped support evidence')
|
||||
|
||||
def excluded_attribution_rejected(self, atom, line, kind, control_atom, control_line):
|
||||
# Fixed cases below are independent of both policy and exclusion table.
|
||||
audit.verify_authority(self.submitted, ROOT)
|
||||
rows = {r['id']: r for r in self.submitted['rows']}
|
||||
control = rows['original.line.' + str(control_line)]
|
||||
self.assertTrue(any(c['kind'] == kind and control_atom in c['atomic_requirement_ids']
|
||||
for c in control['scoped_support']))
|
||||
target = rows['original.line.' + str(line)]
|
||||
self.assertIn(atom, target['unsupported_atomic_requirement_ids'])
|
||||
def inject(d):
|
||||
row = next(r for r in d['rows'] if r['id'] == target['id'])
|
||||
claim = {'kind': kind, 'atomic_requirement_ids': [atom],
|
||||
'scope': audit.SUPPORT_POLICY[kind]['scope'], 'content_status': 'missing'}
|
||||
row.update(status='partial', content_status='missing', scoped_support=[claim],
|
||||
support_scope=claim['scope'], evidence=audit.support_pins(ROOT, kind),
|
||||
unsupported_atomic_requirement_ids=sorted(set(row['atomic_requirement_ids']) - {atom}))
|
||||
self.independent_reject(inject, 'excluded attribution in row claim')
|
||||
policy = copy.deepcopy(audit.SUPPORT_POLICY)
|
||||
policy[kind]['ars'] = policy[kind]['ars'] | {atom}
|
||||
with patch.object(audit, 'SUPPORT_POLICY', policy):
|
||||
with self.assertRaisesRegex(ValueError, 'excluded attribution in SUPPORT_POLICY'):
|
||||
audit.verify_authority(self.submitted, ROOT)
|
||||
# Even regeneration under a corrupted policy cannot legitimize it.
|
||||
with self.assertRaisesRegex(ValueError, 'excluded attribution in SUPPORT_POLICY'):
|
||||
audit.verify_authority(audit.expected(ROOT), ROOT)
|
||||
audit.verify_authority(self.submitted, ROOT)
|
||||
|
||||
def test_excluded_camera_entry(self):
|
||||
self.excluded_attribution_rejected('AR.ui.observation_camera_entry', 221,
|
||||
'peeping-container', 'AR.underground.observation', 34)
|
||||
|
||||
def test_excluded_screenshot_capture(self):
|
||||
self.excluded_attribution_rejected('AR.ui.observation_capture', 222,
|
||||
'peeping-container', 'AR.observation.recorder', 53)
|
||||
|
||||
def test_excluded_blackmail(self):
|
||||
self.excluded_attribution_rejected('AR.underground.blackmail', 21,
|
||||
'peeping-container', 'AR.observation.recorder', 53)
|
||||
|
||||
def test_excluded_observation_route_progress(self):
|
||||
self.excluded_attribution_rejected('AR.route_action.observation_progress', 156,
|
||||
'peeping-container', 'AR.underground.observation', 34)
|
||||
|
||||
def test_excluded_dedicated_observation_mode(self):
|
||||
self.excluded_attribution_rejected('AR.schedule.B-14.3.observation', 257,
|
||||
'peeping-container', 'AR.observation.recorder', 53)
|
||||
|
||||
def test_excluded_ntr_event_night_peak(self):
|
||||
self.excluded_attribution_rejected('AR.events.ntr_night_peak', 65,
|
||||
'night-input', 'AR.guest.need_sexual_night_peak', 65)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--project-root', type=Path, default=Path('.'))
|
||||
args, remaining = parser.parse_known_args()
|
||||
ROOT = args.project_root.resolve()
|
||||
unittest.main(argv=[__file__] + remaining)
|
||||
Reference in New Issue
Block a user