Files
jiapuapp/tests/navigation-source-scan-contract.ps1
T
2026-07-23 08:24:07 +08:00

294 lines
13 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Remove-JavaScriptComments([string]$Content) {
# 注释不能伪造迁移计数;字符串必须保留,才能继续捕获方括号与 Reflect 取值。
$builder = [System.Text.StringBuilder]::new($Content.Length)
$state = 'code'
$quote = [char]0
$stringReturnState = 'code'
$commentReturnState = 'code'
$templateReturnStates = [System.Collections.Generic.Stack[string]]::new()
$templateExpressionDepths = [System.Collections.Generic.Stack[int]]::new()
for ($index = 0; $index -lt $Content.Length; $index += 1) {
$character = $Content[$index]
$next = if ($index + 1 -lt $Content.Length) { $Content[$index + 1] } else { [char]0 }
if ($state -eq 'template-string') {
[void]$builder.Append($character)
if ($character -eq [char]92 -and $index + 1 -lt $Content.Length) {
$index += 1
[void]$builder.Append($Content[$index])
} elseif ($character -eq [char]96) {
$state = $templateReturnStates.Pop()
} elseif ($character -eq [char]36 -and $next -eq [char]123) {
[void]$builder.Append($next)
$index += 1
$templateExpressionDepths.Push(1)
$state = 'template-code'
}
continue
}
if ($state -in @('code', 'template-code')) {
if ($state -eq 'template-code' -and $character -eq [char]123) {
$depth = $templateExpressionDepths.Pop() + 1
$templateExpressionDepths.Push($depth)
[void]$builder.Append($character)
} elseif ($state -eq 'template-code' -and $character -eq [char]125) {
$depth = $templateExpressionDepths.Pop() - 1
[void]$builder.Append($character)
if ($depth -eq 0) {
$state = 'template-string'
} else {
$templateExpressionDepths.Push($depth)
}
} elseif ($state -eq 'code' -and $character -eq [char]60 -and $index + 3 -lt $Content.Length -and $Content.Substring($index, 4) -eq '<!--') {
$state = 'html-comment'
[void]$builder.Append(' ')
$index += 3
} elseif ($character -eq [char]96) {
$templateReturnStates.Push($state)
$state = 'template-string'
[void]$builder.Append($character)
} elseif ($character -in @([char]39, [char]34)) {
$stringReturnState = $state
$state = 'string'
$quote = $character
[void]$builder.Append($character)
} elseif ($character -eq [char]47 -and $next -eq [char]47) {
$state = 'line-comment'
$commentReturnState = if ($templateExpressionDepths.Count -gt 0) { 'template-code' } else { 'code' }
[void]$builder.Append(' ')
$index += 1
} elseif ($character -eq [char]47 -and $next -eq [char]42) {
$state = 'block-comment'
$commentReturnState = if ($templateExpressionDepths.Count -gt 0) { 'template-code' } else { 'code' }
[void]$builder.Append(' ')
$index += 1
} else {
[void]$builder.Append($character)
}
continue
}
if ($state -eq 'string') {
[void]$builder.Append($character)
if ($character -eq [char]92 -and $index + 1 -lt $Content.Length) {
$index += 1
[void]$builder.Append($Content[$index])
} elseif ($character -eq $quote) {
$state = $stringReturnState
}
continue
}
if ($state -eq 'line-comment') {
if ($character -in @([char]10, [char]13)) {
$state = $commentReturnState
[void]$builder.Append($character)
} else {
[void]$builder.Append(' ')
}
continue
}
if ($state -eq 'html-comment') {
if ($character -eq [char]45 -and $index + 2 -lt $Content.Length -and $Content.Substring($index, 3) -eq '-->') {
$state = 'code'
[void]$builder.Append(' ')
$index += 2
} elseif ($character -in @([char]10, [char]13)) {
[void]$builder.Append($character)
} else {
[void]$builder.Append(' ')
}
continue
}
if ($character -eq [char]42 -and $next -eq [char]47) {
$state = $commentReturnState
[void]$builder.Append(' ')
$index += 1
} elseif ($character -in @([char]10, [char]13)) {
[void]$builder.Append($character)
} else {
[void]$builder.Append(' ')
}
}
return $builder.ToString()
}
function Remove-HtmlComments([string]$Content) {
return [regex]::Replace(
$Content,
'(?s)<!--.*?-->',
{ param($match) $match.Value -replace '[^\r\n]', ' ' }
)
}
function Get-ScannableSource([string]$Content, [string]$Extension) {
if ($Extension -ne '.vue') { return Remove-JavaScriptComments $Content }
# SFC 必须分段处理:先遮蔽顶层 script/style,再把完整 markup(含嵌套 template
# 按 HTML 处理;template 中的 https:// 绝不是 JS 注释。
$builder = [System.Text.StringBuilder]::new()
$codeBlockPattern = '(?is)<(?<Tag>script|style)\b[^>]*>(?<Body>.*?)</\k<Tag>>'
$blocks = [regex]::Matches(
$Content,
$codeBlockPattern
)
$markup = [regex]::Replace(
$Content,
$codeBlockPattern,
{ param($match) $match.Value -replace '[^\r\n]', ' ' }
)
[void]$builder.AppendLine((Remove-HtmlComments $markup))
foreach ($block in $blocks) {
[void]$builder.AppendLine((Remove-JavaScriptComments $block.Groups['Body'].Value))
}
return $builder.ToString()
}
$navigationMethodPattern = 'navigateTo|navigateBack|redirectTo|reLaunch|switchTab'
$navigationReferencePattern = '\b(?:' + $navigationMethodPattern + ')\b'
$switchTabReferencePattern = '\bswitchTab\b'
$stackReferencePattern = '\bgetCurrentPages\b'
$declarativeNavigationPattern = '<\s*navigator\b'
$dynamicUniAccessPattern = '(?:\buni\b|\(\s*uni\s*\))\s*(?:\?\.)?\s*\[|\bReflect\.(?:get|apply)\s*\(\s*uni\b'
$escapedUniValuePattern = '\buni\b(?!\s*(?:\?\.|\.|\[|-))'
foreach ($sample in @(
'uni.navigateTo',
'(uni).navigateBack',
'uni?.["redirectTo"]',
"const {`n reLaunch: replace`n} = uni",
'const u = uni; u.switchTab',
'Reflect.get(uni, "navigateTo")'
)) {
if ((Remove-JavaScriptComments $sample) -notmatch $navigationReferencePattern) {
throw "导航源扫描正则漏检受保护语法:$sample"
}
}
foreach ($sample in @(
'// uni.navigateTo()',
'/* Reflect.get(uni, "navigateBack") */',
'<!-- uni.redirectTo({}) -->',
'const label = `${ /* uni.reLaunch() */ safe }`'
)) {
if ((Remove-JavaScriptComments $sample) -match $navigationReferencePattern) {
throw "导航源扫描不得让注释伪造迁移计数:$sample"
}
}
foreach ($sample in @('<navigator url="/pages/demo">', 'uni?.[method]', '(uni)[method]', 'Reflect.get(uni, method)')) {
$sanitized = Remove-JavaScriptComments $sample
if ($sanitized -notmatch $declarativeNavigationPattern -and $sanitized -notmatch $dynamicUniAccessPattern) {
throw "导航源扫描漏检声明式或动态 Uni 导航入口:$sample"
}
}
foreach ($sample in @('const u = uni', 'consume(uni)', 'const u = globalThis.uni')) {
if ((Remove-JavaScriptComments $sample) -notmatch $escapedUniValuePattern) {
throw "导航源扫描漏检 Uni 对象逃逸:$sample"
}
}
$sfcUrlSample = '<template><text>https://example.test/a</text><button @click="uni.navigateTo({ url: target })"></button></template>'
if ((Get-ScannableSource $sfcUrlSample '.vue') -notmatch '\bnavigateTo\b') {
throw '导航源扫描把 template 的 https:// 误判为注释并漏掉后续真实调用'
}
$nestedTemplateSample = '<template><template v-if="ready"><text>安全内容</text></template><button @click="uni.redirectTo({ url: target })"></button></template>'
if ((Get-ScannableSource $nestedTemplateSample '.vue') -notmatch '\bredirectTo\b') {
throw '导航源扫描在嵌套 template 结束标签处提前停止'
}
$templateLiteralUrlSample = 'const url = `https://example.test/a`; uni.reLaunch({ url })'
if ((Remove-JavaScriptComments $templateLiteralUrlSample) -notmatch '\breLaunch\b') {
throw '导航源扫描把模板字符串中的 https:// 误判为行注释'
}
# 这是任务 3—9 的临时、精确迁移债务账本:每迁移一个文件就必须同轮删除
# 对应行并收紧计数;任务 10 必须把该列表清空,而不是保留长期白名单。
$expectedMigrationDebt = @()
$excludedDirectories = @('.agents', '.git', '.hbuilderx', 'design-pipeline', 'docs', 'node_modules', 'static', 'tests', 'unpackage')
$productionFiles = @(
Get-ChildItem -LiteralPath $root -File |
Where-Object { $_.Extension -in @('.vue', '.js') }
foreach ($directory in Get-ChildItem -LiteralPath $root -Directory | Where-Object { $_.Name -notin $excludedDirectories }) {
Get-ChildItem -LiteralPath $directory.FullName -Recurse -File |
Where-Object { $_.Extension -in @('.vue', '.js') }
}
)
$sourceFiles = @($productionFiles | Where-Object {
$relativePath = $_.FullName.Substring($root.Length + 1).Replace('\', '/')
$relativePath -match '^(components|pages)/'
})
foreach ($file in $productionFiles) {
$content = Get-ScannableSource ([System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)) $file.Extension
$relativePath = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
if ($content -match $declarativeNavigationPattern) {
throw "声明式 navigator 绕过了唯一导航网关:$relativePath"
}
if ($content -match $dynamicUniAccessPattern) {
throw "动态 Uni 属性或反射调用绕过了可审计导航合同:$relativePath"
}
if ($relativePath -ne 'utils/navigation.js' -and $content -match $escapedUniValuePattern) {
throw "Uni 对象被作为值传递或保存,无法静态审计导航调用:$relativePath"
}
}
$actualMigrationDebt = @()
$switchTabOwners = @()
foreach ($file in $sourceFiles) {
$content = Get-ScannableSource ([System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)) $file.Extension
$relativePath = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
$navigateToCount = [regex]::Matches($content, '\bnavigateTo\b').Count
$navigateBackCount = [regex]::Matches($content, '\bnavigateBack\b').Count
$redirectToCount = [regex]::Matches($content, '\bredirectTo\b').Count
$reLaunchCount = [regex]::Matches($content, '\breLaunch\b').Count
$navigationCount = $navigateToCount + $navigateBackCount + $redirectToCount + $reLaunchCount
$stackCount = [regex]::Matches($content, $stackReferencePattern).Count
$routeLiteralCount = [regex]::Matches($content, '/pages/').Count
if ($content -match $switchTabReferencePattern) { $switchTabOwners += $relativePath }
if (($navigationCount + $stackCount + $routeLiteralCount) -gt 0) {
$actualMigrationDebt += "$relativePath|$navigateToCount|$navigateBackCount|$redirectToCount|$reLaunchCount|$stackCount|$routeLiteralCount"
}
}
$actualMigrationDebt = @($actualMigrationDebt | Sort-Object)
if ($switchTabOwners.Count -gt 0) {
throw "项目没有原生 tabBar,不得调用 uni.switchTab$($switchTabOwners -join ', ')"
}
if (($actualMigrationDebt -join "`n") -ne ($expectedMigrationDebt -join "`n")) {
$unexpected = @($actualMigrationDebt | Where-Object { $_ -notin $expectedMigrationDebt })
$missing = @($expectedMigrationDebt | Where-Object { $_ -notin $actualMigrationDebt })
throw "导航迁移债务漂移;新增/计数变化:$($unexpected -join ', ');已消失但未收紧:$($missing -join ', ')"
}
foreach ($file in $productionFiles | Where-Object {
$_.FullName.Substring($root.Length + 1).Replace('\', '/') -notmatch '^(components|pages)/'
}) {
$content = Get-ScannableSource ([System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)) $file.Extension
$relativePath = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
$hasNavigationRuntime = $content -match "$navigationReferencePattern|$stackReferencePattern"
$hasRouteLiteral = $content.Contains('/pages/')
if ($content -match $switchTabReferencePattern) {
throw "项目没有原生 tabBarutils 也不得调用 uni.switchTab$relativePath"
}
if ($hasNavigationRuntime -and $relativePath -ne 'utils/navigation.js') {
throw "导航网关外的 utils 文件调用 Uni 导航或页面栈:$relativePath"
}
if ($hasRouteLiteral -and $relativePath -ne 'utils/navigation-routes.js') {
throw "路由注册表外的 utils 文件保存业务页面路径:$relativePath"
}
}
$fallbackOwners = @(
$productionFiles | Where-Object {
(Get-ScannableSource ([System.IO.File]::ReadAllText($_.FullName, [System.Text.Encoding]::UTF8)) $_.Extension) -match 'fallbackUrl|fallback-url'
} | ForEach-Object { $_.FullName.Substring($root.Length + 1).Replace('\', '/') }
)
if ($fallbackOwners.Count -gt 0) {
throw "旧 fallbackUrl 合同必须全局删除:$($fallbackOwners -join ', ')"
}
Write-Output "NAVIGATION-SOURCE-SCAN-CONTRACT PASS MIGRATION-DEBT=$($actualMigrationDebt.Count)"