#!/usr/bin/env node /** * Static consistency checker between navigation declaration and source code triggers. * * What it detects (v0.1): * 1) Used transitionId in code but missing in navigation.declaration.ts transitions[]. * 2) Usage occurs inside a page component whose route path is NOT included in transition.from (path match only). * * Notes: * - This is a best-effort static checker. It does not execute code. * - For "from" checks, we only validate route path presence (string '/x' or object {path:'/x', ...}). * We do NOT attempt to prove search constraints correctness (e.g. tab='shelf' vs '*'). */ import fs from 'fs'; import path from 'path'; import process from 'process'; import ts from 'typescript'; const WORKSPACE_ROOT = process.cwd(); function usage() { console.log(` Usage: node scripts/check_navigation_declaration_consistency.mjs WechatReading Options: --json Output JSON only --actions Also validate Actions (data-action) consistency --actions-only Only run Actions checks --fail-on-warn Exit non-zero on warnings too `); } function parseArgs(argv) { const args = argv.slice(2); const appName = args.find(a => !a.startsWith('--')); const jsonOnly = args.includes('--json'); const failOnWarn = args.includes('--fail-on-warn'); const actions = args.includes('--actions'); const actionsOnly = args.includes('--actions-only'); return { appName, jsonOnly, failOnWarn, actions, actionsOnly }; } function readText(filePath) { return fs.readFileSync(filePath, 'utf8'); } function listFilesRecursive(dir, { exts, ignoreDirs }) { const out = []; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (ignoreDirs.has(entry.name)) continue; out.push(...listFilesRecursive(full, { exts, ignoreDirs })); } else { const ext = path.extname(entry.name); if (exts.has(ext)) out.push(full); } } return out; } function createSourceFile(filePath) { const text = readText(filePath); const ext = path.extname(filePath); const kind = ext === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS; return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, kind); } function isObjectLiteral(node) { return node && node.kind === ts.SyntaxKind.ObjectLiteralExpression; } function getProp(objLit, name) { if (!objLit || !objLit.properties) return null; for (const prop of objLit.properties) { if (!ts.isPropertyAssignment(prop)) continue; const key = prop.name; if (ts.isIdentifier(key) && key.text === name) return prop.initializer; if (ts.isStringLiteral(key) && key.text === name) return prop.initializer; } return null; } function getStringLiteralValue(node) { if (!node) return null; // Unwrap common TS wrappers like `'x' as TransitionId` or `('x')` let cur = node; while (cur) { if (ts.isAsExpression(cur)) { cur = cur.expression; continue; } if (ts.isSatisfiesExpression && ts.isSatisfiesExpression(cur)) { cur = cur.expression; continue; } if (ts.isParenthesizedExpression(cur)) { cur = cur.expression; continue; } break; } if (ts.isStringLiteral(cur) || ts.isNoSubstitutionTemplateLiteral(cur)) return cur.text; return null; } function extractNavDeclaration(navDeclPath) { const sf = createSourceFile(navDeclPath); /** @type {null | ts.ObjectLiteralExpression} */ let declObj = null; function unwrapExpr(expr) { let cur = expr; // Unwrap common TS wrappers: `as const`, `satisfies`, parentheses // - AsExpression: `{...} as const` // - SatisfiesExpression: `({...}) satisfies T` // - ParenthesizedExpression: `({...})` while (cur) { if (ts.isAsExpression(cur)) { cur = cur.expression; continue; } if (ts.isSatisfiesExpression && ts.isSatisfiesExpression(cur)) { cur = cur.expression; continue; } if (ts.isParenthesizedExpression(cur)) { cur = cur.expression; continue; } break; } return cur; } function visit(node) { if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === 'NAVIGATION_DECLARATION') { const init = node.initializer ? unwrapExpr(node.initializer) : null; if (init && ts.isObjectLiteralExpression(init)) declObj = init; } ts.forEachChild(node, visit); } visit(sf); if (!declObj) { throw new Error(`Cannot find NAVIGATION_DECLARATION object in ${navDeclPath}`); } const routesInit = getProp(declObj, 'routes'); const transitionsInit = getProp(declObj, 'transitions'); /** @type {Array<{path:string, component:string, queryParamKeys:Set, uiStates:Array<{id:string, discreteKeys:Set}> , uiStateDiscreteKeySets:Set[], hasBaseDiscreteState:boolean}>} */ const routes = []; /** @type {Map, fromEntries:Array<{path:string, search?:Record}>, uiPlacement:null|string, uiGesture:null|string, rawFrom:any}>} */ const transitions = new Map(); if (routesInit && ts.isArrayLiteralExpression(routesInit)) { for (const el of routesInit.elements) { if (!ts.isObjectLiteralExpression(el)) continue; const p = getStringLiteralValue(getProp(el, 'path')); const c = getStringLiteralValue(getProp(el, 'component')); if (!p || !c) continue; const queryParamKeys = new Set(); const queryParamsInit = getProp(el, 'queryParams'); if (queryParamsInit && ts.isObjectLiteralExpression(queryParamsInit)) { for (const prop of queryParamsInit.properties) { if (!ts.isPropertyAssignment(prop)) continue; const key = ts.isIdentifier(prop.name) ? prop.name.text : ts.isStringLiteral(prop.name) ? prop.name.text : null; if (!key) continue; queryParamKeys.add(key); } } const uiStateDiscreteKeySets = []; const uiStates = []; let hasBaseDiscreteState = false; const uiStatesInit = getProp(el, 'uiStates'); if (uiStatesInit && ts.isArrayLiteralExpression(uiStatesInit)) { for (const stateEl of uiStatesInit.elements) { if (!ts.isObjectLiteralExpression(stateEl)) continue; const uiStateId = getStringLiteralValue(getProp(stateEl, 'id')) ?? ''; const searchInit = getProp(stateEl, 'search'); const searchObj = searchInit && ts.isObjectLiteralExpression(searchInit) ? searchInit : null; const discreteKeys = new Set(); if (searchObj) { for (const prop of searchObj.properties) { if (!ts.isPropertyAssignment(prop)) continue; const key = ts.isIdentifier(prop.name) ? prop.name.text : ts.isStringLiteral(prop.name) ? prop.name.text : null; if (!key) continue; // queryParams are dynamic; don't count them as discrete keys if (queryParamKeys.has(key)) continue; discreteKeys.add(key); } } uiStateDiscreteKeySets.push(discreteKeys); uiStates.push({ id: uiStateId, discreteKeys }); if (discreteKeys.size === 0) hasBaseDiscreteState = true; } } routes.push({ path: p, component: c, queryParamKeys, uiStates, uiStateDiscreteKeySets, hasBaseDiscreteState, }); } } if (transitionsInit && ts.isArrayLiteralExpression(transitionsInit)) { for (const el of transitionsInit.elements) { if (!ts.isObjectLiteralExpression(el)) continue; const id = getStringLiteralValue(getProp(el, 'id')); if (!id) continue; const fromInit = getProp(el, 'from'); const fromPaths = new Set(); const fromEntries = []; const uiInit = getProp(el, 'ui'); let uiPlacement = null; let uiGesture = null; if (uiInit && ts.isObjectLiteralExpression(uiInit)) { uiPlacement = getStringLiteralValue(getProp(uiInit, 'placement')); uiGesture = getStringLiteralValue(getProp(uiInit, 'gesture')); } const parseSearchObj = (searchInit) => { if (!searchInit || !ts.isObjectLiteralExpression(searchInit)) return undefined; const out = {}; for (const prop of searchInit.properties) { if (!ts.isPropertyAssignment(prop)) continue; const key = ts.isIdentifier(prop.name) ? prop.name.text : ts.isStringLiteral(prop.name) ? prop.name.text : null; if (!key) continue; const val = getStringLiteralValue(prop.initializer); if (val !== null) out[key] = val; } return out; }; const addFromNode = node => { const str = getStringLiteralValue(node); if (str) { fromPaths.add(str); fromEntries.push({ path: str }); return; } if (node && ts.isObjectLiteralExpression(node)) { const p = getStringLiteralValue(getProp(node, 'path')); if (p) { fromPaths.add(p); const search = parseSearchObj(getProp(node, 'search')); fromEntries.push({ path: p, ...(search ? { search } : {}) }); } } }; if (fromInit) { if (ts.isArrayLiteralExpression(fromInit)) { for (const item of fromInit.elements) addFromNode(item); } else { addFromNode(fromInit); } } transitions.set(id, { id, fromPaths, fromEntries, uiPlacement, uiGesture, rawFrom: fromInit ? sf.text.slice(fromInit.pos, fromInit.end) : null, }); } } const componentToPaths = new Map(); for (const r of routes) { const list = componentToPaths.get(r.component) ?? []; list.push(r.path); componentToPaths.set(r.component, list); } return { routes, transitions, componentToPaths }; } function extractTransitionUsages(appDir, declaredTransitionIdSet) { const ignoreDirs = new Set(['node_modules', 'dist', 'build', 'data', 'context']); const exts = new Set(['.ts', '.tsx']); const files = listFilesRecursive(appDir, { exts, ignoreDirs }); /** @type {Array<{id:string, kind:string, file:string, line:number, col:number, componentGuess?:string, inferredSearch?:Record}>} */ const usages = []; for (const filePath of files) { // Never treat the declaration itself as a "usage" source. if (filePath.endsWith(`${path.sep}navigation.declaration.ts`)) continue; const sf = createSourceFile(filePath); const rel = path.relative(WORKSPACE_ROOT, filePath); const base = path.basename(filePath).replace(/\.(ts|tsx)$/, ''); const isPage = rel.includes(`${path.sep}pages${path.sep}`); const componentGuess = isPage ? base : undefined; // Heuristic: collect transition ids from object literals like: // - `{ transition: 'tab.reading' }` (TabBar in WechatReading) // - `{ transitionId: 'readingList.open.finished' as TransitionId }` (MyReadingPage) // - `{ triggerId: 'tab.home' as TransitionId }` (TabBar in TencentMeeting) // This lets us count indirect usage patterns like `bindTap(tab.transition)` in TabBar. // // Supported property names (common naming patterns for transition IDs): const TRANSITION_KEYS = [ 'transition', // e.g. { transition: 'tab.reading' } 'transitionId', // e.g. { transitionId: 'home.open' } 'triggerId', // e.g. { triggerId: 'tab.home' } - from useTriggerGestures 'navId', // e.g. { navId: 'page.open' } 'navigationId', // e.g. { navigationId: 'route.go' } ]; const fileTransitionIdPool = new Set(); const isLikelyTransitionId = (v) => typeof v === 'string' && v.includes('.') && !/\s/.test(v) && /^[A-Za-z][A-Za-z0-9_.-]*$/.test(v); (function collectTransitionPool() { function visitPool(node) { if (ts.isObjectLiteralExpression(node)) { for (const key of TRANSITION_KEYS) { const t = getProp(node, key); const v = getStringLiteralValue(t); if (isLikelyTransitionId(v)) fileTransitionIdPool.add(v); } } ts.forEachChild(node, visitPool); } visitPool(sf); })(); function record(kind, id, node) { const lc = sf.getLineAndCharacterOfPosition(node.getStart(sf)); const inferredSearch = inferSearchFromContext(node, sf); usages.push({ id, kind, file: rel, line: lc.line + 1, col: lc.character + 1, componentGuess, inferredSearch, }); } function inferSearchFromContext(node, sourceFile) { const out = {}; const addFromExpr = (expr) => { for (const { ident, value } of extractStringEqualityConstraints(expr)) { const key = mapIdentToSearchKey(ident); if (!key) continue; out[key] = value; } }; // Walk up a few parents to find if/ternary conditions guarding this usage. let cur = node; let steps = 0; while (cur && cur.parent && steps < 10) { const p = cur.parent; if (ts.isIfStatement(p)) addFromExpr(p.expression); if (ts.isConditionalExpression(p)) addFromExpr(p.condition); if (ts.isBinaryExpression(p) || ts.isPrefixUnaryExpression(p) || ts.isParenthesizedExpression(p)) { // no-op, covered by extract on higher-level conditions } cur = p; steps++; if (ts.isSourceFile(cur)) break; } return Object.keys(out).length ? out : undefined; } function mapIdentToSearchKey(ident) { const s = String(ident || '').toLowerCase(); if (s.includes('tab')) return 'tab'; if (s.includes('sub')) return 'sub'; if (s.includes('category')) return 'category'; if (s.includes('modal')) return 'modal'; if (s.includes('select')) return 'select'; return null; } function extractStringEqualityConstraints(expr) { /** @type {Array<{ident:string,value:string}>} */ const res = []; const visit = (e) => { if (!e) return; if (ts.isParenthesizedExpression(e)) return visit(e.expression); if (ts.isBinaryExpression(e)) { // Handle `a === 'x'` and `'x' === a` if ( e.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || e.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken ) { const leftIdent = ts.isIdentifier(e.left) ? e.left.text : null; const rightIdent = ts.isIdentifier(e.right) ? e.right.text : null; const leftStr = getStringLiteralValue(e.left); const rightStr = getStringLiteralValue(e.right); if (leftIdent && rightStr) res.push({ ident: leftIdent, value: rightStr }); if (rightIdent && leftStr) res.push({ ident: rightIdent, value: leftStr }); } // Recurse through && / || trees etc. visit(e.left); visit(e.right); return; } if (ts.isPrefixUnaryExpression(e)) return visit(e.operand); }; visit(expr); return res; } function visit(node) { if (ts.isCallExpression(node)) { // go('transition.id', ...) if (ts.isIdentifier(node.expression) && node.expression.text === 'go') { const first = node.arguments[0]; const id = getStringLiteralValue(first); if (id) record('go', id, first); } // bindTap('transition.id', ...) if (ts.isIdentifier(node.expression) && node.expression.text === 'bindTap') { const first = node.arguments[0]; const id = getStringLiteralValue(first); if (id) { record('bindTap', id, first); } else if (fileTransitionIdPool.size > 0) { // Indirect bindTap usage (e.g., bindTap(tab.transition)) // Record all transition ids seen in the local pool as "used". for (const pooled of fileTransitionIdPool) { record('bindTap', pooled, node); } } } // bindLongPress('transition.id', ...) if (ts.isIdentifier(node.expression) && node.expression.text === 'bindLongPress') { const first = node.arguments[0]; const id = getStringLiteralValue(first); if (id) record('bindLongPress', id, first); } // bindDoubleTap('transition.id', ...) if (ts.isIdentifier(node.expression) && node.expression.text === 'bindDoubleTap') { const first = node.arguments[0]; const id = getStringLiteralValue(first); if (id) record('bindDoubleTap', id, first); } // Best-effort: indirect bindings via shared components (e.g., header/topbar config APIs). // We detect any call that passes an object with `id: ''` and only count it // when it matches a declared TransitionId (prevents ActionId strings from being mis-counted). const expr = node.expression; const calleeName = ts.isIdentifier(expr) ? expr.text : ts.isPropertyAccessExpression(expr) ? expr.name.text : 'call'; for (const arg of node.arguments ?? []) { if (!arg || !ts.isObjectLiteralExpression(arg)) continue; const idInit = getProp(arg, 'id'); const id = getStringLiteralValue(idInit); if (id && (!declaredTransitionIdSet || declaredTransitionIdSet.has(id))) { record(`call:${calleeName}`, id, idInit ?? arg); } } } ts.forEachChild(node, visit); } visit(sf); } return usages; } function buildReport({ nav, usages }) { const declaredIds = new Set(nav.transitions.keys()); // Built-in/system triggers are not part of navigation.declaration.ts transitions[]. // Example: `system.back` is used by bindBack() across apps. const isSystemId = (id) => typeof id === 'string' && id.startsWith('system.'); const usedIds = new Set(usages.map(u => u.id).filter(id => !isSystemId(id))); const missingInDeclaration = [...usedIds].filter(id => !declaredIds.has(id)).sort(); const unusedInCode = [...declaredIds].filter(id => !usedIds.has(id)).sort(); /** @type {Record>} */ const missingUsageSites = {}; for (const u of usages) { if (isSystemId(u.id)) continue; if (declaredIds.has(u.id)) continue; const arr = missingUsageSites[u.id] ?? (missingUsageSites[u.id] = []); arr.push({ id: u.id, kind: u.kind, file: u.file, line: u.line, col: u.col, ...(u.componentGuess ? { component: u.componentGuess } : {}), }); } for (const id of Object.keys(missingUsageSites)) { const seen = new Set(); missingUsageSites[id] = missingUsageSites[id] .filter(x => { const key = `${x.file}:${x.line}:${x.col}:${x.kind}:${x.component ?? ''}`; if (seen.has(key)) return false; seen.add(key); return true; }) .sort((a, b) => `${a.file}:${a.line}:${a.col}`.localeCompare(`${b.file}:${b.line}:${b.col}`), ); } /** @type {Array<{id:string, file:string, line:number, col:number, component?:string, expectedFromPaths:string[], declaredFromPaths:string[]}>} */ const fromMismatches = []; // Build expected from paths per transitionId based on page-local usage sites we can map. /** @type {Map>} */ const expectedFromById = new Map(); for (const u of usages) { if (!u.componentGuess) continue; const pathsForComponent = nav.componentToPaths.get(u.componentGuess) ?? []; if (pathsForComponent.length === 0) continue; const set = expectedFromById.get(u.id) ?? new Set(); for (const p of pathsForComponent) set.add(p); expectedFromById.set(u.id, set); } for (const u of usages) { const t = nav.transitions.get(u.id); if (!t) continue; // already covered by missingInDeclaration if (!u.componentGuess) continue; // we only validate within page components for now const pathsForComponent = nav.componentToPaths.get(u.componentGuess) ?? []; if (pathsForComponent.length === 0) continue; // unknown mapping const ok = pathsForComponent.some(p => t.fromPaths.has(p)); if (!ok) { fromMismatches.push({ id: u.id, file: u.file, line: u.line, col: u.col, component: u.componentGuess, expectedFromPaths: pathsForComponent, declaredFromPaths: [...t.fromPaths].sort(), }); } } // Gesture mismatches: compare declared ui.gesture with observed bind* usage. /** @type {Array<{id:string, declaredGesture:string|null, observedGestures:string[], examples:Array<{file:string,line:number,col:number,kind:string}>}>} */ const gestureMismatches = []; const observedById = new Map(); for (const u of usages) { if (u.kind === 'bindTap') { const entry = observedById.get(u.id) ?? { gestures: new Set(), examples: [] }; entry.gestures.add('tap'); entry.examples.push({ file: u.file, line: u.line, col: u.col, kind: u.kind }); observedById.set(u.id, entry); } if (u.kind === 'bindLongPress') { const entry = observedById.get(u.id) ?? { gestures: new Set(), examples: [] }; entry.gestures.add('longPress'); entry.examples.push({ file: u.file, line: u.line, col: u.col, kind: u.kind }); observedById.set(u.id, entry); } if (u.kind === 'bindDoubleTap') { const entry = observedById.get(u.id) ?? { gestures: new Set(), examples: [] }; entry.gestures.add('doubleTap'); entry.examples.push({ file: u.file, line: u.line, col: u.col, kind: u.kind }); observedById.set(u.id, entry); } } for (const [id, obs] of observedById.entries()) { const t = nav.transitions.get(id); if (!t) continue; const declared = t.uiGesture ?? null; const observedGestures = [...obs.gestures].sort(); if (declared && observedGestures.length === 1 && declared !== observedGestures[0]) { gestureMismatches.push({ id, declaredGesture: declared, observedGestures, examples: obs.examples.slice(0, 3), }); } } // from.search over-broad heuristics (best-effort): // If a usage site is guarded by a specific tab/sub/etc, but the transition.from for that route path // is missing search or uses wildcard '*', warn/suggest narrowing. /** @type {Array<{id:string, sourcePath:string, key:string, expectedValue:string, declaredValue:string|null, example:{file:string,line:number,col:number}}>} */ const fromSearchTooBroad = []; for (const u of usages) { if (!u.componentGuess) continue; if (!u.inferredSearch) continue; const t = nav.transitions.get(u.id); if (!t) continue; const pathsForComponent = nav.componentToPaths.get(u.componentGuess) ?? []; if (pathsForComponent.length === 0) continue; // only handle single-path components to avoid ambiguity if (pathsForComponent.length !== 1) continue; const sourcePath = pathsForComponent[0]; // Find from entries in declaration that match this source path const relevant = t.fromEntries.filter(e => e.path === sourcePath); if (relevant.length === 0) continue; for (const [key, expectedValue] of Object.entries(u.inferredSearch)) { for (const entry of relevant) { const declaredValue = entry.search?.[key] ?? null; if (declaredValue === '*') { fromSearchTooBroad.push({ id: u.id, sourcePath, key, expectedValue, declaredValue, example: { file: u.file, line: u.line, col: u.col }, }); } if (declaredValue === null && expectedValue) { fromSearchTooBroad.push({ id: u.id, sourcePath, key, expectedValue, declaredValue: null, example: { file: u.file, line: u.line, col: u.col }, }); } } } } // Warn about declared "from" paths that are route paths but have no mapped usage site. /** @type {Array<{id:string, extraFromPaths:string[], declaredFromPaths:string[], hasAnyUsage:boolean}>} */ const extraFromWarnings = []; const allRoutePaths = new Set(nav.routes.map(r => r.path)); for (const [id, t] of nav.transitions.entries()) { const expected = expectedFromById.get(id) ?? new Set(); // Only run this heuristic when we have at least one mapped usage site (page -> route). // Otherwise (e.g., triggers inside shared components like TabBar), we can't infer expected-from. if (expected.size === 0) continue; // Tabbar transitions are global entry points; page-local inference will under-estimate. if (t.uiPlacement === 'tabbar') continue; const extra = [...t.fromPaths].filter(p => allRoutePaths.has(p) && !expected.has(p)).sort(); // Only warn if we have at least one usage of this id anywhere (otherwise it might be truly unused/placeholder). const hasAnyUsage = usedIds.has(id); if (hasAnyUsage && extra.length > 0) { extraFromWarnings.push({ id, extraFromPaths: extra, declaredFromPaths: [...t.fromPaths].sort(), hasAnyUsage, }); } } // Warn about "from" using bare route path when that route has NO base discrete uiState. // This commonly creates an extra placeholder node (e.g. "/ranking" or "/profile/following") with indegree=0. /** @type {Array<{id:string, fromPath:string, reason:string}>} */ const fromBarePathWithoutBaseState = []; const routeMetaByPath = new Map(nav.routes.map(r => [r.path, r])); for (const [id, t] of nav.transitions.entries()) { for (const entry of t.fromEntries) { const routeMeta = routeMetaByPath.get(entry.path); if (!routeMeta) continue; // bare path (no search constraint) only if (entry.search && Object.keys(entry.search).length > 0) continue; if (routeMeta.hasBaseDiscreteState) continue; const keySets = routeMeta.uiStateDiscreteKeySets || []; const uniqueKeySetStr = Array.from( new Set( keySets .filter(s => s && s.size > 0) .map(s => Array.from(s).sort().join(',')), ), ) .filter(Boolean) .slice(0, 5); const hint = uniqueKeySetStr.length > 0 ? `该路由没有离散基础态(uiStates 不包含 search:{})。可达状态的离散键集合示例:${uniqueKeySetStr .map(s => `[${s}]`) .join('、')}` : '该路由没有离散基础态(uiStates 不包含 search:{})。'; fromBarePathWithoutBaseState.push({ id, fromPath: entry.path, reason: hint, }); } } // Base uiState naming: if uiState.search is {} (no discrete keys), id must end with ".base" // Ref: docs/platform/declarative-navigation.md ("Routes" / "Actions" sections) /** @type {Array<{routePath:string, uiStateId:string, message:string}>} */ const invalidBaseStateIds = []; /** @type {Array<{routePath:string, baseStateIds:string[]}>} */ const multipleBaseStates = []; for (const r of nav.routes) { const baseStates = (r.uiStates ?? []).filter(s => (s?.discreteKeys?.size ?? 0) === 0); if (baseStates.length > 1) { multipleBaseStates.push({ routePath: r.path, baseStateIds: baseStates.map(s => s.id).filter(Boolean), }); } for (const s of baseStates) { const id = s.id ?? ''; if (!id.endsWith('.base')) { invalidBaseStateIds.push({ routePath: r.path, uiStateId: id, message: `Invalid base uiState id "${id}" for route "${r.path}". If uiState.search is {}, id must end with ".base".`, }); } } } return { summary: { routes: nav.routes.length, transitionsDeclared: declaredIds.size, transitionsUsedInCode: usedIds.size, missingInDeclaration: missingInDeclaration.length, unusedInCode: unusedInCode.length, fromMismatches: fromMismatches.length, extraFromWarnings: extraFromWarnings.length, gestureMismatches: gestureMismatches.length, fromSearchTooBroad: fromSearchTooBroad.length, fromBarePathWithoutBaseState: fromBarePathWithoutBaseState.length, invalidBaseStateIds: invalidBaseStateIds.length, multipleBaseStates: multipleBaseStates.length, }, missingInDeclaration, missingUsageSites, unusedInCode, fromMismatches, extraFromWarnings, gestureMismatches, fromSearchTooBroad, fromBarePathWithoutBaseState, invalidBaseStateIds, multipleBaseStates, }; } function printHuman(report) { const s = report.summary; console.log('=== Navigation Declaration Consistency Report ==='); console.log(`routes=${s.routes} transitionsDeclared=${s.transitionsDeclared} transitionsUsedInCode=${s.transitionsUsedInCode}`); console.log(`missingInDeclaration=${s.missingInDeclaration} fromMismatches=${s.fromMismatches} unusedInCode=${s.unusedInCode}`); console.log(''); if (report.missingInDeclaration.length) { console.log('--- ERROR: Used in code but missing in declaration ---'); for (const id of report.missingInDeclaration) { console.log(`- ${id}`); const sites = report.missingUsageSites?.[id] ?? []; for (const ex of sites.slice(0, 8)) { console.log(` @ ${ex.file}:${ex.line}:${ex.col} (${ex.kind}${ex.component ? `, component=${ex.component}` : ''})`); } if (sites.length > 8) console.log(` ... (${sites.length - 8} more)`); } console.log(''); } if (report.fromMismatches.length) { console.log('--- ERROR: Usage file route not included in transition.from (path-only check) ---'); for (const m of report.fromMismatches) { console.log(`- ${m.id} @ ${m.file}:${m.line}:${m.col} (component=${m.component})`); console.log(` expected from includes one of: ${m.expectedFromPaths.join(', ')}`); console.log(` declared from paths: ${m.declaredFromPaths.join(', ') || '(none parsed)'}`); } console.log(''); } if (report.unusedInCode.length) { console.log('--- WARN: Declared but not found in code (may be OK: implicit/entry/back/placeholder) ---'); for (const id of report.unusedInCode.slice(0, 50)) console.log(`- ${id}`); if (report.unusedInCode.length > 50) console.log(`... (${report.unusedInCode.length - 50} more)`); console.log(''); } if (report.extraFromWarnings?.length) { console.log('--- WARN: Declared transition.from includes route paths with no mapped trigger site in code ---'); for (const w of report.extraFromWarnings) { console.log(`- ${w.id}: extra from paths = ${w.extraFromPaths.join(', ')}`); } console.log(''); } if (report.gestureMismatches?.length) { console.log('--- WARN: Declared ui.gesture mismatch vs bind* usage ---'); for (const m of report.gestureMismatches) { console.log(`- ${m.id}: declared=${m.declaredGesture} observed=${m.observedGestures.join(', ')}`); for (const ex of m.examples) { console.log(` @ ${ex.file}:${ex.line}:${ex.col} (${ex.kind})`); } } console.log(''); } if (report.fromSearchTooBroad?.length) { console.log('--- WARN: transition.from.search might be too broad (best-effort inference from guarded usage sites) ---'); for (const w of report.fromSearchTooBroad.slice(0, 30)) { console.log(`- ${w.id} from ${w.sourcePath}: expected ${w.key}=${w.expectedValue}, declared ${w.key}=${w.declaredValue ?? '(missing)'}`); console.log(` @ ${w.example.file}:${w.example.line}:${w.example.col}`); } if (report.fromSearchTooBroad.length > 30) console.log(`... (${report.fromSearchTooBroad.length - 30} more)`); console.log(''); } if (report.fromBarePathWithoutBaseState?.length) { console.log('--- WARN: transition.from uses bare route path but route has no base discrete uiState (may create indegree=0 placeholder node) ---'); for (const w of report.fromBarePathWithoutBaseState.slice(0, 50)) { console.log(`- ${w.id}: from "${w.fromPath}" (no search constraint)`); console.log(` ${w.reason}`); console.log(` fix: replace with FromConstraint(s), e.g. { path: "${w.fromPath}", search: { : '*' } } or enumerate concrete uiStates.`); } if (report.fromBarePathWithoutBaseState.length > 50) { console.log(`... (${report.fromBarePathWithoutBaseState.length - 50} more)`); } console.log(''); } if (report.invalidBaseStateIds?.length) { console.log('--- ERROR: invalid base uiState ids (uiState.search is {} but id does not end with .base) ---'); for (const e of report.invalidBaseStateIds.slice(0, 50)) { console.log(`- ${e.message}`); } if (report.invalidBaseStateIds.length > 50) { console.log(`... (${report.invalidBaseStateIds.length - 50} more)`); } console.log(''); } if (report.multipleBaseStates?.length) { console.log('--- WARN: route has multiple base uiStates (search:{}). Consider keeping only one. ---'); for (const w of report.multipleBaseStates.slice(0, 50)) { console.log(`- ${w.routePath}: base uiStates = ${w.baseStateIds.join(', ')}`); } if (report.multipleBaseStates.length > 50) { console.log(`... (${report.multipleBaseStates.length - 50} more)`); } console.log(''); } } // ============================================================================ // Actions consistency (data-action / bindTap({kind:'action'})) // ============================================================================ function extractActionsFromNavDeclaration(navDeclPath) { const sf = createSourceFile(navDeclPath); /** @type {null | ts.ObjectLiteralExpression} */ let declObj = null; function unwrapExpr(expr) { let cur = expr; while (cur) { if (ts.isAsExpression(cur)) { cur = cur.expression; continue; } if (ts.isSatisfiesExpression && ts.isSatisfiesExpression(cur)) { cur = cur.expression; continue; } if (ts.isParenthesizedExpression(cur)) { cur = cur.expression; continue; } break; } return cur; } function visit(node) { if ( ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === 'NAVIGATION_DECLARATION' ) { const init = node.initializer ? unwrapExpr(node.initializer) : null; if (init && ts.isObjectLiteralExpression(init)) declObj = init; } ts.forEachChild(node, visit); } visit(sf); if (!declObj) { throw new Error(`Cannot find NAVIGATION_DECLARATION object in ${navDeclPath}`); } const routesInit = getProp(declObj, 'routes'); /** @type {Array<{id:string,label?:string,behavior?:string,scope?:string,paramsSchema?:Record,routePath:string,uiStateId:string}>} */ const actions = []; if (routesInit && ts.isArrayLiteralExpression(routesInit)) { for (const routeEl of routesInit.elements) { if (!ts.isObjectLiteralExpression(routeEl)) continue; const routePath = getStringLiteralValue(getProp(routeEl, 'path')) ?? ''; const uiStatesInit = getProp(routeEl, 'uiStates'); if (!uiStatesInit || !ts.isArrayLiteralExpression(uiStatesInit)) continue; for (const uiStateEl of uiStatesInit.elements) { if (!ts.isObjectLiteralExpression(uiStateEl)) continue; const uiStateId = getStringLiteralValue(getProp(uiStateEl, 'id')) ?? ''; const actionsInit = getProp(uiStateEl, 'actions'); if (!actionsInit || !ts.isArrayLiteralExpression(actionsInit)) continue; for (const actEl of actionsInit.elements) { if (!ts.isObjectLiteralExpression(actEl)) continue; const id = getStringLiteralValue(getProp(actEl, 'id')); if (!id) continue; const label = getStringLiteralValue(getProp(actEl, 'label')) ?? undefined; const behavior = getStringLiteralValue(getProp(actEl, 'behavior')) ?? undefined; const scope = getStringLiteralValue(getProp(actEl, 'scope')) ?? undefined; const paramsSchemaInit = getProp(actEl, 'paramsSchema'); let paramsSchema = undefined; if (paramsSchemaInit && ts.isObjectLiteralExpression(paramsSchemaInit)) { paramsSchema = {}; for (const prop of paramsSchemaInit.properties) { if (!ts.isPropertyAssignment(prop)) continue; const key = ts.isIdentifier(prop.name) ? prop.name.text : ts.isStringLiteral(prop.name) ? prop.name.text : null; if (!key) continue; const val = getStringLiteralValue(prop.initializer); if (val) paramsSchema[key] = val; } } actions.push({ id, label, behavior, scope, paramsSchema, routePath, uiStateId }); } } } } return actions; } function extractActionUsages(appDir, declaredIdSet) { const ignoreDirs = new Set(['node_modules', 'dist', 'build']); const exts = new Set(['.ts', '.tsx']); const files = listFilesRecursive(appDir, { exts, ignoreDirs }); /** @type {Array<{id:string, kind:'bindTap'|'bindLongPress'|'bindDoubleTap'|'data-action'|'stringLiteral', file:string, line:number, col:number}>} */ const usages = []; for (const filePath of files) { // Never treat the declaration itself as a "usage" source. if (filePath.endsWith(`${path.sep}navigation.declaration.ts`)) continue; const sf = createSourceFile(filePath); const rel = path.relative(WORKSPACE_ROOT, filePath); function record(kind, id, node) { const lc = sf.getLineAndCharacterOfPosition(node.getStart(sf)); usages.push({ id, kind, file: rel, line: lc.line + 1, col: lc.character + 1, }); } function extractActionSpecId(objLit) { if (!isObjectLiteral(objLit)) return null; const kindInit = getProp(objLit, 'kind'); const kindVal = getStringLiteralValue(kindInit); if (kindVal !== 'action') return null; return getStringLiteralValue(getProp(objLit, 'id')); } function visit(node) { // bindTap({kind:'action',id:'x'}) if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { const callee = node.expression.text; if (callee === 'bindTap' || callee === 'bindLongPress' || callee === 'bindDoubleTap') { const first = node.arguments[0]; if (first && ts.isObjectLiteralExpression(first)) { const id = extractActionSpecId(first); if (id) record(callee, id, first); } } } // JSX: data-action="x" if (ts.isJsxAttribute(node)) { const name = node.name?.text; if (name === 'data-action') { const init = node.initializer; if (init && ts.isStringLiteral(init)) { record('data-action', init.text, init); } if (init && ts.isJsxExpression(init) && init.expression) { const v = getStringLiteralValue(init.expression); if (v) record('data-action', v, init.expression); } } } // Best-effort: any string literal that exactly matches a declared actionId // (covers passing actionId via props / lookup tables). if (declaredIdSet && ts.isStringLiteral(node)) { if (declaredIdSet.has(node.text)) record('stringLiteral', node.text, node); } if (declaredIdSet && ts.isNoSubstitutionTemplateLiteral(node)) { if (declaredIdSet.has(node.text)) record('stringLiteral', node.text, node); } ts.forEachChild(node, visit); } visit(sf); } return usages; } function validateActionDeclarations(actions) { /** @type {Array<{level:'error'|'warn', code:string, message:string, actionId?:string, routePath?:string, uiStateId?:string}>} */ const issues = []; const idToDecls = new Map(); for (const a of actions) { const list = idToDecls.get(a.id) ?? []; list.push(a); idToDecls.set(a.id, list); } for (const [id, list] of idToDecls.entries()) { if (list.length > 1) { issues.push({ level: 'error', code: 'duplicate_action_id', message: `Duplicate action id "${id}" declared in multiple uiStates (${list.length}).`, actionId: id, }); } } // Allow camelCase / PascalCase segments; still require dot-separated segments. const idRe = /^[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)+$/; for (const a of actions) { const { id, behavior, scope, paramsSchema } = a; const segments = id.split('.'); if (!idRe.test(id) || segments.length < 3) { issues.push({ level: 'error', code: 'invalid_action_id', message: `Invalid actionId "${id}". Must match ${idRe} and have at least 3 segments.`, actionId: id, routePath: a.routePath, uiStateId: a.uiStateId, }); } if (behavior === 'select') { if (!/\.select\.[a-zA-Z0-9]+$/.test(id)) { issues.push({ level: 'error', code: 'select_id_format', message: `behavior='select' actionId must match ".select.