$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 = '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)<(?script|style)\b[^>]*>(?.*?)>' $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") */', '', 'const label = `${ /* uni.reLaunch() */ safe }`' )) { if ((Remove-JavaScriptComments $sample) -match $navigationReferencePattern) { throw "导航源扫描不得让注释伪造迁移计数:$sample" } } foreach ($sample in @('', '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 = '' if ((Get-ScannableSource $sfcUrlSample '.vue') -notmatch '\bnavigateTo\b') { throw '导航源扫描把 template 的 https:// 误判为注释并漏掉后续真实调用' } $nestedTemplateSample = '' 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)"