|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Shared traversal: where the AUTHORED filters are in a metadata stack. |
| 5 | + * |
| 6 | + * Two rules in this package need the same answer to the same question — "which |
| 7 | + * values in this stack were authored as a filter?" — and they need it for |
| 8 | + * different reasons: `validate-filter-tokens.ts` classifies the STRINGS inside |
| 9 | + * those subtrees (#3574), `validate-empty-combinators.ts` classifies their |
| 10 | + * SHAPE (#5330). The subtree-finding half is identical for both, and it is the |
| 11 | + * half with the interesting failure mode: #3574 happened because a resolver |
| 12 | + * enumerated known surfaces and the dashboard was simply never added to the |
| 13 | + * list. `page-walk.ts` (#3583/#5405) and `view-walk.ts` (#6381) are the same |
| 14 | + * argument on two other traversals — with N copies the next author fixes one of |
| 15 | + * N and the survivors keep the old verdict — and this file is written from |
| 16 | + * theirs. |
| 17 | + * |
| 18 | + * ## What is shared, and what deliberately is NOT |
| 19 | + * |
| 20 | + * The MECHANISM is shared: descend a stack item, recognise a filter KEY, hand |
| 21 | + * the subtree to a visitor. The SURFACE LIST is a parameter, not a constant, |
| 22 | + * because the two callers genuinely differ: the token rule scans the seven |
| 23 | + * presentation collections it has always scanned, and adding an eighth to a |
| 24 | + * shared constant would silently widen a live gating rule. A caller declares |
| 25 | + * its own {@link FilterSurface} list and owns that decision. |
| 26 | + * |
| 27 | + * ## Scanning for KEYS rather than enumerating surfaces |
| 28 | + * |
| 29 | + * Widget filters, list-view filters, dataset and measure filters, report |
| 30 | + * runtime filters, flow CRUD node filters and SDUI component filters all spell |
| 31 | + * the key the same way, so a new surface that follows the convention is covered |
| 32 | + * the day it ships. That is the property #3574 lacked. |
| 33 | + * |
| 34 | + * Navigation `recordId` / `params` are NOT filter keys and are never visited: |
| 35 | + * they resolve an additional vocabulary (`AppContextSelector` ids such as |
| 36 | + * `{active_package}`) that is meaningless in a filter, and restricting the walk |
| 37 | + * is what holds false positives at zero. |
| 38 | + */ |
| 39 | + |
| 40 | +/** Any plain metadata record. */ |
| 41 | +type AnyRec = Record<string, unknown>; |
| 42 | + |
| 43 | +/** Keys whose subtree is a filter. The one place a filter is authored. */ |
| 44 | +export const FILTER_KEYS: ReadonlySet<string> = new Set(['filter', 'filters', 'runtimeFilter']); |
| 45 | + |
| 46 | +/** One stack collection a caller wants walked. */ |
| 47 | +export interface FilterSurface { |
| 48 | + /** Stack collection key — `dashboards`, `objects`, `flows`, … */ |
| 49 | + key: string; |
| 50 | + /** Singular noun used in the `where` label — `dashboard`, `object`, `flow`, … */ |
| 51 | + kind: string; |
| 52 | +} |
| 53 | + |
| 54 | +/** One authored filter subtree, with everything a finding needs to name it. */ |
| 55 | +export interface AuthoredFilter { |
| 56 | + /** The value found under the filter key, exactly as authored. */ |
| 57 | + value: unknown; |
| 58 | + /** Config path, e.g. `dashboards[0].widgets[2].filter`. */ |
| 59 | + path: string; |
| 60 | + /** Human-readable location, e.g. `dashboard "sales" · widget "my_deals"`. */ |
| 61 | + where: string; |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * Coerce a collection (array or name-keyed map) to an array of records, |
| 66 | + * injecting `name` from the map key — so a rule works on both the parsed |
| 67 | + * (array) and normalized (map) stack shapes. |
| 68 | + */ |
| 69 | +function asArray(v: unknown): AnyRec[] { |
| 70 | + if (Array.isArray(v)) return v as AnyRec[]; |
| 71 | + if (v && typeof v === 'object') { |
| 72 | + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); |
| 73 | + } |
| 74 | + return []; |
| 75 | +} |
| 76 | + |
| 77 | +function label(v: unknown, fallback: string): string { |
| 78 | + return typeof v === 'string' && v.length > 0 ? v : fallback; |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * Find filter subtrees anywhere beneath `node` and hand each to `visit`. |
| 83 | + * |
| 84 | + * Exported for a caller that already has a single item in hand (the runtime |
| 85 | + * publish gate's per-write snapshot arrives that way) rather than a whole stack. |
| 86 | + */ |
| 87 | +export function scanForFilters( |
| 88 | + node: unknown, |
| 89 | + path: string, |
| 90 | + where: string, |
| 91 | + visit: (filter: AuthoredFilter) => void, |
| 92 | + seen: Set<unknown> = new Set(), |
| 93 | +): void { |
| 94 | + if (!node || typeof node !== 'object') return; |
| 95 | + // Metadata graphs can be cyclic once normalized; guard the walk. |
| 96 | + if (seen.has(node)) return; |
| 97 | + seen.add(node); |
| 98 | + |
| 99 | + if (Array.isArray(node)) { |
| 100 | + node.forEach((v, i) => scanForFilters(v, `${path}[${i}]`, where, visit, seen)); |
| 101 | + return; |
| 102 | + } |
| 103 | + |
| 104 | + for (const [k, v] of Object.entries(node as AnyRec)) { |
| 105 | + const childPath = `${path}.${k}`; |
| 106 | + if (FILTER_KEYS.has(k)) { |
| 107 | + visit({ value: v, path: childPath, where }); |
| 108 | + continue; |
| 109 | + } |
| 110 | + scanForFilters(v, childPath, where, visit, seen); |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +/** |
| 115 | + * Walk every authored filter in `stack` across the caller's surfaces. |
| 116 | + * |
| 117 | + * Pure traversal: it holds no judgement and emits no findings. Dashboards get |
| 118 | + * a per-widget `where` because that is the surface #3574 was filed against and |
| 119 | + * naming the widget is what lets an author jump straight to it; every other |
| 120 | + * surface is named by its collection kind and its own `name` / `id`. |
| 121 | + */ |
| 122 | +export function walkAuthoredFilters( |
| 123 | + stack: unknown, |
| 124 | + surfaces: readonly FilterSurface[], |
| 125 | + visit: (filter: AuthoredFilter) => void, |
| 126 | +): void { |
| 127 | + if (!stack || typeof stack !== 'object') return; |
| 128 | + |
| 129 | + for (const { key, kind } of surfaces) { |
| 130 | + const items = asArray((stack as AnyRec)[key]); |
| 131 | + items.forEach((item, i) => { |
| 132 | + const name = label(item.name ?? item.id, `#${i}`); |
| 133 | + if (kind === 'dashboard') { |
| 134 | + const widgets = Array.isArray(item.widgets) ? (item.widgets as AnyRec[]) : []; |
| 135 | + widgets.forEach((w, wi) => { |
| 136 | + const wName = label(w.id ?? w.title, `#${wi}`); |
| 137 | + scanForFilters( |
| 138 | + w, |
| 139 | + `${key}[${i}].widgets[${wi}]`, |
| 140 | + `dashboard "${name}" · widget "${wName}"`, |
| 141 | + visit, |
| 142 | + new Set(), |
| 143 | + ); |
| 144 | + }); |
| 145 | + // ...and everything else on the dashboard (globalFilters, header, etc.) |
| 146 | + // minus the widgets already covered above. |
| 147 | + const { widgets: _skip, ...rest } = item; |
| 148 | + scanForFilters(rest, `${key}[${i}]`, `dashboard "${name}"`, visit, new Set()); |
| 149 | + return; |
| 150 | + } |
| 151 | + scanForFilters(item, `${key}[${i}]`, `${kind} "${name}"`, visit, new Set()); |
| 152 | + }); |
| 153 | + } |
| 154 | +} |
0 commit comments