diff --git a/CHANGELOG.md b/CHANGELOG.md index a756711..fb8200f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `ParseInt` UDF — converts a numeric string to an integer ([#190](https://github.com/roostorg/osprey/pull/190) by [@bealsbe](https://github.com/bealsbe)) - Add `StringSlice` UDF which extracts a substring by index range ([#189](https://github.com/roostorg/osprey/pull/189) by [@bealsbe](https://github.com/bealsbe)) - 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)) +- Add Rules Registry page at `/rules` ([#277](https://github.com/roostorg/osprey/pull/277) by [@haileyok](https://github.com/haileyok)) ### 🐛 Bug fixes - Default to selecting all for event stream ([#194](https://github.com/roostorg/osprey/pull/194) by [@chimosky](https://github.com/chimosky)) diff --git a/osprey_ui/src/App.tsx b/osprey_ui/src/App.tsx index efc7bfe..0b52a3a 100644 --- a/osprey_ui/src/App.tsx +++ b/osprey_ui/src/App.tsx @@ -6,6 +6,7 @@ import { getApplicationConfig } from './actions/ConfigActions'; import UdfDocsView from './components/docs/UdfDocsView'; import BulkJobHistoryView from './components/bulk_job_history/BulkJobHistory'; import { FeaturesPage } from './components/features/FeaturesPage'; +import { RulesPage } from './components/rules/RulesPage'; import RulesVisualizerView from './components/rules_visualizer/RulesVisualizer'; import EntityViewBar from './components/entities/EntityViewBar'; import EventPage from './components/event_stream/EventPage'; @@ -104,6 +105,9 @@ const AppRouter: React.FC = () => { + + + diff --git a/osprey_ui/src/Constants.tsx b/osprey_ui/src/Constants.tsx index dc697b7..f338a58 100644 --- a/osprey_ui/src/Constants.tsx +++ b/osprey_ui/src/Constants.tsx @@ -5,6 +5,7 @@ export const Routes = { DOCS_UDFS: '/docs/udfs', ENTITY: '/entity/:entityType/:entityId', FEATURES: '/features', + RULES: '/rules', SAVED_QUERY: '/saved-query/:savedQueryId', SAVED_QUERY_LATEST: '/saved-query/:savedQueryId/latest', BULK_JOB_HISTORY: '/bulk-job-history', diff --git a/osprey_ui/src/actions/RulesActions.tsx b/osprey_ui/src/actions/RulesActions.tsx new file mode 100644 index 0000000..ad5e63b --- /dev/null +++ b/osprey_ui/src/actions/RulesActions.tsx @@ -0,0 +1,10 @@ +import HTTPUtils, { HTTPResponse } from '../utils/HTTPUtils'; +import { RulesListResponse } from '../types/RulesTypes'; + +export async function getRulesList(): Promise { + const response: HTTPResponse = await HTTPUtils.get('rules'); + if (response.ok) { + return response.data; + } + throw new Error(response.error.message ?? 'Failed to fetch rules list'); +} diff --git a/osprey_ui/src/components/navigation/NavBar.tsx b/osprey_ui/src/components/navigation/NavBar.tsx index 33ec314..a8fe00a 100644 --- a/osprey_ui/src/components/navigation/NavBar.tsx +++ b/osprey_ui/src/components/navigation/NavBar.tsx @@ -11,6 +11,7 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, DatabaseOutlined, + FileTextOutlined, } from '@ant-design/icons'; import { Link, useLocation } from 'react-router-dom'; @@ -52,6 +53,7 @@ const NAV_GROUPS: NavGroup[] = [ { key: Routes.RULES_VISUALIZER, icon: , label: 'Rules Visualizer' }, { key: Routes.DOCS_UDFS, icon: , label: 'UDF Registry' }, { key: Routes.FEATURES, icon: , label: 'Features' }, + { key: Routes.RULES, icon: , label: 'Rules' }, ], }, { diff --git a/osprey_ui/src/components/rules/RulesPage.module.css b/osprey_ui/src/components/rules/RulesPage.module.css new file mode 100644 index 0000000..62d4ca2 --- /dev/null +++ b/osprey_ui/src/components/rules/RulesPage.module.css @@ -0,0 +1,66 @@ +.viewContainer { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.scrollArea { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.statsRow { + display: flex; + gap: 16px; + margin-bottom: 24px; +} + +.statsRow > * { + flex: 1; +} + +.statCardClickable { + cursor: pointer; +} + +.statCardActive { + border-color: var(--brand-primary); +} + +.ruleName { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-weight: 600; +} + +.ruleSource { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + color: var(--text-light-secondary); + font-size: 12px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.headerRow { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.conditionBlock { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + padding: 8px 10px; + background: var(--background-secondary); + border: 1px solid var(--divider); + border-radius: 4px; + white-space: pre-wrap; + overflow-wrap: break-word; + margin: 0; +} diff --git a/osprey_ui/src/components/rules/RulesPage.tsx b/osprey_ui/src/components/rules/RulesPage.tsx new file mode 100644 index 0000000..580abad --- /dev/null +++ b/osprey_ui/src/components/rules/RulesPage.tsx @@ -0,0 +1,315 @@ +import * as React from 'react'; +import { + Card, + Collapse, + Descriptions, + Empty, + Input, + Pagination, + Select, + Space, + Statistic, + Switch, + Tag, + Tooltip, + Typography, +} from 'antd'; +import { SearchOutlined } from '@ant-design/icons'; + +import { getRulesList } from '../../actions/RulesActions'; +import usePromiseResult from '../../hooks/usePromiseResult'; +import { RuleInfo, RulesListResponse, SortKey } from '../../types/RulesTypes'; +import { renderFromPromiseResult } from '../../utils/PromiseResultUtils'; + +import styles from './RulesPage.module.css'; + +const { Title, Paragraph, Text } = Typography; + +type FiltersState = { + search: string; + unusedOnly: boolean; + sortKey: SortKey; + page: number; + pageSize: number; +}; + +type FiltersAction = + | { type: 'setSearch'; value: string } + | { type: 'setUnusedOnly'; value: boolean } + | { type: 'toggleUnusedOnly' } + | { type: 'setSortKey'; value: SortKey } + | { type: 'setPage'; page: number; pageSize: number }; + +const INITIAL_FILTERS: FiltersState = { + search: '', + unusedOnly: false, + sortKey: SortKey.MostReferenced, + page: 1, + pageSize: 50, +}; + +// Every filter action resets page to 1; only setPage preserves it. +function filtersReducer(state: FiltersState, action: FiltersAction): FiltersState { + switch (action.type) { + case 'setSearch': { + return { ...state, search: action.value, page: 1 }; + } + case 'setUnusedOnly': { + return { ...state, unusedOnly: action.value, page: 1 }; + } + case 'toggleUnusedOnly': { + return { ...state, unusedOnly: !state.unusedOnly, page: 1 }; + } + case 'setSortKey': { + return { ...state, sortKey: action.value, page: 1 }; + } + case 'setPage': { + return { ...state, page: action.page, pageSize: action.pageSize }; + } + } +} + +export const RulesPage: React.FC = () => { + const result = usePromiseResult(() => { + return getRulesList(); + }); + + return renderFromPromiseResult(result, (data) => { + return ; + }); +}; + +const RulesPageContent: React.FC<{ data: RulesListResponse }> = ({ data }) => { + const [filters, dispatch] = React.useReducer(filtersReducer, INITIAL_FILTERS); + const { rules, total, when_rules_total, unused_total } = data; + const { search, unusedOnly, sortKey, page, pageSize } = filters; + + const filtered = React.useMemo(() => { + const query = search.trim().toLowerCase(); + const list = rules.filter((r) => { + if ( + query && + !r.name.toLowerCase().includes(query) && + !r.source_file.toLowerCase().includes(query) && + !r.description.toLowerCase().includes(query) + ) { + return false; + } + if (unusedOnly && r.referenced_by_whenrules !== 0) { + return false; + } + return true; + }); + if (sortKey === SortKey.Name) { + return [...list].sort((a, b) => { + return a.name.localeCompare(b.name); + }); + } + if (sortKey === SortKey.MostReferenced) { + return [...list].sort((a, b) => { + return b.referenced_by_whenrules - a.referenced_by_whenrules || a.name.localeCompare(b.name); + }); + } + return [...list].sort((a, b) => { + return a.referenced_by_whenrules - b.referenced_by_whenrules || a.name.localeCompare(b.name); + }); + }, [rules, search, unusedOnly, sortKey]); + + const paginated = React.useMemo(() => { + return filtered.slice((page - 1) * pageSize, page * pageSize); + }, [filtered, page, pageSize]); + + const collapseItems = React.useMemo(() => { + return paginated.map((r) => { + return { + key: r.name, + label: , + children: , + }; + }); + }, [paginated]); + + return ( +
+
+ + Rules Registry + + + Named rule definitions across the engine — conditions, descriptions, the features each rule references, and + how many WhenRules blocks include it. + + +
+ + + + + + + + + + { + dispatch({ type: 'toggleUnusedOnly' }); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + dispatch({ type: 'toggleUnusedOnly' }); + } + }} + > + + + +
+ + + } + placeholder="Search rules..." + value={search} + onChange={(e) => { + dispatch({ type: 'setSearch', value: e.target.value }); + }} + allowClear + style={{ width: 280 }} + /> + + size="small" + value={sortKey} + onChange={(value) => { + dispatch({ type: 'setSortKey', value }); + }} + style={{ width: 170 }} + options={[ + { value: SortKey.MostReferenced, label: 'Most referenced' }, + { value: SortKey.LeastReferenced, label: 'Least referenced' }, + { value: SortKey.Name, label: 'Name (A-Z)' }, + ]} + /> + + { + dispatch({ type: 'setUnusedOnly', value }); + }} + /> + Unused only + + + + + Rules ({filtered.length}) + + {filtered.length === 0 ? ( + + ) : ( + <> + + { + dispatch({ type: 'setPage', page, pageSize }); + }} + showSizeChanger + pageSizeOptions={['25', '50', '100', '200']} + showTotal={(total, [start, end]) => { + return `${start}–${end} of ${total}`; + }} + size="small" + align="center" + style={{ marginTop: 20 }} + /> + + )} +
+
+ ); +}; + +const RuleHeader: React.FC<{ rule: RuleInfo }> = ({ rule }) => { + const isUnused = rule.referenced_by_whenrules === 0; + return ( +
+ {rule.name} + {isUnused && ( + + unused + + )} + {rule.source_file} + + {rule.referenced_by_whenrules > 0 && ( + + {rule.referenced_by_whenrules} when-rules + + )} + +
+ ); +}; + +const RuleDetail: React.FC<{ rule: RuleInfo }> = ({ rule }) => { + return ( + + + {rule.source_file} + + + {rule.description ? {rule.description} : —} + + + {rule.when_all.length === 0 ? ( + — + ) : ( + + {rule.when_all.map((cond, i) => { + return ( +
+                  {cond}
+                
+ ); + })} +
+ )} +
+ + {rule.referenced_features.length === 0 ? ( + — + ) : ( + + {rule.referenced_features.map((name) => { + return {name}; + })} + + )} + + + {`${rule.referenced_by_whenrules} block${rule.referenced_by_whenrules === 1 ? '' : 's'}`} + +
+ ); +}; diff --git a/osprey_ui/src/types/RulesTypes.tsx b/osprey_ui/src/types/RulesTypes.tsx new file mode 100644 index 0000000..a0d78be --- /dev/null +++ b/osprey_ui/src/types/RulesTypes.tsx @@ -0,0 +1,21 @@ +export interface RuleInfo { + name: string; + source_file: string; + description: string; + when_all: string[]; + referenced_features: string[]; + referenced_by_whenrules: number; +} + +export interface RulesListResponse { + rules: RuleInfo[]; + total: number; + when_rules_total: number; + unused_total: number; +} + +export enum SortKey { + Name = 'name', + MostReferenced = 'most-referenced', + LeastReferenced = 'least-referenced', +} diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/app.py b/osprey_worker/src/osprey/worker/ui_api/osprey/app.py index 70642b8..2586df2 100644 --- a/osprey_worker/src/osprey/worker/ui_api/osprey/app.py +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/app.py @@ -68,6 +68,7 @@ def create_app() -> Flask: events, features, queries, + rules, rules_visualizer, saved_queries, ) @@ -109,6 +110,7 @@ def create_app() -> Flask: _register_with_prefix(app, entities.blueprint) _register_with_prefix(app, events.blueprint) _register_with_prefix(app, features.blueprint) + _register_with_prefix(app, rules.blueprint) _register_with_prefix(app, queries.blueprint) _register_with_prefix(app, config.blueprint) _register_with_prefix(app, docs.blueprint) diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/_features_ast_utils.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_engine_ast_utils.py similarity index 55% rename from osprey_worker/src/osprey/worker/ui_api/osprey/views/_features_ast_utils.py rename to osprey_worker/src/osprey/worker/ui_api/osprey/views/_engine_ast_utils.py index c57440d..2b1afa2 100644 --- a/osprey_worker/src/osprey/worker/ui_api/osprey/views/_features_ast_utils.py +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_engine_ast_utils.py @@ -1,6 +1,6 @@ -"""Shared AST traversal helpers used by features.py.""" +"""Shared engine-AST traversal helpers used by views (features.py, rules.py, ...).""" -from typing import Any, Optional +from typing import Any, Optional, Set from osprey.engine.ast.grammar import ( Attribute, @@ -66,3 +66,48 @@ def ast_to_string(node: Any) -> str: if hasattr(node, 'value'): return str(node.value) return str(node) + + +def collect_name_references(node: Any, out: Set[str]) -> None: + """Recursively collect all Name.identifier values referenced by an expression node. + + Skip the function-identifier position of Call nodes (we don't treat e.g. + `JsonData` in `JsonData(...)` as a feature reference). Walk FormatString.names, + BinaryOperation/BinaryComparison left/right, BooleanOperation values, + UnaryOperation operand, AstList items, and Attribute chains. + """ + if node is None: + return + if isinstance(node, Name): + out.add(node.identifier) + return + if isinstance(node, Call): + for arg in node.arguments: + collect_name_references(arg.value, out) + return + if isinstance(node, FormatString): + for n in node.names: + out.add(n.identifier) + return + if isinstance(node, BinaryComparison): + collect_name_references(node.left, out) + collect_name_references(node.right, out) + return + if isinstance(node, BinaryOperation): + collect_name_references(node.left, out) + collect_name_references(node.right, out) + return + if isinstance(node, UnaryOperation): + collect_name_references(node.operand, out) + return + if isinstance(node, BooleanOperation): + for v in node.values: + collect_name_references(v, out) + return + if isinstance(node, AstList): + for item in node.items: + collect_name_references(item, out) + return + if isinstance(node, Attribute): + collect_name_references(node.name, out) + return diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/features.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/features.py index 87289d8..e306052 100644 --- a/osprey_worker/src/osprey/worker/ui_api/osprey/views/features.py +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/features.py @@ -13,16 +13,12 @@ from osprey.engine.ast.grammar import ( BooleanOperation, Call, FormatString, - Name, UnaryOperation, ) -from osprey.engine.ast.grammar import ( - List as AstList, -) from osprey.worker.lib.singletons import ENGINE from osprey.worker.ui_api.osprey.lib.abilities import CanViewDocs, require_ability -from ._features_ast_utils import ast_to_string, get_func_identifier +from ._engine_ast_utils import ast_to_string, collect_name_references, get_func_identifier logger = logging.getLogger(__name__) @@ -79,51 +75,6 @@ def _derive_category(file_path: str) -> str: return parts[0] -def _collect_name_references(node: Any, out: Set[str]) -> None: - """Recursively collect all Name.identifier values referenced by an expression node. - - Skip the function-identifier position of Call nodes (we don't treat e.g. - `JsonData` in `JsonData(...)` as a feature reference). Walk FormatString.names, - BinaryOperation/BinaryComparison left/right, BooleanOperation values, - UnaryOperation operand, AstList items, and Attribute chains. - """ - if node is None: - return - if isinstance(node, Name): - out.add(node.identifier) - return - if isinstance(node, Call): - for arg in node.arguments: - _collect_name_references(arg.value, out) - return - if isinstance(node, FormatString): - for n in node.names: - out.add(n.identifier) - return - if isinstance(node, BinaryComparison): - _collect_name_references(node.left, out) - _collect_name_references(node.right, out) - return - if isinstance(node, BinaryOperation): - _collect_name_references(node.left, out) - _collect_name_references(node.right, out) - return - if isinstance(node, UnaryOperation): - _collect_name_references(node.operand, out) - return - if isinstance(node, BooleanOperation): - for v in node.values: - _collect_name_references(v, out) - return - if isinstance(node, AstList): - for item in node.items: - _collect_name_references(item, out) - return - if isinstance(node, Attribute): - _collect_name_references(node.name, out) - return - - def _find_assign_for_feature(sources: Any, feature_name: str, source_path: str, source_line: int) -> Optional[Assign]: """Look up the Assign AST node for a feature in its source file. @@ -219,7 +170,7 @@ def _extract_features_from_engine() -> List[Dict[str, Any]]: refs: Set[str] = set() when_all_arg = statement.value.find_argument('when_all') if when_all_arg: - _collect_name_references(when_all_arg.value, refs) + collect_name_references(when_all_arg.value, refs) for feat in refs & feature_names: rule_refs.setdefault(feat, set()).add(rule_name) continue @@ -239,7 +190,7 @@ def _extract_features_from_engine() -> List[Dict[str, Any]]: for arg in call_node.arguments: if arg.name == 'rules_any': continue - _collect_name_references(arg.value, refs) + collect_name_references(arg.value, refs) for feat in refs & feature_names: whenrules_refs[feat] = whenrules_refs.get(feat, 0) + 1 continue @@ -250,7 +201,7 @@ def _extract_features_from_engine() -> List[Dict[str, Any]]: if defining_name not in feature_names: continue refs = set() - _collect_name_references(statement.value, refs) + collect_name_references(statement.value, refs) for feat in refs & feature_names: if feat == defining_name: continue diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/rules.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/rules.py new file mode 100644 index 0000000..5342ba7 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/rules.py @@ -0,0 +1,136 @@ +import logging +from typing import Any, Dict, List, Optional, Set + +from flask import Blueprint, jsonify +from osprey.engine.ast.grammar import ( + Assign, + Call, + FormatString, + Name, + String, +) +from osprey.engine.ast.grammar import ( + List as AstList, +) +from osprey.worker.lib.singletons import ENGINE +from osprey.worker.ui_api.osprey.lib.abilities import CanViewDocs, require_ability + +from ._engine_ast_utils import ast_to_string, collect_name_references, get_func_identifier + +logger = logging.getLogger(__name__) + +blueprint = Blueprint('rules', __name__) + + +def _description_to_string(value: Any) -> str: + """Render a Rule's description argument back to a string. + + SML lets description be either a String literal or a FormatString + template. We return the raw template for FormatString — the registry + is a static view, never substituted. + """ + if isinstance(value, String): + return value.value + if isinstance(value, FormatString): + return value.format_string + raise TypeError(f'BUG: Rule description was {value!r}, should have been caught by validator.') + + +def _extract_rules_from_engine() -> tuple[List[Dict[str, Any]], int]: + """Walk the engine once, collecting Rule defs and WhenRules → Rule reference counts. + + WhenRules can appear in a source iterated before the Rule they reference + (e.g., main.sml's WhenRules referencing a Rule in an imported file), so + we accumulate counts into a name-keyed map during the walk and backfill + each Rule's referenced_by_whenrules at the end. + """ + engine = ENGINE.instance() + sources = engine.execution_graph.validated_sources.sources + + whenrules_ref_count: Dict[str, int] = {} + when_rules_total = 0 + rules: List[Dict[str, Any]] = [] + + for source in sources: + for statement in source.ast_root.statements: + # WhenRules(...) — bare statement or assigned + call_node: Optional[Call] = None + if isinstance(statement, Call) and get_func_identifier(statement) == 'WhenRules': + call_node = statement + elif ( + isinstance(statement, Assign) + and isinstance(statement.value, Call) + and get_func_identifier(statement.value) == 'WhenRules' + ): + call_node = statement.value + if call_node is not None: + when_rules_total += 1 + rules_any_arg = call_node.find_argument('rules_any') + if rules_any_arg is not None and isinstance(rules_any_arg.value, AstList): + for item in rules_any_arg.value.items: + if isinstance(item, Name): + whenrules_ref_count[item.identifier] = whenrules_ref_count.get(item.identifier, 0) + 1 + continue + + # Rule(...) — must be an Assign + if not ( + isinstance(statement, Assign) + and isinstance(statement.value, Call) + and get_func_identifier(statement.value) == 'Rule' + ): + continue + + rule_name = statement.target.identifier + call = statement.value + + when_all: List[str] = [] + when_all_arg = call.find_argument('when_all') + if when_all_arg is not None and isinstance(when_all_arg.value, AstList): + for item in when_all_arg.value.items: + when_all.append(ast_to_string(item)) + elif when_all_arg is not None: + when_all.append(ast_to_string(when_all_arg.value)) + + description = '' + description_arg = call.find_argument('description') + if description_arg is not None: + description = _description_to_string(description_arg.value) + + refs: Set[str] = set() + if when_all_arg is not None: + collect_name_references(when_all_arg.value, refs) + if description_arg is not None: + collect_name_references(description_arg.value, refs) + referenced_features = sorted(refs) + + rules.append( + { + 'name': rule_name, + 'source_file': source.path, + 'description': description, + 'when_all': when_all, + 'referenced_features': referenced_features, + 'referenced_by_whenrules': 0, # backfilled below + } + ) + + for rule in rules: + rule['referenced_by_whenrules'] = whenrules_ref_count.get(rule['name'], 0) + + return rules, when_rules_total + + +@blueprint.route('/rules', methods=['GET']) +@require_ability(CanViewDocs) +def rules_list() -> Any: + """Return the catalog of Rule(...) definitions across the rules engine.""" + rules, when_rules_total = _extract_rules_from_engine() + unused_total = sum(1 for r in rules if r['referenced_by_whenrules'] == 0) + return jsonify( + { + 'rules': rules, + 'total': len(rules), + 'when_rules_total': when_rules_total, + 'unused_total': unused_total, + } + ) diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rules.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rules.py new file mode 100644 index 0000000..882de71 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rules.py @@ -0,0 +1,208 @@ +import json + +import pytest +from flask import Response, url_for +from flask.testing import FlaskClient + +_base_sources_dict = { + 'config.yaml': json.dumps( + { + 'ui_config': {}, + 'labels': {}, + 'acl': { + 'users': { + 'local-dev@localhost': {'abilities': [{'name': 'CAN_VIEW_DOCS', 'allow_all': True}]}, + } + }, + } + ) +} + +_no_ability_sources_dict = {'config.yaml': json.dumps({'ui_config': {}, 'labels': {}})} + + +@pytest.mark.use_rules_sources( + { + **_base_sources_dict, + 'main.sml': '', + } +) +def test_empty_engine_returns_empty_catalog(client: 'FlaskClient[Response]') -> None: + """Empty engine returns {rules: [], total: 0, when_rules_total: 0, unused_total: 0}.""" + res = client.get(url_for('rules.rules_list')) + assert res.status_code == 200 + assert res.json == {'rules': [], 'total': 0, 'when_rules_total': 0, 'unused_total': 0} + + +@pytest.mark.use_rules_sources( + { + **_base_sources_dict, + 'main.sml': """ + UserId: str = JsonData(path='$.user_id') + PostText: str = JsonData(path='$.post_text') + ContainsHello = Rule( + when_all=[PostText == 'hello'], + description='Post contains hello', + ) + """, + } +) +def test_response_shape_and_basic_rule(client: 'FlaskClient[Response]') -> None: + """Response shape and the 6 RuleInfo fields against a simple rule.""" + res = client.get(url_for('rules.rules_list')) + assert res.status_code == 200 + + body = res.json + assert set(body.keys()) == {'rules', 'total', 'when_rules_total', 'unused_total'} + assert body['total'] == 1 + assert body['when_rules_total'] == 0 + assert body['unused_total'] == 1 + assert len(body['rules']) == 1 + + rule = body['rules'][0] + expected_fields = { + 'name', + 'source_file', + 'description', + 'when_all', + 'referenced_features', + 'referenced_by_whenrules', + } + assert set(rule.keys()) == expected_fields + assert rule['name'] == 'ContainsHello' + assert rule['source_file'] == 'main.sml' + assert rule['description'] == 'Post contains hello' + assert isinstance(rule['when_all'], list) and len(rule['when_all']) == 1 + assert 'PostText' in rule['when_all'][0] + assert 'PostText' in rule['referenced_features'] + assert rule['referenced_by_whenrules'] == 0 + + +@pytest.mark.use_rules_sources( + { + **_base_sources_dict, + 'main.sml': """ + UserId: str = JsonData(path='$.user_id') + PostText: str = JsonData(path='$.post_text') + FlaggedPhrase: str = JsonData(path='$.phrase') + RuleWithFmt = Rule( + when_all=[PostText == FlaggedPhrase], + description=f'User {UserId} said {FlaggedPhrase}', + ) + """, + } +) +def test_referenced_features_from_when_all_and_format_description(client: 'FlaskClient[Response]') -> None: + """referenced_features unions names from when_all expressions AND a FormatString description.""" + res = client.get(url_for('rules.rules_list')) + rule = next(r for r in res.json['rules'] if r['name'] == 'RuleWithFmt') + + # PostText and FlaggedPhrase are in when_all; UserId only appears in the description template. + # All three should appear in referenced_features, sorted. + assert rule['referenced_features'] == ['FlaggedPhrase', 'PostText', 'UserId'] + # The description ships as the raw template, NOT substituted. + assert rule['description'] == 'User {UserId} said {FlaggedPhrase}' + + +@pytest.mark.use_rules_sources( + { + **_base_sources_dict, + # main.sml is iterated FIRST (dict insertion order), and its WhenRules + # references a Rule defined in an imported source that's iterated + # SECOND. The two-sub-pass walk must still credit the reference. + 'main.sml': """ + Import(rules=['extra_rules.sml']) + + UserId: str = JsonData(path='$.user_id') + + WhenRules( + rules_any=[ContainsHello], + then=[DeclareVerdict(verdict=UserId)], + ) + """, + 'extra_rules.sml': """ + PostText: str = JsonData(path='$.post_text') + + ContainsHello = Rule( + when_all=[PostText == 'hello'], + description='back-referenced', + ) + """, + } +) +def test_whenrules_in_main_references_rule_in_imported_source(client: 'FlaskClient[Response]') -> None: + """A WhenRules in main.sml referencing a Rule in an imported source still credits the reference. + + Sources iterate in dict-insertion order, so main.sml (containing the WhenRules) is walked + before extra_rules.sml (containing the Rule). A single-pass walk would miss the reference + because the Rule hasn't been seen yet when the WhenRules is processed. The two-sub-pass + walk (pass 1 counts refs, pass 2 collects rules and attaches counts) handles this. + """ + res = client.get(url_for('rules.rules_list')) + assert res.status_code == 200 + + rule = next(r for r in res.json['rules'] if r['name'] == 'ContainsHello') + assert rule['referenced_by_whenrules'] == 1 + assert res.json['when_rules_total'] == 1 + assert res.json['unused_total'] == 0 # The one rule is referenced + + +@pytest.mark.use_rules_sources( + { + **_base_sources_dict, + 'main.sml': """ + UserId: str = JsonData(path='$.user_id') + PostText: str = JsonData(path='$.post_text') + + ReferencedRule = Rule(when_all=[PostText == 'a'], description='ref') + UnusedRule = Rule(when_all=[PostText == 'b'], description='unref') + + WhenRules( + rules_any=[ReferencedRule], + then=[DeclareVerdict(verdict=UserId)], + ) + """, + } +) +def test_unused_total_excludes_referenced_rules(client: 'FlaskClient[Response]') -> None: + """unused_total counts only rules with referenced_by_whenrules == 0.""" + res = client.get(url_for('rules.rules_list')) + body = res.json + + assert body['total'] == 2 + assert body['when_rules_total'] == 1 + assert body['unused_total'] == 1 + + by_name = {r['name']: r for r in body['rules']} + assert by_name['ReferencedRule']['referenced_by_whenrules'] == 1 + assert by_name['UnusedRule']['referenced_by_whenrules'] == 0 + + +@pytest.mark.use_rules_sources( + { + **_base_sources_dict, + 'main.sml': """ + UserId: str = JsonData(path='$.user_id') + R = Rule(when_all=[UserId == 'x'], description='r') + """, + } +) +def test_dual_route_registration(client: 'FlaskClient[Response]') -> None: + """GET reachable at both /rules and /api/rules, identical body.""" + res_root = client.get('/rules') + res_api = client.get('/api/rules') + assert res_root.status_code == 200 + assert res_api.status_code == 200 + assert res_root.json == res_api.json + + +@pytest.mark.use_rules_sources( + { + **_no_ability_sources_dict, + 'main.sml': "UserId: str = JsonData(path='$.user_id')", + } +) +def test_endpoint_requires_can_view_docs(client: 'FlaskClient[Response]') -> None: + """A user without CAN_VIEW_DOCS gets 401.""" + res = client.get(url_for('rules.rules_list')) + assert res.status_code == 401