Mirrored from GitHub github.com/roostorg/osprey
0

Configure Feed

Select the types of activity you want to include in your feed.

[osprey-ui] Rules Registry page (#277)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+883 -121
+1
CHANGELOG.md
··· 21 21 - Add `ParseInt` UDF — converts a numeric string to an integer ([#190](https://github.com/roostorg/osprey/pull/190) by [@bealsbe](https://github.com/bealsbe)) 22 22 - Add `StringSlice` UDF which extracts a substring by index range ([#189](https://github.com/roostorg/osprey/pull/189) by [@bealsbe](https://github.com/bealsbe)) 23 23 - Add `InExperiment` UDF which checks if an entity is in an experiment ([#203](https://github.com/roostorg/osprey/pull/203) by [@bealsbe](https://github.com/bealsbe)) 24 + - Add Rules Registry page at `/rules` ([#277](https://github.com/roostorg/osprey/pull/277) by [@haileyok](https://github.com/haileyok)) 24 25 25 26 ### 🐛 Bug fixes 26 27 - Default to selecting all for event stream ([#194](https://github.com/roostorg/osprey/pull/194) by [@chimosky](https://github.com/chimosky))
+4
osprey_ui/src/App.tsx
··· 6 6 import UdfDocsView from './components/docs/UdfDocsView'; 7 7 import BulkJobHistoryView from './components/bulk_job_history/BulkJobHistory'; 8 8 import { FeaturesPage } from './components/features/FeaturesPage'; 9 + import { RulesPage } from './components/rules/RulesPage'; 9 10 import RulesVisualizerView from './components/rules_visualizer/RulesVisualizer'; 10 11 import EntityViewBar from './components/entities/EntityViewBar'; 11 12 import EventPage from './components/event_stream/EventPage'; ··· 103 104 </Route> 104 105 <Route path={Routes.FEATURES}> 105 106 <FeaturesPage /> 107 + </Route> 108 + <Route path={Routes.RULES}> 109 + <RulesPage /> 106 110 </Route> 107 111 <Route exact path={[Routes.ENTITY, Routes.HOME, Routes.SAVED_QUERY]}> 108 112 <QueryView />
+1
osprey_ui/src/Constants.tsx
··· 5 5 DOCS_UDFS: '/docs/udfs', 6 6 ENTITY: '/entity/:entityType/:entityId', 7 7 FEATURES: '/features', 8 + RULES: '/rules', 8 9 SAVED_QUERY: '/saved-query/:savedQueryId', 9 10 SAVED_QUERY_LATEST: '/saved-query/:savedQueryId/latest', 10 11 BULK_JOB_HISTORY: '/bulk-job-history',
+10
osprey_ui/src/actions/RulesActions.tsx
··· 1 + import HTTPUtils, { HTTPResponse } from '../utils/HTTPUtils'; 2 + import { RulesListResponse } from '../types/RulesTypes'; 3 + 4 + export async function getRulesList(): Promise<RulesListResponse> { 5 + const response: HTTPResponse = await HTTPUtils.get('rules'); 6 + if (response.ok) { 7 + return response.data; 8 + } 9 + throw new Error(response.error.message ?? 'Failed to fetch rules list'); 10 + }
+2
osprey_ui/src/components/navigation/NavBar.tsx
··· 11 11 MenuFoldOutlined, 12 12 MenuUnfoldOutlined, 13 13 DatabaseOutlined, 14 + FileTextOutlined, 14 15 } from '@ant-design/icons'; 15 16 import { Link, useLocation } from 'react-router-dom'; 16 17 ··· 52 53 { key: Routes.RULES_VISUALIZER, icon: <ApartmentOutlined />, label: 'Rules Visualizer' }, 53 54 { key: Routes.DOCS_UDFS, icon: <FunctionOutlined />, label: 'UDF Registry' }, 54 55 { key: Routes.FEATURES, icon: <DatabaseOutlined />, label: 'Features' }, 56 + { key: Routes.RULES, icon: <FileTextOutlined />, label: 'Rules' }, 55 57 ], 56 58 }, 57 59 {
+66
osprey_ui/src/components/rules/RulesPage.module.css
··· 1 + .viewContainer { 2 + height: 100%; 3 + width: 100%; 4 + display: flex; 5 + flex-direction: column; 6 + overflow: hidden; 7 + } 8 + 9 + .scrollArea { 10 + flex: 1; 11 + overflow-y: auto; 12 + padding: 24px; 13 + } 14 + 15 + .statsRow { 16 + display: flex; 17 + gap: 16px; 18 + margin-bottom: 24px; 19 + } 20 + 21 + .statsRow > * { 22 + flex: 1; 23 + } 24 + 25 + .statCardClickable { 26 + cursor: pointer; 27 + } 28 + 29 + .statCardActive { 30 + border-color: var(--brand-primary); 31 + } 32 + 33 + .ruleName { 34 + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; 35 + font-weight: 600; 36 + } 37 + 38 + .ruleSource { 39 + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; 40 + color: var(--text-light-secondary); 41 + font-size: 12px; 42 + flex: 1; 43 + overflow: hidden; 44 + text-overflow: ellipsis; 45 + white-space: nowrap; 46 + min-width: 0; 47 + } 48 + 49 + .headerRow { 50 + display: flex; 51 + align-items: center; 52 + gap: 8px; 53 + min-width: 0; 54 + } 55 + 56 + .conditionBlock { 57 + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; 58 + font-size: 12px; 59 + padding: 8px 10px; 60 + background: var(--background-secondary); 61 + border: 1px solid var(--divider); 62 + border-radius: 4px; 63 + white-space: pre-wrap; 64 + overflow-wrap: break-word; 65 + margin: 0; 66 + }
+315
osprey_ui/src/components/rules/RulesPage.tsx
··· 1 + import * as React from 'react'; 2 + import { 3 + Card, 4 + Collapse, 5 + Descriptions, 6 + Empty, 7 + Input, 8 + Pagination, 9 + Select, 10 + Space, 11 + Statistic, 12 + Switch, 13 + Tag, 14 + Tooltip, 15 + Typography, 16 + } from 'antd'; 17 + import { SearchOutlined } from '@ant-design/icons'; 18 + 19 + import { getRulesList } from '../../actions/RulesActions'; 20 + import usePromiseResult from '../../hooks/usePromiseResult'; 21 + import { RuleInfo, RulesListResponse, SortKey } from '../../types/RulesTypes'; 22 + import { renderFromPromiseResult } from '../../utils/PromiseResultUtils'; 23 + 24 + import styles from './RulesPage.module.css'; 25 + 26 + const { Title, Paragraph, Text } = Typography; 27 + 28 + type FiltersState = { 29 + search: string; 30 + unusedOnly: boolean; 31 + sortKey: SortKey; 32 + page: number; 33 + pageSize: number; 34 + }; 35 + 36 + type FiltersAction = 37 + | { type: 'setSearch'; value: string } 38 + | { type: 'setUnusedOnly'; value: boolean } 39 + | { type: 'toggleUnusedOnly' } 40 + | { type: 'setSortKey'; value: SortKey } 41 + | { type: 'setPage'; page: number; pageSize: number }; 42 + 43 + const INITIAL_FILTERS: FiltersState = { 44 + search: '', 45 + unusedOnly: false, 46 + sortKey: SortKey.MostReferenced, 47 + page: 1, 48 + pageSize: 50, 49 + }; 50 + 51 + // Every filter action resets page to 1; only setPage preserves it. 52 + function filtersReducer(state: FiltersState, action: FiltersAction): FiltersState { 53 + switch (action.type) { 54 + case 'setSearch': { 55 + return { ...state, search: action.value, page: 1 }; 56 + } 57 + case 'setUnusedOnly': { 58 + return { ...state, unusedOnly: action.value, page: 1 }; 59 + } 60 + case 'toggleUnusedOnly': { 61 + return { ...state, unusedOnly: !state.unusedOnly, page: 1 }; 62 + } 63 + case 'setSortKey': { 64 + return { ...state, sortKey: action.value, page: 1 }; 65 + } 66 + case 'setPage': { 67 + return { ...state, page: action.page, pageSize: action.pageSize }; 68 + } 69 + } 70 + } 71 + 72 + export const RulesPage: React.FC = () => { 73 + const result = usePromiseResult(() => { 74 + return getRulesList(); 75 + }); 76 + 77 + return renderFromPromiseResult(result, (data) => { 78 + return <RulesPageContent data={data} />; 79 + }); 80 + }; 81 + 82 + const RulesPageContent: React.FC<{ data: RulesListResponse }> = ({ data }) => { 83 + const [filters, dispatch] = React.useReducer(filtersReducer, INITIAL_FILTERS); 84 + const { rules, total, when_rules_total, unused_total } = data; 85 + const { search, unusedOnly, sortKey, page, pageSize } = filters; 86 + 87 + const filtered = React.useMemo(() => { 88 + const query = search.trim().toLowerCase(); 89 + const list = rules.filter((r) => { 90 + if ( 91 + query && 92 + !r.name.toLowerCase().includes(query) && 93 + !r.source_file.toLowerCase().includes(query) && 94 + !r.description.toLowerCase().includes(query) 95 + ) { 96 + return false; 97 + } 98 + if (unusedOnly && r.referenced_by_whenrules !== 0) { 99 + return false; 100 + } 101 + return true; 102 + }); 103 + if (sortKey === SortKey.Name) { 104 + return [...list].sort((a, b) => { 105 + return a.name.localeCompare(b.name); 106 + }); 107 + } 108 + if (sortKey === SortKey.MostReferenced) { 109 + return [...list].sort((a, b) => { 110 + return b.referenced_by_whenrules - a.referenced_by_whenrules || a.name.localeCompare(b.name); 111 + }); 112 + } 113 + return [...list].sort((a, b) => { 114 + return a.referenced_by_whenrules - b.referenced_by_whenrules || a.name.localeCompare(b.name); 115 + }); 116 + }, [rules, search, unusedOnly, sortKey]); 117 + 118 + const paginated = React.useMemo(() => { 119 + return filtered.slice((page - 1) * pageSize, page * pageSize); 120 + }, [filtered, page, pageSize]); 121 + 122 + const collapseItems = React.useMemo(() => { 123 + return paginated.map((r) => { 124 + return { 125 + key: r.name, 126 + label: <RuleHeader rule={r} />, 127 + children: <RuleDetail rule={r} />, 128 + }; 129 + }); 130 + }, [paginated]); 131 + 132 + return ( 133 + <div className={styles.viewContainer}> 134 + <div className={styles.scrollArea}> 135 + <Title level={3} style={{ marginBottom: 4 }}> 136 + Rules Registry 137 + </Title> 138 + <Paragraph type="secondary"> 139 + Named rule definitions across the engine — conditions, descriptions, the features each rule references, and 140 + how many WhenRules blocks include it. 141 + </Paragraph> 142 + 143 + <div className={styles.statsRow}> 144 + <Card size="small"> 145 + <Statistic title="Rules" value={total} /> 146 + </Card> 147 + <Tooltip title="Total WhenRules blocks across all sources."> 148 + <Card size="small"> 149 + <Statistic title="WhenRules" value={when_rules_total} /> 150 + </Card> 151 + </Tooltip> 152 + <Tooltip 153 + title={ 154 + unusedOnly 155 + ? 'Filtering to unused only — click to clear.' 156 + : 'Rules with no references from WhenRules blocks — cleanup candidates. Click to filter.' 157 + } 158 + > 159 + <Card 160 + size="small" 161 + className={`${styles.statCardClickable} ${unusedOnly ? styles.statCardActive : ''}`} 162 + role="button" 163 + tabIndex={0} 164 + aria-pressed={unusedOnly} 165 + onClick={() => { 166 + dispatch({ type: 'toggleUnusedOnly' }); 167 + }} 168 + onKeyDown={(e) => { 169 + if (e.key === 'Enter' || e.key === ' ') { 170 + e.preventDefault(); 171 + dispatch({ type: 'toggleUnusedOnly' }); 172 + } 173 + }} 174 + > 175 + <Statistic title="Unused rules" value={unused_total} /> 176 + </Card> 177 + </Tooltip> 178 + </div> 179 + 180 + <Space wrap style={{ marginBottom: 12 }}> 181 + <Input 182 + size="small" 183 + prefix={<SearchOutlined />} 184 + placeholder="Search rules..." 185 + value={search} 186 + onChange={(e) => { 187 + dispatch({ type: 'setSearch', value: e.target.value }); 188 + }} 189 + allowClear 190 + style={{ width: 280 }} 191 + /> 192 + <Select<SortKey> 193 + size="small" 194 + value={sortKey} 195 + onChange={(value) => { 196 + dispatch({ type: 'setSortKey', value }); 197 + }} 198 + style={{ width: 170 }} 199 + options={[ 200 + { value: SortKey.MostReferenced, label: 'Most referenced' }, 201 + { value: SortKey.LeastReferenced, label: 'Least referenced' }, 202 + { value: SortKey.Name, label: 'Name (A-Z)' }, 203 + ]} 204 + /> 205 + <Space size={6}> 206 + <Switch 207 + size="small" 208 + checked={unusedOnly} 209 + onChange={(value) => { 210 + dispatch({ type: 'setUnusedOnly', value }); 211 + }} 212 + /> 213 + <span style={{ fontSize: 12 }}>Unused only</span> 214 + </Space> 215 + </Space> 216 + 217 + <Title level={5} style={{ marginTop: 8 }}> 218 + Rules ({filtered.length}) 219 + </Title> 220 + {filtered.length === 0 ? ( 221 + <Empty description="No rules match the current filters" /> 222 + ) : ( 223 + <> 224 + <Collapse items={collapseItems} bordered={false} /> 225 + <Pagination 226 + current={page} 227 + pageSize={pageSize} 228 + total={filtered.length} 229 + onChange={(page, pageSize) => { 230 + dispatch({ type: 'setPage', page, pageSize }); 231 + }} 232 + showSizeChanger 233 + pageSizeOptions={['25', '50', '100', '200']} 234 + showTotal={(total, [start, end]) => { 235 + return `${start}–${end} of ${total}`; 236 + }} 237 + size="small" 238 + align="center" 239 + style={{ marginTop: 20 }} 240 + /> 241 + </> 242 + )} 243 + </div> 244 + </div> 245 + ); 246 + }; 247 + 248 + const RuleHeader: React.FC<{ rule: RuleInfo }> = ({ rule }) => { 249 + const isUnused = rule.referenced_by_whenrules === 0; 250 + return ( 251 + <div className={styles.headerRow}> 252 + <code className={styles.ruleName}>{rule.name}</code> 253 + {isUnused && ( 254 + <Tooltip title="This rule is defined but no WhenRules block references it. Possible cleanup candidate."> 255 + <Tag color="orange">unused</Tag> 256 + </Tooltip> 257 + )} 258 + <span className={styles.ruleSource}>{rule.source_file}</span> 259 + <Space size={6} style={{ flexShrink: 0 }}> 260 + {rule.referenced_by_whenrules > 0 && ( 261 + <Text type="secondary" style={{ fontSize: 11 }}> 262 + <strong>{rule.referenced_by_whenrules}</strong> when-rules 263 + </Text> 264 + )} 265 + </Space> 266 + </div> 267 + ); 268 + }; 269 + 270 + const RuleDetail: React.FC<{ rule: RuleInfo }> = ({ rule }) => { 271 + return ( 272 + <Descriptions 273 + size="small" 274 + column={1} 275 + colon={false} 276 + styles={{ label: { width: 180, fontWeight: 600, paddingRight: 16 } }} 277 + > 278 + <Descriptions.Item label="Source file"> 279 + <code>{rule.source_file}</code> 280 + </Descriptions.Item> 281 + <Descriptions.Item label="Description"> 282 + {rule.description ? <Text>{rule.description}</Text> : <Text type="secondary">—</Text>} 283 + </Descriptions.Item> 284 + <Descriptions.Item label={`When all (${rule.when_all.length})`}> 285 + {rule.when_all.length === 0 ? ( 286 + <Text type="secondary">—</Text> 287 + ) : ( 288 + <Space direction="vertical" size={4} style={{ width: '100%' }}> 289 + {rule.when_all.map((cond, i) => { 290 + return ( 291 + <pre key={i} className={styles.conditionBlock}> 292 + {cond} 293 + </pre> 294 + ); 295 + })} 296 + </Space> 297 + )} 298 + </Descriptions.Item> 299 + <Descriptions.Item label={`Referenced features (${rule.referenced_features.length})`}> 300 + {rule.referenced_features.length === 0 ? ( 301 + <Text type="secondary">—</Text> 302 + ) : ( 303 + <Space size={6} wrap> 304 + {rule.referenced_features.map((name) => { 305 + return <code key={name}>{name}</code>; 306 + })} 307 + </Space> 308 + )} 309 + </Descriptions.Item> 310 + <Descriptions.Item label="WhenRules"> 311 + {`${rule.referenced_by_whenrules} block${rule.referenced_by_whenrules === 1 ? '' : 's'}`} 312 + </Descriptions.Item> 313 + </Descriptions> 314 + ); 315 + };
+21
osprey_ui/src/types/RulesTypes.tsx
··· 1 + export interface RuleInfo { 2 + name: string; 3 + source_file: string; 4 + description: string; 5 + when_all: string[]; 6 + referenced_features: string[]; 7 + referenced_by_whenrules: number; 8 + } 9 + 10 + export interface RulesListResponse { 11 + rules: RuleInfo[]; 12 + total: number; 13 + when_rules_total: number; 14 + unused_total: number; 15 + } 16 + 17 + export enum SortKey { 18 + Name = 'name', 19 + MostReferenced = 'most-referenced', 20 + LeastReferenced = 'least-referenced', 21 + }
+2
osprey_worker/src/osprey/worker/ui_api/osprey/app.py
··· 68 68 events, 69 69 features, 70 70 queries, 71 + rules, 71 72 rules_visualizer, 72 73 saved_queries, 73 74 ) ··· 109 110 _register_with_prefix(app, entities.blueprint) 110 111 _register_with_prefix(app, events.blueprint) 111 112 _register_with_prefix(app, features.blueprint) 113 + _register_with_prefix(app, rules.blueprint) 112 114 _register_with_prefix(app, queries.blueprint) 113 115 _register_with_prefix(app, config.blueprint) 114 116 _register_with_prefix(app, docs.blueprint)
+113
osprey_worker/src/osprey/worker/ui_api/osprey/views/_engine_ast_utils.py
··· 1 + """Shared engine-AST traversal helpers used by views (features.py, rules.py, ...).""" 2 + 3 + from typing import Any, Optional, Set 4 + 5 + from osprey.engine.ast.grammar import ( 6 + Attribute, 7 + BinaryComparison, 8 + BinaryOperation, 9 + BooleanOperation, 10 + Call, 11 + FormatString, 12 + Name, 13 + Number, 14 + String, 15 + UnaryOperation, 16 + ) 17 + from osprey.engine.ast.grammar import ( 18 + List as AstList, 19 + ) 20 + 21 + 22 + def get_func_identifier(call: Call) -> Optional[str]: 23 + """Get the function name from a Call node.""" 24 + if isinstance(call.func, Name): 25 + return call.func.identifier 26 + if isinstance(call.func, Attribute): 27 + return call.func.attribute 28 + return None 29 + 30 + 31 + def ast_to_string(node: Any) -> str: 32 + """Convert an AST expression node to a human-readable string.""" 33 + if isinstance(node, Name): 34 + return node.identifier 35 + if isinstance(node, String): 36 + return repr(node.value) 37 + if isinstance(node, Number): 38 + return str(node.value) 39 + if isinstance(node, BinaryComparison): 40 + left = ast_to_string(node.left) 41 + right = ast_to_string(node.right) 42 + return f'{left} {node.comparator.original_comparator} {right}' 43 + if isinstance(node, BinaryOperation): 44 + left = ast_to_string(node.left) 45 + right = ast_to_string(node.right) 46 + return f'{left} {node.operator.original_operator} {right}' 47 + if isinstance(node, UnaryOperation): 48 + operand = ast_to_string(node.operand) 49 + return f'{node.operator.original_operator} {operand}' 50 + if isinstance(node, BooleanOperation): 51 + parts = [ast_to_string(v) for v in node.values] 52 + return f' {node.operand.original_operand} '.join(parts) 53 + if isinstance(node, Call): 54 + func_name = get_func_identifier(node) or '?' 55 + parts = [] 56 + for arg in node.arguments: 57 + parts.append(f'{arg.name}={ast_to_string(arg.value)}') 58 + return f'{func_name}({", ".join(parts)})' 59 + if isinstance(node, Attribute): 60 + return f'{node.name.identifier}.{node.attribute}' 61 + if isinstance(node, AstList): 62 + items = [ast_to_string(item) for item in node.items] 63 + return f'[{", ".join(items)}]' 64 + if isinstance(node, FormatString): 65 + return f"f'{node.format_string}'" 66 + if hasattr(node, 'value'): 67 + return str(node.value) 68 + return str(node) 69 + 70 + 71 + def collect_name_references(node: Any, out: Set[str]) -> None: 72 + """Recursively collect all Name.identifier values referenced by an expression node. 73 + 74 + Skip the function-identifier position of Call nodes (we don't treat e.g. 75 + `JsonData` in `JsonData(...)` as a feature reference). Walk FormatString.names, 76 + BinaryOperation/BinaryComparison left/right, BooleanOperation values, 77 + UnaryOperation operand, AstList items, and Attribute chains. 78 + """ 79 + if node is None: 80 + return 81 + if isinstance(node, Name): 82 + out.add(node.identifier) 83 + return 84 + if isinstance(node, Call): 85 + for arg in node.arguments: 86 + collect_name_references(arg.value, out) 87 + return 88 + if isinstance(node, FormatString): 89 + for n in node.names: 90 + out.add(n.identifier) 91 + return 92 + if isinstance(node, BinaryComparison): 93 + collect_name_references(node.left, out) 94 + collect_name_references(node.right, out) 95 + return 96 + if isinstance(node, BinaryOperation): 97 + collect_name_references(node.left, out) 98 + collect_name_references(node.right, out) 99 + return 100 + if isinstance(node, UnaryOperation): 101 + collect_name_references(node.operand, out) 102 + return 103 + if isinstance(node, BooleanOperation): 104 + for v in node.values: 105 + collect_name_references(v, out) 106 + return 107 + if isinstance(node, AstList): 108 + for item in node.items: 109 + collect_name_references(item, out) 110 + return 111 + if isinstance(node, Attribute): 112 + collect_name_references(node.name, out) 113 + return
-68
osprey_worker/src/osprey/worker/ui_api/osprey/views/_features_ast_utils.py
··· 1 - """Shared AST traversal helpers used by features.py.""" 2 - 3 - from typing import Any, Optional 4 - 5 - from osprey.engine.ast.grammar import ( 6 - Attribute, 7 - BinaryComparison, 8 - BinaryOperation, 9 - BooleanOperation, 10 - Call, 11 - FormatString, 12 - Name, 13 - Number, 14 - String, 15 - UnaryOperation, 16 - ) 17 - from osprey.engine.ast.grammar import ( 18 - List as AstList, 19 - ) 20 - 21 - 22 - def get_func_identifier(call: Call) -> Optional[str]: 23 - """Get the function name from a Call node.""" 24 - if isinstance(call.func, Name): 25 - return call.func.identifier 26 - if isinstance(call.func, Attribute): 27 - return call.func.attribute 28 - return None 29 - 30 - 31 - def ast_to_string(node: Any) -> str: 32 - """Convert an AST expression node to a human-readable string.""" 33 - if isinstance(node, Name): 34 - return node.identifier 35 - if isinstance(node, String): 36 - return repr(node.value) 37 - if isinstance(node, Number): 38 - return str(node.value) 39 - if isinstance(node, BinaryComparison): 40 - left = ast_to_string(node.left) 41 - right = ast_to_string(node.right) 42 - return f'{left} {node.comparator.original_comparator} {right}' 43 - if isinstance(node, BinaryOperation): 44 - left = ast_to_string(node.left) 45 - right = ast_to_string(node.right) 46 - return f'{left} {node.operator.original_operator} {right}' 47 - if isinstance(node, UnaryOperation): 48 - operand = ast_to_string(node.operand) 49 - return f'{node.operator.original_operator} {operand}' 50 - if isinstance(node, BooleanOperation): 51 - parts = [ast_to_string(v) for v in node.values] 52 - return f' {node.operand.original_operand} '.join(parts) 53 - if isinstance(node, Call): 54 - func_name = get_func_identifier(node) or '?' 55 - parts = [] 56 - for arg in node.arguments: 57 - parts.append(f'{arg.name}={ast_to_string(arg.value)}') 58 - return f'{func_name}({", ".join(parts)})' 59 - if isinstance(node, Attribute): 60 - return f'{node.name.identifier}.{node.attribute}' 61 - if isinstance(node, AstList): 62 - items = [ast_to_string(item) for item in node.items] 63 - return f'[{", ".join(items)}]' 64 - if isinstance(node, FormatString): 65 - return f"f'{node.format_string}'" 66 - if hasattr(node, 'value'): 67 - return str(node.value) 68 - return str(node)
+4 -53
osprey_worker/src/osprey/worker/ui_api/osprey/views/features.py
··· 13 13 BooleanOperation, 14 14 Call, 15 15 FormatString, 16 - Name, 17 16 UnaryOperation, 18 17 ) 19 - from osprey.engine.ast.grammar import ( 20 - List as AstList, 21 - ) 22 18 from osprey.worker.lib.singletons import ENGINE 23 19 from osprey.worker.ui_api.osprey.lib.abilities import CanViewDocs, require_ability 24 20 25 - from ._features_ast_utils import ast_to_string, get_func_identifier 21 + from ._engine_ast_utils import ast_to_string, collect_name_references, get_func_identifier 26 22 27 23 logger = logging.getLogger(__name__) 28 24 ··· 79 75 return parts[0] 80 76 81 77 82 - def _collect_name_references(node: Any, out: Set[str]) -> None: 83 - """Recursively collect all Name.identifier values referenced by an expression node. 84 - 85 - Skip the function-identifier position of Call nodes (we don't treat e.g. 86 - `JsonData` in `JsonData(...)` as a feature reference). Walk FormatString.names, 87 - BinaryOperation/BinaryComparison left/right, BooleanOperation values, 88 - UnaryOperation operand, AstList items, and Attribute chains. 89 - """ 90 - if node is None: 91 - return 92 - if isinstance(node, Name): 93 - out.add(node.identifier) 94 - return 95 - if isinstance(node, Call): 96 - for arg in node.arguments: 97 - _collect_name_references(arg.value, out) 98 - return 99 - if isinstance(node, FormatString): 100 - for n in node.names: 101 - out.add(n.identifier) 102 - return 103 - if isinstance(node, BinaryComparison): 104 - _collect_name_references(node.left, out) 105 - _collect_name_references(node.right, out) 106 - return 107 - if isinstance(node, BinaryOperation): 108 - _collect_name_references(node.left, out) 109 - _collect_name_references(node.right, out) 110 - return 111 - if isinstance(node, UnaryOperation): 112 - _collect_name_references(node.operand, out) 113 - return 114 - if isinstance(node, BooleanOperation): 115 - for v in node.values: 116 - _collect_name_references(v, out) 117 - return 118 - if isinstance(node, AstList): 119 - for item in node.items: 120 - _collect_name_references(item, out) 121 - return 122 - if isinstance(node, Attribute): 123 - _collect_name_references(node.name, out) 124 - return 125 - 126 - 127 78 def _find_assign_for_feature(sources: Any, feature_name: str, source_path: str, source_line: int) -> Optional[Assign]: 128 79 """Look up the Assign AST node for a feature in its source file. 129 80 ··· 219 170 refs: Set[str] = set() 220 171 when_all_arg = statement.value.find_argument('when_all') 221 172 if when_all_arg: 222 - _collect_name_references(when_all_arg.value, refs) 173 + collect_name_references(when_all_arg.value, refs) 223 174 for feat in refs & feature_names: 224 175 rule_refs.setdefault(feat, set()).add(rule_name) 225 176 continue ··· 239 190 for arg in call_node.arguments: 240 191 if arg.name == 'rules_any': 241 192 continue 242 - _collect_name_references(arg.value, refs) 193 + collect_name_references(arg.value, refs) 243 194 for feat in refs & feature_names: 244 195 whenrules_refs[feat] = whenrules_refs.get(feat, 0) + 1 245 196 continue ··· 250 201 if defining_name not in feature_names: 251 202 continue 252 203 refs = set() 253 - _collect_name_references(statement.value, refs) 204 + collect_name_references(statement.value, refs) 254 205 for feat in refs & feature_names: 255 206 if feat == defining_name: 256 207 continue
+136
osprey_worker/src/osprey/worker/ui_api/osprey/views/rules.py
··· 1 + import logging 2 + from typing import Any, Dict, List, Optional, Set 3 + 4 + from flask import Blueprint, jsonify 5 + from osprey.engine.ast.grammar import ( 6 + Assign, 7 + Call, 8 + FormatString, 9 + Name, 10 + String, 11 + ) 12 + from osprey.engine.ast.grammar import ( 13 + List as AstList, 14 + ) 15 + from osprey.worker.lib.singletons import ENGINE 16 + from osprey.worker.ui_api.osprey.lib.abilities import CanViewDocs, require_ability 17 + 18 + from ._engine_ast_utils import ast_to_string, collect_name_references, get_func_identifier 19 + 20 + logger = logging.getLogger(__name__) 21 + 22 + blueprint = Blueprint('rules', __name__) 23 + 24 + 25 + def _description_to_string(value: Any) -> str: 26 + """Render a Rule's description argument back to a string. 27 + 28 + SML lets description be either a String literal or a FormatString 29 + template. We return the raw template for FormatString — the registry 30 + is a static view, never substituted. 31 + """ 32 + if isinstance(value, String): 33 + return value.value 34 + if isinstance(value, FormatString): 35 + return value.format_string 36 + raise TypeError(f'BUG: Rule description was {value!r}, should have been caught by validator.') 37 + 38 + 39 + def _extract_rules_from_engine() -> tuple[List[Dict[str, Any]], int]: 40 + """Walk the engine once, collecting Rule defs and WhenRules → Rule reference counts. 41 + 42 + WhenRules can appear in a source iterated before the Rule they reference 43 + (e.g., main.sml's WhenRules referencing a Rule in an imported file), so 44 + we accumulate counts into a name-keyed map during the walk and backfill 45 + each Rule's referenced_by_whenrules at the end. 46 + """ 47 + engine = ENGINE.instance() 48 + sources = engine.execution_graph.validated_sources.sources 49 + 50 + whenrules_ref_count: Dict[str, int] = {} 51 + when_rules_total = 0 52 + rules: List[Dict[str, Any]] = [] 53 + 54 + for source in sources: 55 + for statement in source.ast_root.statements: 56 + # WhenRules(...) — bare statement or assigned 57 + call_node: Optional[Call] = None 58 + if isinstance(statement, Call) and get_func_identifier(statement) == 'WhenRules': 59 + call_node = statement 60 + elif ( 61 + isinstance(statement, Assign) 62 + and isinstance(statement.value, Call) 63 + and get_func_identifier(statement.value) == 'WhenRules' 64 + ): 65 + call_node = statement.value 66 + if call_node is not None: 67 + when_rules_total += 1 68 + rules_any_arg = call_node.find_argument('rules_any') 69 + if rules_any_arg is not None and isinstance(rules_any_arg.value, AstList): 70 + for item in rules_any_arg.value.items: 71 + if isinstance(item, Name): 72 + whenrules_ref_count[item.identifier] = whenrules_ref_count.get(item.identifier, 0) + 1 73 + continue 74 + 75 + # Rule(...) — must be an Assign 76 + if not ( 77 + isinstance(statement, Assign) 78 + and isinstance(statement.value, Call) 79 + and get_func_identifier(statement.value) == 'Rule' 80 + ): 81 + continue 82 + 83 + rule_name = statement.target.identifier 84 + call = statement.value 85 + 86 + when_all: List[str] = [] 87 + when_all_arg = call.find_argument('when_all') 88 + if when_all_arg is not None and isinstance(when_all_arg.value, AstList): 89 + for item in when_all_arg.value.items: 90 + when_all.append(ast_to_string(item)) 91 + elif when_all_arg is not None: 92 + when_all.append(ast_to_string(when_all_arg.value)) 93 + 94 + description = '' 95 + description_arg = call.find_argument('description') 96 + if description_arg is not None: 97 + description = _description_to_string(description_arg.value) 98 + 99 + refs: Set[str] = set() 100 + if when_all_arg is not None: 101 + collect_name_references(when_all_arg.value, refs) 102 + if description_arg is not None: 103 + collect_name_references(description_arg.value, refs) 104 + referenced_features = sorted(refs) 105 + 106 + rules.append( 107 + { 108 + 'name': rule_name, 109 + 'source_file': source.path, 110 + 'description': description, 111 + 'when_all': when_all, 112 + 'referenced_features': referenced_features, 113 + 'referenced_by_whenrules': 0, # backfilled below 114 + } 115 + ) 116 + 117 + for rule in rules: 118 + rule['referenced_by_whenrules'] = whenrules_ref_count.get(rule['name'], 0) 119 + 120 + return rules, when_rules_total 121 + 122 + 123 + @blueprint.route('/rules', methods=['GET']) 124 + @require_ability(CanViewDocs) 125 + def rules_list() -> Any: 126 + """Return the catalog of Rule(...) definitions across the rules engine.""" 127 + rules, when_rules_total = _extract_rules_from_engine() 128 + unused_total = sum(1 for r in rules if r['referenced_by_whenrules'] == 0) 129 + return jsonify( 130 + { 131 + 'rules': rules, 132 + 'total': len(rules), 133 + 'when_rules_total': when_rules_total, 134 + 'unused_total': unused_total, 135 + } 136 + )
+208
osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rules.py
··· 1 + import json 2 + 3 + import pytest 4 + from flask import Response, url_for 5 + from flask.testing import FlaskClient 6 + 7 + _base_sources_dict = { 8 + 'config.yaml': json.dumps( 9 + { 10 + 'ui_config': {}, 11 + 'labels': {}, 12 + 'acl': { 13 + 'users': { 14 + 'local-dev@localhost': {'abilities': [{'name': 'CAN_VIEW_DOCS', 'allow_all': True}]}, 15 + } 16 + }, 17 + } 18 + ) 19 + } 20 + 21 + _no_ability_sources_dict = {'config.yaml': json.dumps({'ui_config': {}, 'labels': {}})} 22 + 23 + 24 + @pytest.mark.use_rules_sources( 25 + { 26 + **_base_sources_dict, 27 + 'main.sml': '', 28 + } 29 + ) 30 + def test_empty_engine_returns_empty_catalog(client: 'FlaskClient[Response]') -> None: 31 + """Empty engine returns {rules: [], total: 0, when_rules_total: 0, unused_total: 0}.""" 32 + res = client.get(url_for('rules.rules_list')) 33 + assert res.status_code == 200 34 + assert res.json == {'rules': [], 'total': 0, 'when_rules_total': 0, 'unused_total': 0} 35 + 36 + 37 + @pytest.mark.use_rules_sources( 38 + { 39 + **_base_sources_dict, 40 + 'main.sml': """ 41 + UserId: str = JsonData(path='$.user_id') 42 + PostText: str = JsonData(path='$.post_text') 43 + ContainsHello = Rule( 44 + when_all=[PostText == 'hello'], 45 + description='Post contains hello', 46 + ) 47 + """, 48 + } 49 + ) 50 + def test_response_shape_and_basic_rule(client: 'FlaskClient[Response]') -> None: 51 + """Response shape and the 6 RuleInfo fields against a simple rule.""" 52 + res = client.get(url_for('rules.rules_list')) 53 + assert res.status_code == 200 54 + 55 + body = res.json 56 + assert set(body.keys()) == {'rules', 'total', 'when_rules_total', 'unused_total'} 57 + assert body['total'] == 1 58 + assert body['when_rules_total'] == 0 59 + assert body['unused_total'] == 1 60 + assert len(body['rules']) == 1 61 + 62 + rule = body['rules'][0] 63 + expected_fields = { 64 + 'name', 65 + 'source_file', 66 + 'description', 67 + 'when_all', 68 + 'referenced_features', 69 + 'referenced_by_whenrules', 70 + } 71 + assert set(rule.keys()) == expected_fields 72 + assert rule['name'] == 'ContainsHello' 73 + assert rule['source_file'] == 'main.sml' 74 + assert rule['description'] == 'Post contains hello' 75 + assert isinstance(rule['when_all'], list) and len(rule['when_all']) == 1 76 + assert 'PostText' in rule['when_all'][0] 77 + assert 'PostText' in rule['referenced_features'] 78 + assert rule['referenced_by_whenrules'] == 0 79 + 80 + 81 + @pytest.mark.use_rules_sources( 82 + { 83 + **_base_sources_dict, 84 + 'main.sml': """ 85 + UserId: str = JsonData(path='$.user_id') 86 + PostText: str = JsonData(path='$.post_text') 87 + FlaggedPhrase: str = JsonData(path='$.phrase') 88 + RuleWithFmt = Rule( 89 + when_all=[PostText == FlaggedPhrase], 90 + description=f'User {UserId} said {FlaggedPhrase}', 91 + ) 92 + """, 93 + } 94 + ) 95 + def test_referenced_features_from_when_all_and_format_description(client: 'FlaskClient[Response]') -> None: 96 + """referenced_features unions names from when_all expressions AND a FormatString description.""" 97 + res = client.get(url_for('rules.rules_list')) 98 + rule = next(r for r in res.json['rules'] if r['name'] == 'RuleWithFmt') 99 + 100 + # PostText and FlaggedPhrase are in when_all; UserId only appears in the description template. 101 + # All three should appear in referenced_features, sorted. 102 + assert rule['referenced_features'] == ['FlaggedPhrase', 'PostText', 'UserId'] 103 + # The description ships as the raw template, NOT substituted. 104 + assert rule['description'] == 'User {UserId} said {FlaggedPhrase}' 105 + 106 + 107 + @pytest.mark.use_rules_sources( 108 + { 109 + **_base_sources_dict, 110 + # main.sml is iterated FIRST (dict insertion order), and its WhenRules 111 + # references a Rule defined in an imported source that's iterated 112 + # SECOND. The two-sub-pass walk must still credit the reference. 113 + 'main.sml': """ 114 + Import(rules=['extra_rules.sml']) 115 + 116 + UserId: str = JsonData(path='$.user_id') 117 + 118 + WhenRules( 119 + rules_any=[ContainsHello], 120 + then=[DeclareVerdict(verdict=UserId)], 121 + ) 122 + """, 123 + 'extra_rules.sml': """ 124 + PostText: str = JsonData(path='$.post_text') 125 + 126 + ContainsHello = Rule( 127 + when_all=[PostText == 'hello'], 128 + description='back-referenced', 129 + ) 130 + """, 131 + } 132 + ) 133 + def test_whenrules_in_main_references_rule_in_imported_source(client: 'FlaskClient[Response]') -> None: 134 + """A WhenRules in main.sml referencing a Rule in an imported source still credits the reference. 135 + 136 + Sources iterate in dict-insertion order, so main.sml (containing the WhenRules) is walked 137 + before extra_rules.sml (containing the Rule). A single-pass walk would miss the reference 138 + because the Rule hasn't been seen yet when the WhenRules is processed. The two-sub-pass 139 + walk (pass 1 counts refs, pass 2 collects rules and attaches counts) handles this. 140 + """ 141 + res = client.get(url_for('rules.rules_list')) 142 + assert res.status_code == 200 143 + 144 + rule = next(r for r in res.json['rules'] if r['name'] == 'ContainsHello') 145 + assert rule['referenced_by_whenrules'] == 1 146 + assert res.json['when_rules_total'] == 1 147 + assert res.json['unused_total'] == 0 # The one rule is referenced 148 + 149 + 150 + @pytest.mark.use_rules_sources( 151 + { 152 + **_base_sources_dict, 153 + 'main.sml': """ 154 + UserId: str = JsonData(path='$.user_id') 155 + PostText: str = JsonData(path='$.post_text') 156 + 157 + ReferencedRule = Rule(when_all=[PostText == 'a'], description='ref') 158 + UnusedRule = Rule(when_all=[PostText == 'b'], description='unref') 159 + 160 + WhenRules( 161 + rules_any=[ReferencedRule], 162 + then=[DeclareVerdict(verdict=UserId)], 163 + ) 164 + """, 165 + } 166 + ) 167 + def test_unused_total_excludes_referenced_rules(client: 'FlaskClient[Response]') -> None: 168 + """unused_total counts only rules with referenced_by_whenrules == 0.""" 169 + res = client.get(url_for('rules.rules_list')) 170 + body = res.json 171 + 172 + assert body['total'] == 2 173 + assert body['when_rules_total'] == 1 174 + assert body['unused_total'] == 1 175 + 176 + by_name = {r['name']: r for r in body['rules']} 177 + assert by_name['ReferencedRule']['referenced_by_whenrules'] == 1 178 + assert by_name['UnusedRule']['referenced_by_whenrules'] == 0 179 + 180 + 181 + @pytest.mark.use_rules_sources( 182 + { 183 + **_base_sources_dict, 184 + 'main.sml': """ 185 + UserId: str = JsonData(path='$.user_id') 186 + R = Rule(when_all=[UserId == 'x'], description='r') 187 + """, 188 + } 189 + ) 190 + def test_dual_route_registration(client: 'FlaskClient[Response]') -> None: 191 + """GET reachable at both /rules and /api/rules, identical body.""" 192 + res_root = client.get('/rules') 193 + res_api = client.get('/api/rules') 194 + assert res_root.status_code == 200 195 + assert res_api.status_code == 200 196 + assert res_root.json == res_api.json 197 + 198 + 199 + @pytest.mark.use_rules_sources( 200 + { 201 + **_no_ability_sources_dict, 202 + 'main.sml': "UserId: str = JsonData(path='$.user_id')", 203 + } 204 + ) 205 + def test_endpoint_requires_can_view_docs(client: 'FlaskClient[Response]') -> None: 206 + """A user without CAN_VIEW_DOCS gets 401.""" 207 + res = client.get(url_for('rules.rules_list')) 208 + assert res.status_code == 401