fix(web): restore product navigation coverage
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 24s
CD Pipeline / build-and-deploy (push) Successful in 4m52s
CD Pipeline / post-deploy-checks (push) Successful in 58s
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 24s
CD Pipeline / build-and-deploy (push) Successful in 4m52s
CD Pipeline / post-deploy-checks (push) Successful in 58s
This commit is contained in:
@@ -433,6 +433,8 @@ jobs:
|
||||
;;
|
||||
ops/runner/verify-awoooi-non110-cd-closure.py)
|
||||
;;
|
||||
scripts/dev/awoooi-navigation-coverage-guard.py)
|
||||
;;
|
||||
scripts/ci/wait-host-web-build-pressure.sh)
|
||||
;;
|
||||
scripts/reboot-recovery/dr-escrow-evidence-checklist.py)
|
||||
|
||||
@@ -36,10 +36,12 @@
|
||||
},
|
||||
"nav": {
|
||||
"home": "首頁",
|
||||
"overview": "總覽",
|
||||
"dashboard": "儀表板",
|
||||
"approvals": "授權中心",
|
||||
"errors": "錯誤追蹤",
|
||||
"actions": "行動日誌",
|
||||
"alertOperationLogs": "告警操作紀錄",
|
||||
"knowledge": "知識殿堂",
|
||||
"settings": "設定",
|
||||
"alerts": "告警",
|
||||
@@ -53,6 +55,7 @@
|
||||
"tickets": "工單",
|
||||
"cost": "成本分析",
|
||||
"reports": "報表",
|
||||
"runs": "執行紀錄",
|
||||
"terminal": "終端",
|
||||
"apps": "應用",
|
||||
"services": "服務目錄",
|
||||
@@ -60,7 +63,11 @@
|
||||
"notifications": "通知",
|
||||
"billing": "帳單",
|
||||
"help": "說明",
|
||||
"demo": "Demo",
|
||||
"drift": "漂移偵測",
|
||||
"authorizations": "授權中心",
|
||||
"aiopsTimeline": "AI Ops 時間線",
|
||||
"openclawLiveOps": "OpenClaw Live Ops",
|
||||
"neuralCommand": "神經指揮中心",
|
||||
"commandCenter": "指令中心",
|
||||
"delivery": "交付閉環",
|
||||
@@ -608,7 +615,8 @@
|
||||
"addCritical": "+ 嚴重",
|
||||
"addMedium": "+ 中度",
|
||||
"creating": "建立中...",
|
||||
"liveDashboard": "即時事件流 (SSE)"
|
||||
"liveDashboard": "即時事件流 (SSE)",
|
||||
"disabled": "展示頁已在正式環境停用。"
|
||||
},
|
||||
"host": {
|
||||
"devops": {
|
||||
|
||||
@@ -36,10 +36,12 @@
|
||||
},
|
||||
"nav": {
|
||||
"home": "首頁",
|
||||
"overview": "總覽",
|
||||
"dashboard": "儀表板",
|
||||
"approvals": "授權中心",
|
||||
"errors": "錯誤追蹤",
|
||||
"actions": "行動日誌",
|
||||
"alertOperationLogs": "告警操作紀錄",
|
||||
"knowledge": "知識殿堂",
|
||||
"settings": "設定",
|
||||
"alerts": "告警",
|
||||
@@ -53,6 +55,7 @@
|
||||
"tickets": "工單",
|
||||
"cost": "成本分析",
|
||||
"reports": "報表",
|
||||
"runs": "執行紀錄",
|
||||
"terminal": "終端",
|
||||
"apps": "應用",
|
||||
"services": "服務目錄",
|
||||
@@ -60,7 +63,11 @@
|
||||
"notifications": "通知",
|
||||
"billing": "帳單",
|
||||
"help": "說明",
|
||||
"demo": "Demo",
|
||||
"drift": "漂移偵測",
|
||||
"authorizations": "授權中心",
|
||||
"aiopsTimeline": "AI Ops 時間線",
|
||||
"openclawLiveOps": "OpenClaw Live Ops",
|
||||
"neuralCommand": "神經指揮中心",
|
||||
"commandCenter": "指令中心",
|
||||
"delivery": "交付閉環",
|
||||
@@ -608,7 +615,8 @@
|
||||
"addCritical": "+ 嚴重",
|
||||
"addMedium": "+ 中度",
|
||||
"creating": "建立中...",
|
||||
"liveDashboard": "即時事件流 (SSE)"
|
||||
"liveDashboard": "即時事件流 (SSE)",
|
||||
"disabled": "展示頁已在正式環境停用。"
|
||||
},
|
||||
"host": {
|
||||
"devops": {
|
||||
|
||||
@@ -92,15 +92,7 @@ export default function DemoPage({ params }: { params: { locale: string } }) {
|
||||
const tMock = useTranslations('mockData')
|
||||
const tDryRun = useTranslations('dryRun')
|
||||
const locale = params.locale
|
||||
|
||||
// 生產環境保護: 需要 NEXT_PUBLIC_ENABLE_DEMO=true 才能存取
|
||||
if (process.env.NEXT_PUBLIC_ENABLE_DEMO !== 'true') {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<p className="text-muted-foreground">展示頁已在正式環境停用。</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const demoEnabled = process.env.NEXT_PUBLIC_ENABLE_DEMO === 'true'
|
||||
|
||||
const [_isCreating, setIsCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
@@ -153,6 +145,15 @@ export default function DemoPage({ params }: { params: { locale: string } }) {
|
||||
}
|
||||
}, [approvalConfigs, tApproval])
|
||||
|
||||
// 生產環境保護: 需要 NEXT_PUBLIC_ENABLE_DEMO=true 才能存取
|
||||
if (!demoEnabled) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<p className="text-muted-foreground">{t('disabled')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout locale={locale}>
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLocale } from 'next-intl'
|
||||
import {
|
||||
Search,
|
||||
@@ -26,6 +26,7 @@ import { Z_INDEX } from '@/lib/constants/z-index'
|
||||
import {
|
||||
PRODUCT_BOTTOM_NAV_ITEMS,
|
||||
PRODUCT_NAV_SECTIONS,
|
||||
type ProductNavItem,
|
||||
} from '@/lib/navigation/product-ia'
|
||||
|
||||
interface PaletteItem {
|
||||
@@ -47,12 +48,15 @@ const COMMAND_PALETTE_PRIMARY_NAV_IDS = new Set([
|
||||
'operations',
|
||||
])
|
||||
|
||||
type PaletteNavSource = ProductNavItem & {
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const t = useTranslations('commandPalette')
|
||||
const tNav = useTranslations('nav')
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
const pathname = usePathname()
|
||||
const { openTerminal } = useTerminalStore()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -66,10 +70,21 @@ export function CommandPalette() {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const navigationItems: PaletteItem[] = [
|
||||
...PRODUCT_NAV_SECTIONS.flatMap(section => section.items),
|
||||
const navigationSources: PaletteNavSource[] = [
|
||||
...PRODUCT_NAV_SECTIONS.flatMap(section => section.items.flatMap(item => [
|
||||
item,
|
||||
...(item.children ?? []).map(child => ({
|
||||
...child,
|
||||
id: `${item.id}-${child.id}`,
|
||||
Icon: item.Icon,
|
||||
parentId: item.id,
|
||||
surface: 'secondary' as const,
|
||||
})),
|
||||
])),
|
||||
...PRODUCT_BOTTOM_NAV_ITEMS,
|
||||
].map((item) => {
|
||||
]
|
||||
|
||||
const navigationItems: PaletteItem[] = navigationSources.map((item) => {
|
||||
const path = item.href === '/' ? '' : item.href
|
||||
|
||||
return {
|
||||
@@ -82,6 +97,7 @@ export function CommandPalette() {
|
||||
item.id,
|
||||
item.href,
|
||||
item.labelKey,
|
||||
item.parentId ?? '',
|
||||
...(item.aliases ?? []),
|
||||
...(item.relatedPaths ?? []),
|
||||
],
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import {
|
||||
PRODUCT_BOTTOM_NAV_ITEMS,
|
||||
PRODUCT_NAV_SECTIONS,
|
||||
type ProductNavChild,
|
||||
type ProductNavItem,
|
||||
} from '@/lib/navigation/product-ia'
|
||||
// Phase 8.0 #15: 改用 approval store SSE (移除 polling)
|
||||
@@ -103,8 +104,10 @@ export function Sidebar({
|
||||
const isActive = (item: ProductNavItem) => (
|
||||
isRouteActive(item.href, item.exact) ||
|
||||
item.aliases?.some(alias => isRouteActive(alias)) === true ||
|
||||
item.relatedPaths?.some(path => isRouteActive(path)) === true
|
||||
item.relatedPaths?.some(path => isRouteActive(path)) === true ||
|
||||
item.children?.some(child => isChildActive(child)) === true
|
||||
)
|
||||
const isChildActive = (item: ProductNavChild) => isRouteActive(item.href, item.exact)
|
||||
const sidebarWidth = width ?? (compact && collapsed ? 48 : collapsed ? 64 : 224)
|
||||
|
||||
return (
|
||||
@@ -150,48 +153,88 @@ export function Sidebar({
|
||||
const count = item.badge && mounted ? pendingCount : 0
|
||||
const secondary = item.surface === 'secondary'
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/${locale}${item.href === '/' ? '' : item.href}`}
|
||||
title={collapsed ? t(item.labelKey) : undefined}
|
||||
onClick={onNavigate}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: collapsed ? 0 : secondary ? 7 : 8,
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
padding: collapsed ? '8px 0' : secondary ? '4px 12px 4px 30px' : '6px 12px',
|
||||
fontSize: secondary ? 13 : 14,
|
||||
color: active ? '#141413' : secondary ? '#77736a' : '#87867f',
|
||||
fontWeight: active ? 600 : 400,
|
||||
borderRight: active ? '2px solid #d97757' : '2px solid transparent',
|
||||
background: active ? 'rgba(217,119,87,0.08)' : 'transparent',
|
||||
textDecoration: 'none',
|
||||
transition: 'all 0.15s',
|
||||
position: 'relative' as const,
|
||||
minHeight: collapsed ? 36 : secondary ? 28 : 32,
|
||||
}}
|
||||
>
|
||||
<item.Icon size={secondary ? 14 : 15} aria-hidden="true" />
|
||||
{!collapsed && (
|
||||
<span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
)}
|
||||
{item.badge && count > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 12,
|
||||
background: '#d97757',
|
||||
color: 'white',
|
||||
borderRadius: 10,
|
||||
padding: '1px 5px',
|
||||
fontWeight: 700,
|
||||
}}>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<div key={item.id}>
|
||||
<Link
|
||||
href={`/${locale}${item.href === '/' ? '' : item.href}`}
|
||||
title={collapsed ? t(item.labelKey) : undefined}
|
||||
onClick={onNavigate}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: collapsed ? 0 : secondary ? 7 : 8,
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
padding: collapsed ? '8px 0' : secondary ? '4px 12px 4px 30px' : '6px 12px',
|
||||
fontSize: secondary ? 13 : 14,
|
||||
color: active ? '#141413' : secondary ? '#77736a' : '#87867f',
|
||||
fontWeight: active ? 600 : 400,
|
||||
borderRight: active ? '2px solid #d97757' : '2px solid transparent',
|
||||
background: active ? 'rgba(217,119,87,0.08)' : 'transparent',
|
||||
textDecoration: 'none',
|
||||
transition: 'all 0.15s',
|
||||
position: 'relative' as const,
|
||||
minHeight: collapsed ? 36 : secondary ? 28 : 32,
|
||||
}}
|
||||
>
|
||||
<item.Icon size={secondary ? 14 : 15} aria-hidden="true" />
|
||||
{!collapsed && (
|
||||
<span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
)}
|
||||
{item.badge && count > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 12,
|
||||
background: '#d97757',
|
||||
color: 'white',
|
||||
borderRadius: 10,
|
||||
padding: '1px 5px',
|
||||
fontWeight: 700,
|
||||
}}>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{!collapsed && item.children?.length ? (
|
||||
<div
|
||||
aria-label={t(item.labelKey)}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 1,
|
||||
margin: '1px 0 5px',
|
||||
paddingLeft: secondary ? 44 : 35,
|
||||
paddingRight: 8,
|
||||
}}
|
||||
>
|
||||
{item.children.map(child => {
|
||||
const childActive = isChildActive(child)
|
||||
return (
|
||||
<Link
|
||||
key={`${item.id}-${child.id}`}
|
||||
href={`/${locale}${child.href}`}
|
||||
onClick={onNavigate}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 24,
|
||||
padding: '3px 8px',
|
||||
borderLeft: childActive ? '2px solid #d97757' : '2px solid #e7e2d8',
|
||||
color: childActive ? '#141413' : '#7d7a72',
|
||||
background: childActive ? 'rgba(217,119,87,0.08)' : 'transparent',
|
||||
fontSize: 12,
|
||||
fontWeight: childActive ? 650 : 450,
|
||||
lineHeight: 1.25,
|
||||
textDecoration: 'none',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{t(child.labelKey)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ export type ProductNavItem = {
|
||||
exact?: boolean
|
||||
badge?: boolean
|
||||
surface?: 'primary' | 'secondary'
|
||||
children?: ProductNavChild[]
|
||||
}
|
||||
|
||||
export type ProductNavSection = {
|
||||
@@ -37,6 +38,14 @@ export type ProductNavSection = {
|
||||
items: ProductNavItem[]
|
||||
}
|
||||
|
||||
export type ProductNavChild = {
|
||||
id: string
|
||||
href: string
|
||||
labelKey: string
|
||||
exact?: boolean
|
||||
badge?: boolean
|
||||
}
|
||||
|
||||
export type WorkflowNavItem = {
|
||||
labelKey: string
|
||||
href: string
|
||||
@@ -57,6 +66,10 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [
|
||||
labelKey: 'awooopWarRoom',
|
||||
Icon: BrainCircuit,
|
||||
exact: true,
|
||||
children: [
|
||||
{ id: 'aiops-timeline', href: '/aiops/timeline', labelKey: 'aiopsTimeline' },
|
||||
{ id: 'openclaw-live-ops-space', href: '/openclaw/live-ops-space', labelKey: 'openclawLiveOps' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'observability',
|
||||
@@ -64,6 +77,17 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [
|
||||
labelKey: 'observabilityCommandCenter',
|
||||
Icon: Monitor,
|
||||
aliases: ['/monitoring', '/apm', '/errors', '/apps', '/services', '/topology', '/alerts', '/alert-operation-logs', '/notifications'],
|
||||
children: [
|
||||
{ id: 'monitoring', href: '/monitoring', labelKey: 'monitoring' },
|
||||
{ id: 'apm', href: '/apm', labelKey: 'apm' },
|
||||
{ id: 'errors', href: '/errors', labelKey: 'errors' },
|
||||
{ id: 'apps', href: '/apps', labelKey: 'apps' },
|
||||
{ id: 'services', href: '/services', labelKey: 'services' },
|
||||
{ id: 'topology', href: '/topology', labelKey: 'topology' },
|
||||
{ id: 'alerts', href: '/alerts', labelKey: 'alerts' },
|
||||
{ id: 'alert-operation-logs', href: '/alert-operation-logs', labelKey: 'alertOperationLogs' },
|
||||
{ id: 'notifications', href: '/notifications', labelKey: 'notifications' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -82,6 +106,13 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [
|
||||
labelKey: 'knowledgeAutomation',
|
||||
Icon: BookOpen,
|
||||
aliases: ['/knowledge', '/automation', '/auto-repair', '/drift', '/neural-command'],
|
||||
children: [
|
||||
{ id: 'knowledge', href: '/knowledge', labelKey: 'knowledge' },
|
||||
{ id: 'automation', href: '/automation', labelKey: 'automation' },
|
||||
{ id: 'auto-repair', href: '/auto-repair', labelKey: 'autoRepair' },
|
||||
{ id: 'drift', href: '/drift', labelKey: 'drift' },
|
||||
{ id: 'neural-command', href: '/neural-command', labelKey: 'neuralCommand' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'awooop-tenants',
|
||||
@@ -110,6 +141,9 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [
|
||||
aliases: ['/authorizations'],
|
||||
badge: true,
|
||||
surface: 'secondary',
|
||||
children: [
|
||||
{ id: 'authorizations', href: '/authorizations', labelKey: 'authorizations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'awooop-contracts',
|
||||
@@ -134,21 +168,14 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [
|
||||
href: '/iwooos',
|
||||
labelKey: 'iwooos',
|
||||
Icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
id: 'iwooos-security-compliance',
|
||||
href: '/security-compliance',
|
||||
labelKey: 'iwooosSecurityCompliance',
|
||||
Icon: ShieldCheck,
|
||||
aliases: ['/security', '/compliance'],
|
||||
surface: 'secondary',
|
||||
},
|
||||
{
|
||||
id: 'governance-security',
|
||||
href: '/governance',
|
||||
labelKey: 'governanceSecurity',
|
||||
Icon: Radar,
|
||||
surface: 'secondary',
|
||||
aliases: ['/security-compliance'],
|
||||
relatedPaths: ['/security', '/compliance', '/governance'],
|
||||
children: [
|
||||
{ id: 'security-compliance', href: '/security-compliance', labelKey: 'securityCompliance' },
|
||||
{ id: 'security', href: '/security', labelKey: 'security' },
|
||||
{ id: 'compliance', href: '/compliance', labelKey: 'compliance' },
|
||||
{ id: 'governance', href: '/governance', labelKey: 'governance' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
@@ -156,12 +183,24 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [
|
||||
labelKey: 'operationsOverview',
|
||||
Icon: Package,
|
||||
aliases: ['/deployments', '/tickets', '/cost', '/billing', '/action-logs', '/reports'],
|
||||
children: [
|
||||
{ id: 'deployments', href: '/deployments', labelKey: 'deployments' },
|
||||
{ id: 'tickets', href: '/tickets', labelKey: 'tickets' },
|
||||
{ id: 'cost', href: '/cost', labelKey: 'cost' },
|
||||
{ id: 'billing', href: '/billing', labelKey: 'billing' },
|
||||
{ id: 'action-logs', href: '/action-logs', labelKey: 'actions' },
|
||||
{ id: 'reports', href: '/reports', labelKey: 'reports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
href: '/settings',
|
||||
labelKey: 'settings',
|
||||
Icon: Settings,
|
||||
aliases: ['/users'],
|
||||
children: [
|
||||
{ id: 'users', href: '/users', labelKey: 'users' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -184,7 +223,17 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [
|
||||
surface: 'secondary',
|
||||
},
|
||||
{ id: 'terminal', href: '/terminal', labelKey: 'terminal', Icon: Terminal, surface: 'secondary' },
|
||||
{ id: 'classic', href: '/classic', labelKey: 'classicAICenter', Icon: Radar, surface: 'secondary' },
|
||||
{
|
||||
id: 'classic',
|
||||
href: '/classic',
|
||||
labelKey: 'classicAICenter',
|
||||
Icon: Radar,
|
||||
surface: 'secondary',
|
||||
aliases: ['/demo'],
|
||||
children: [
|
||||
{ id: 'demo', href: '/demo', labelKey: 'demo' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -97,6 +97,11 @@ def test_iwooos_security_operation_api_stays_on_controlled_runtime_profile() ->
|
||||
assert source in text
|
||||
|
||||
|
||||
def test_navigation_coverage_guard_stays_on_controlled_runtime_profile() -> None:
|
||||
text = _workflow_text()
|
||||
assert "scripts/dev/awoooi-navigation-coverage-guard.py)" in text
|
||||
|
||||
|
||||
def test_ai_autonomous_runtime_control_stays_on_controlled_runtime_profile() -> None:
|
||||
text = _workflow_text()
|
||||
expected_sources = [
|
||||
|
||||
107
scripts/dev/awoooi-navigation-coverage-guard.py
Normal file
107
scripts/dev/awoooi-navigation-coverage-guard.py
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Guard AWOOOI product navigation against orphaned frontend routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROUTE_RE = re.compile(r"href:\s*'([^']+)'")
|
||||
ROUTE_LIST_RE = re.compile(r"(?:aliases|relatedPaths):\s*\[([^\]]*)\]")
|
||||
LABEL_KEY_RE = re.compile(r"labelKey:\s*'([^']+)'")
|
||||
QUOTED_RE = re.compile(r"'([^']+)'")
|
||||
|
||||
|
||||
def locale_routes(root: Path) -> set[str]:
|
||||
app_root = root / "apps/web/src/app/[locale]"
|
||||
routes: set[str] = set()
|
||||
for page in app_root.rglob("page.tsx"):
|
||||
rel = page.relative_to(app_root)
|
||||
route_parts = rel.parts[:-1]
|
||||
if any(part.startswith("[") and part.endswith("]") for part in route_parts):
|
||||
continue
|
||||
route = "/" + "/".join(route_parts)
|
||||
routes.add("/" if route == "/" else route.rstrip("/"))
|
||||
return routes
|
||||
|
||||
|
||||
def navigation_routes(root: Path) -> set[str]:
|
||||
source = (root / "apps/web/src/lib/navigation/product-ia.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
routes = set(ROUTE_RE.findall(source))
|
||||
for match in ROUTE_LIST_RE.findall(source):
|
||||
routes.update(QUOTED_RE.findall(match))
|
||||
return {route.rstrip("/") or "/" for route in routes}
|
||||
|
||||
|
||||
def navigation_label_keys(root: Path) -> set[str]:
|
||||
source = (root / "apps/web/src/lib/navigation/product-ia.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
return set(LABEL_KEY_RE.findall(source))
|
||||
|
||||
|
||||
def missing_nav_labels(root: Path, label_keys: set[str]) -> dict[str, list[str]]:
|
||||
missing: dict[str, list[str]] = {}
|
||||
for messages_file in sorted((root / "apps/web/messages").glob("*.json")):
|
||||
with messages_file.open(encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
nav = payload.get("nav") if isinstance(payload, dict) else None
|
||||
nav_keys = set(nav) if isinstance(nav, dict) else set()
|
||||
gaps = sorted(label_keys - nav_keys)
|
||||
if gaps:
|
||||
missing[messages_file.name] = gaps
|
||||
return missing
|
||||
|
||||
|
||||
def covered(route: str, nav_routes: set[str]) -> bool:
|
||||
if route in nav_routes:
|
||||
return True
|
||||
return any(parent != "/" and route.startswith(parent + "/") for parent in nav_routes)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", default=".", help="Repository root")
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.root).resolve()
|
||||
routes = locale_routes(root)
|
||||
nav_routes = navigation_routes(root)
|
||||
label_keys = navigation_label_keys(root)
|
||||
missing = sorted(route for route in routes if not covered(route, nav_routes))
|
||||
missing_labels = missing_nav_labels(root, label_keys)
|
||||
|
||||
sidebar = (root / "apps/web/src/components/layout/sidebar.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
hidden_secondary_filter = "surface !== 'secondary'" in sidebar
|
||||
|
||||
if missing or missing_labels or hidden_secondary_filter:
|
||||
if missing:
|
||||
print("NAVIGATION_COVERAGE_GUARD_MISSING_ROUTES")
|
||||
for route in missing:
|
||||
print(f"- {route}")
|
||||
if missing_labels:
|
||||
print("NAVIGATION_COVERAGE_GUARD_MISSING_LABEL_KEYS")
|
||||
for file_name, gaps in missing_labels.items():
|
||||
print(f"- {file_name}: {', '.join(gaps)}")
|
||||
if hidden_secondary_filter:
|
||||
print("NAVIGATION_COVERAGE_GUARD_HIDDEN_SECONDARY_ITEMS")
|
||||
print("- Sidebar must not filter product nav items by surface !== 'secondary'.")
|
||||
return 1
|
||||
|
||||
print(
|
||||
"AWOOOI_NAVIGATION_COVERAGE_GUARD_OK "
|
||||
f"routes={len(routes)} nav_routes={len(nav_routes)} nav_label_keys={len(label_keys)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user