$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot function Read-Page([string]$path) { Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding UTF8 } $contracts = [ordered]@{ 'pages/records/r03-gift-list.vue' = @('listRelativeRecordFixtures','relativeRecords','openRelative','createRelativePreview','relative-state--empty','openPage(','"R04",','relativeId') 'pages/records/r04-gift-editor.vue' = @('findRelativeRecordFixture','relativeForm','validateRelative','localRelativePreview','relativeId','relativeName','eventName','giftAmount','requestBack') 'pages/records/r05-ritual-list.vue' = @('listCeremonyFixtures','ceremonies','openCeremony','createCeremonyPreview','ceremony-state--empty','openPage(','"R06",','"R07",') 'pages/records/r06-ritual-detail.vue' = @('findCeremonyFixture','listTreeMemberPresentationFixtures','ceremonyDetail','invitees','inviteeUserId','memberOptions','editCeremony','ceremonyId','openPage(','"R07",','ceremony-state--expired') 'pages/records/r07-ritual-editor.vue' = @('findCeremonyFixture','ceremonyForm','validateCeremony','localCeremonyPreview','ceremonyId','ceremonyType','ceremonyTitle','requestBack') 'pages/records/r08-growth-journal.vue' = @('listGrowthRecordFixtures','findTreeMemberPresentationFixture','growthRecords','localGrowthPreview','recordGrowth','requestBack','timeline-state--empty') 'pages/records/r09-life-events.vue' = @('findTreeMemberPresentationFixture','人生事件接口尚未开放','serviceState','requestBack') 'pages/records/r10-memo-list.vue' = @('listMemoFixtures','memos','createMemoPreview','localMemoPreview','memoTitle','requestBack','memo-state--empty') 'pages/records/r11-merit-records.vue' = @('listMeritRecordFixtures','meritRecords','createMeritPreview','localMeritPreview','donorName','meritTitle','totalContribution','requestBack') } foreach ($entry in $contracts.GetEnumerator()) { $source = Read-Page $entry.Key foreach ($token in $entry.Value) { if (-not $source.Contains($token)) { throw "$($entry.Key) missing R contract: $token" } } foreach ($shared in @('ModulePageBackground','PageHeader','AppButton')) { if (-not $source.Contains($shared)) { throw "$($entry.Key) missing shared primitive: $shared" } } foreach ($forbidden in @( 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', '/pages/records/', 'saveResult', 'finishPage(', 'Date.now()', '.unshift(', 'giftId', 'ritualId', 'personName=', '已保存', '已删除' )) { if ($source.Contains($forbidden)) { throw "$($entry.Key) retains forbidden fake-write or old-route contract: $forbidden" } } if ($source -match ')|import\s+ModulePage\s+from') { throw "$($entry.Key) must own its business page" } if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$($entry.Key) must use project feedback primitives" } if ($source -match '(?im)(?= 0', 'String(Number(relativeForm.giftAmount))')) { if ($r04.Contains($invalidAmountRule)) { throw "R04 must preserve optional numeric amount semantics without inventing a boundary or string body: $invalidAmountRule" } } foreach ($inventedInvitationField in @('inviteStatusLabel', 'userId:', 'name:', 'relation:')) { if ($r06.Contains($inventedInvitationField)) { throw "R06 must join inviteeUserId to scoped member options and must not retain invented invitation fields: $inventedInvitationField" } } foreach ($previewGuard in @( @{ Path = 'pages/records/r04-gift-editor.vue'; Pattern = 'editorState\.value\s*===\s*"preview"' }, @{ Path = 'pages/records/r07-ritual-editor.vue'; Pattern = 'editorState\.value\s*===\s*"preview"' }, @{ Path = 'pages/records/r08-growth-journal.vue'; Pattern = 'dirty:\s*Boolean\(localGrowthPreview\.value\)' }, @{ Path = 'pages/records/r10-memo-list.vue'; Pattern = 'dirty:\s*Boolean\(localMemoPreview\.value\)' }, @{ Path = 'pages/records/r11-merit-records.vue'; Pattern = 'dirty:\s*Boolean\(localMeritPreview\.value\)' } )) { $source = Read-Page $previewGuard.Path if ($source -notmatch $previewGuard.Pattern) { throw "$($previewGuard.Path) must guard page back while a local preview remains unsaved" } } foreach ($dialogPage in @( 'pages/records/r04-gift-editor.vue', 'pages/records/r07-ritual-editor.vue', 'pages/records/r08-growth-journal.vue', 'pages/records/r10-memo-list.vue', 'pages/records/r11-merit-records.vue' )) { $source = Read-Page $dialogPage $dialogs = [regex]::Matches($source, '(?s)') if ($dialogs.Count -eq 0 -or ($dialogs | Where-Object { $_.Value -notmatch ':close-on-mask="false"' })) { throw "$dialogPage dialogs must ignore mask taps" } } $r11 = Read-Page 'pages/records/r11-merit-records.vue' foreach ($amountContract in @( 'type="digit"', 'Number.isFinite(Number(amountInput))', 'amount: amountInput ? Number(amountInput) : null', '金额数值(单位待确认)', 'localMeritPreview.meritType' )) { if (-not $r11.Contains($amountContract)) { throw "R11 optional numeric amount or preview visibility contract missing: $amountContract" } } $r10 = Read-Page 'pages/records/r10-memo-list.vue' foreach ($forbidden in @('toggleMemo', 'memo.done =', '点击标记完成', '点击恢复待办')) { if ($r10.Contains($forbidden)) { throw "R10 must not fake completed-state mutation: $forbidden" } } $r09 = Read-Page 'pages/records/r09-life-events.vue' foreach ($forbidden in @('lifeEvents', 'createLifeEvent', 'saveLifeEvent', 'growth-records', 'LIFE_EVENT')) { if ($r09.Contains($forbidden)) { throw "R09 must remain hard closed without a backend contract: $forbidden" } } $runtimeSources = @( 'tests/r-business-flow-runtime-smoke.js', 'tests/r02-person-detail-runtime-smoke.js', 'tests/r02-background-runtime-smoke.js', 'tests/module-page-runtime-smoke.js', 'tests/module-series-responsive-runtime-smoke.js' ) | ForEach-Object { Read-Page $_ } $runtimeText = $runtimeSources -join "`n" foreach ($retired in @('giftId', 'ritualId', 'count=50', 'r02-person-detail?personId=1', 'r08-growth-journal",".timeline-card')) { if ($runtimeText.Contains($retired)) { throw "R runtime smoke retains retired route or fake-volume contract: $retired" } } foreach ($requiredRuntimeFact in @( 'genealogyId=1001&mode=view&relativeId=301&sourceKey=R03', 'R08 local preview must not enter the official timeline', 'R11 local preview must not change the official count', 'personId=103', 'preview back guard missing' )) { if (-not $runtimeText.Contains($requiredRuntimeFact)) { throw "R runtime smoke misses current contract: $requiredRuntimeFact" } } Write-Output 'R-BUSINESS-FLOW-CONTRACT PASS'