294 lines
13 KiB
PowerShell
294 lines
13 KiB
PowerShell
$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 "项目没有原生 tabBar,utils 也不得调用 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)"
|