|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [ADR-0072 — reference resolvability] App-navigation targets that are not |
| 5 | + * object names: `page`, `report`, `dashboard`. |
| 6 | + * |
| 7 | + * ## The hole this closes is inside an EXISTING check, not a missing one |
| 8 | + * |
| 9 | + * `defineStack`'s `validateCrossReferences` already validates these three |
| 10 | + * (`stack.zod.ts`, the "Validate app navigation → object/dashboard/page/report |
| 11 | + * references" block). But each of the three is guarded on the collection being |
| 12 | + * non-empty: |
| 13 | + * |
| 14 | + * ```ts |
| 15 | + * if (nav.type === 'page' && typeof nav.pageName === 'string' |
| 16 | + * && pageNames.size > 0 && !pageNames.has(nav.pageName)) { … } |
| 17 | + * ``` |
| 18 | + * |
| 19 | + * So a stack that declares **no `pages` at all** has its page-nav check |
| 20 | + * silently switched off, and `{ type: 'page', pageName: 'anything' }` sails |
| 21 | + * through. That is precisely the state a stack is in when the target was never |
| 22 | + * written — the most likely way to get here, not the least. |
| 23 | + * |
| 24 | + * Note the asymmetry the guard creates. The `object` arm of the same block has |
| 25 | + * no size gate: it errors unless the item carries `requiresObject`, an |
| 26 | + * EXPLICIT opt-in to "another package provides this". Objects therefore say so |
| 27 | + * out loud; pages, reports and dashboards get an implicit exemption that |
| 28 | + * depends on an unrelated property of the stack. |
| 29 | + * |
| 30 | + * This rule restores the coverage with the ADR-0072 severity posture rather |
| 31 | + * than by tightening the parse-time throw — a throw has no escape hatch for a |
| 32 | + * legitimately cross-package page, and ADR-0072 D1's rule is that one dead |
| 33 | + * finding costs more than a missed one. |
| 34 | + * |
| 35 | + * ## Severity: warning, and why it is not error |
| 36 | + * |
| 37 | + * `validate-object-references` can say ERROR for an unresolved *object* |
| 38 | + * because it resolves against a curated `PLATFORM_PROVIDED_OBJECT_NAMES` |
| 39 | + * registry — it knows which cross-package names are real. No such registry |
| 40 | + * exists for pages, reports or dashboards, so "unresolved" genuinely cannot be |
| 41 | + * distinguished from "provided by a package we cannot see from here". |
| 42 | + * Advisory is the honest ceiling. When `defineStack`'s own check is live (the |
| 43 | + * collection is non-empty) it still hard-fails first; this rule is what speaks |
| 44 | + * when that check has switched itself off. |
| 45 | + * |
| 46 | + * ## Deliberately NOT covered — each verified, not assumed |
| 47 | + * |
| 48 | + * - **`action`** (`actionDef.actionName`) — already owned by |
| 49 | + * `validate-action-name-refs`, which walks app navigation explicitly. Adding |
| 50 | + * it here would double-report the same finding. |
| 51 | + * - **`component`** (`componentRef`) — verified a NON-rule. An unregistered ref |
| 52 | + * does NOT fail silently: `ComponentNavView` renders a named diagnostic |
| 53 | + * ("Component not registered … Ensure the plugin that provides this surface |
| 54 | + * is installed and has called `registerAppComponent()`"), and the registry |
| 55 | + * exists precisely so plugin-provided surfaces may legitimately be absent. |
| 56 | + * Flagging it would break valid plugin nav and prescribe a fix for something |
| 57 | + * already reported better at runtime. |
| 58 | + * - **`url`** — external by definition; nothing to resolve against. |
| 59 | + */ |
| 60 | + |
| 61 | +import type { ReferenceIntegrityFinding } from './reference-integrity-suite.js'; |
| 62 | + |
| 63 | +export type NavTargetRefSeverity = 'error' | 'warning'; |
| 64 | +export type NavTargetRefFinding = ReferenceIntegrityFinding; |
| 65 | + |
| 66 | +/** Emitted when a nav item targets a page/report/dashboard the stack cannot resolve. */ |
| 67 | +export const NAV_TARGET_UNRESOLVED = 'nav-target-unresolved'; |
| 68 | + |
| 69 | +type AnyRec = Record<string, unknown>; |
| 70 | + |
| 71 | +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); |
| 72 | + |
| 73 | +function asArray(v: unknown): AnyRec[] { |
| 74 | + if (Array.isArray(v)) return v.filter(isRec); |
| 75 | + if (isRec(v)) return Object.entries(v).map(([name, def]) => (isRec(def) ? { name, ...def } : { name })); |
| 76 | + return []; |
| 77 | +} |
| 78 | + |
| 79 | +function strName(v: unknown): string | undefined { |
| 80 | + return typeof v === 'string' && v.length > 0 ? v : undefined; |
| 81 | +} |
| 82 | + |
| 83 | +/** |
| 84 | + * An interpolated target resolves at render time — the same conservative |
| 85 | + * exemption `validate-object-references` and `validate-dashboard-action-refs` |
| 86 | + * use to keep false positives near zero (ADR-0072 D1). |
| 87 | + */ |
| 88 | +const isInterpolated = (s: string): boolean => s.includes('${') || s.includes('{'); |
| 89 | + |
| 90 | +/** nav `type` → [target property, stack collection, human noun]. */ |
| 91 | +const NAV_TARGETS: ReadonlyArray<readonly [string, string, string, string]> = [ |
| 92 | + ['page', 'pageName', 'pages', 'page'], |
| 93 | + ['report', 'reportName', 'reports', 'report'], |
| 94 | + ['dashboard', 'dashboardName', 'dashboards', 'dashboard'], |
| 95 | +]; |
| 96 | + |
| 97 | +function namesOf(collection: unknown): Set<string> { |
| 98 | + const out = new Set<string>(); |
| 99 | + for (const entry of asArray(collection)) { |
| 100 | + const n = strName(entry.name); |
| 101 | + if (n) out.add(n); |
| 102 | + } |
| 103 | + return out; |
| 104 | +} |
| 105 | + |
| 106 | +export function validateNavTargetRefs(stack: unknown): NavTargetRefFinding[] { |
| 107 | + const findings: NavTargetRefFinding[] = []; |
| 108 | + if (!isRec(stack)) return findings; |
| 109 | + |
| 110 | + const apps = asArray(stack.apps); |
| 111 | + if (apps.length === 0) return findings; |
| 112 | + |
| 113 | + const declared = new Map<string, Set<string>>(); |
| 114 | + for (const [, , collection] of NAV_TARGETS) { |
| 115 | + declared.set(collection, namesOf((stack as AnyRec)[collection])); |
| 116 | + } |
| 117 | + |
| 118 | + for (const [ai, app] of apps.entries()) { |
| 119 | + const appName = strName(app.name) ?? `#${ai}`; |
| 120 | + |
| 121 | + const walk = (items: unknown, basePath: string): void => { |
| 122 | + if (!Array.isArray(items)) return; |
| 123 | + for (const [ni, raw] of items.entries()) { |
| 124 | + if (!isRec(raw)) continue; |
| 125 | + const nav = raw; |
| 126 | + const navPath = `${basePath}[${ni}]`; |
| 127 | + |
| 128 | + for (const [type, prop, collection, noun] of NAV_TARGETS) { |
| 129 | + if (nav.type !== type) continue; |
| 130 | + const target = strName(nav[prop]); |
| 131 | + if (!target || isInterpolated(target)) continue; |
| 132 | + const known = declared.get(collection)!; |
| 133 | + if (known.has(target)) continue; |
| 134 | + |
| 135 | + const emptyCollection = known.size === 0; |
| 136 | + findings.push({ |
| 137 | + severity: 'warning', |
| 138 | + rule: NAV_TARGET_UNRESOLVED, |
| 139 | + where: `app "${appName}" · nav "${strName(nav.id) ?? strName(nav.label) ?? `#${ni}`}"`, |
| 140 | + path: `${navPath}.${prop}`, |
| 141 | + message: |
| 142 | + `Navigation targets ${noun} '${target}', which this stack does not declare in ` |
| 143 | + + `\`${collection}\`. ` |
| 144 | + + (emptyCollection |
| 145 | + ? `The stack declares NO ${collection} at all, so \`defineStack\`'s own ` |
| 146 | + + `cross-reference check skipped this entry entirely (it is gated on ` |
| 147 | + + `\`${collection === 'pages' ? 'pageNames' : collection === 'reports' ? 'reportNames' : 'dashboardNames'}.size > 0\`) — ` |
| 148 | + + `nothing else will report it. ` |
| 149 | + : '') |
| 150 | + + `The entry renders in the sidebar and resolves to nothing when clicked. If another ` |
| 151 | + + `package provides this ${noun}, this is expected and advisory only.`, |
| 152 | + hint: |
| 153 | + `Declare the ${noun} in \`${collection}\`, correct the name, or remove the nav entry ` |
| 154 | + + `if the ${noun} is gone.`, |
| 155 | + }); |
| 156 | + } |
| 157 | + |
| 158 | + // Recurse: an `object` nav item carries `children` too, not just a |
| 159 | + // `group` — the same reason `stack.zod.ts` does not gate its recursion |
| 160 | + // on the item type. |
| 161 | + if (Array.isArray(nav.children)) walk(nav.children, `${navPath}.children`); |
| 162 | + } |
| 163 | + }; |
| 164 | + |
| 165 | + walk(app.navigation, `apps[${ai}].navigation`); |
| 166 | + // `areas[]` is the other nav container; it was once skipped wholesale in |
| 167 | + // `stack.zod.ts`, so an areas-based app got no nav validation at all. |
| 168 | + for (const [ari, area] of asArray(app.areas).entries()) { |
| 169 | + walk(area.items, `apps[${ai}].areas[${ari}].items`); |
| 170 | + walk(area.navigation, `apps[${ai}].areas[${ari}].navigation`); |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | + return findings; |
| 175 | +} |
0 commit comments