Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bindings/nodejs/dts-header.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ export type PolicyDiagnosticCode =
| 'UNRESOLVED_FUNCTION_TYPE'
| 'IMPLICIT_ANY'
| 'UNCHECKED_NODE'
| 'NULLABILITY_DIVERGENCE'
| 'REDUNDANT_NULLISH'
| 'REPEATED_DERIVATION'
| 'PREFER_MATCH'
| 'PREFER_DICTIONARY'
| 'REDUNDANT_TABLE_ROW'
| 'NON_DISCRIMINATING_COLUMN'
| 'REDUNDANT_PARENTHESES';
Expand Down
2 changes: 2 additions & 0 deletions bindings/nodejs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ export type PolicyDiagnosticCode =
| 'UNRESOLVED_FUNCTION_TYPE'
| 'IMPLICIT_ANY'
| 'UNCHECKED_NODE'
| 'NULLABILITY_DIVERGENCE'
| 'REDUNDANT_NULLISH'
| 'REPEATED_DERIVATION'
| 'PREFER_MATCH'
| 'PREFER_DICTIONARY'
| 'REDUNDANT_TABLE_ROW'
| 'NON_DISCRIMINATING_COLUMN'
| 'REDUNDANT_PARENTHESES';
Expand Down
16 changes: 16 additions & 0 deletions core/engine/src/policy/blocks/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,22 @@ impl AnalysisContext {
.push(Diagnostic::error(code, location, message));
}

pub fn hint_with_target(
&mut self,
code: DiagnosticCode,
expression_id: Option<Arc<str>>,
span: Option<(u32, u32)>,
target: Option<CursorTarget>,
message: impl Into<String>,
) {
let mut location = self.location_with(expression_id, span);
if let Some(t) = target {
location = location.with_target(t);
}
self.diagnostics
.push(Diagnostic::hint(code, location, message));
}

fn location_with(
&self,
expression_id: Option<Arc<str>>,
Expand Down
131 changes: 131 additions & 0 deletions core/engine/src/policy/blocks/decision_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,45 @@ impl DecisionTableIr {
}
}

if cx.is_enriched() {
for col in &self.inputs {
let Some(field) = col.field.as_ref().filter(|f| !f.is_empty()) else {
continue;
};
let Some(field_type) = input_field_types.get(&col.id) else {
continue;
};
if !matches!(field_type.unwrap_nullable().0, VariableType::String) {
continue;
}
let mut tests: Vec<ArmTest> = Vec::new();
for rule in &self.rules {
let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else {
continue;
};
if cell.trim() == "_" {
continue;
}
tests.push(cx.cell_test(cell));
}
if let Some(values) = DictionaryCandidate::from_literal_tests(&tests) {
cx.hint_with_target(
DiagnosticCode::PreferDictionary,
Some(col.id.clone()),
None,
Some(CursorTarget::DecisionTableHead {
col: col.id.clone(),
}),
format!(
"conditions on '{}' only test the fixed strings {} — define a dictionary and type the field with it for membership checking and labeled editing",
field,
DictionaryCandidate::format_values(&values)
),
);
}
}
}

for col in &self.outputs {
if col.field.is_empty() {
continue;
Expand Down Expand Up @@ -548,6 +587,25 @@ impl DecisionTableIr {
}
}

if cx.is_enriched() {
if let Some(values) = DictionaryCandidate::from_const_cells(&cell_types) {
cx.hint_with_target(
DiagnosticCode::PreferDictionary,
Some(col.id.clone()),
None,
Some(CursorTarget::DecisionTableHead {
col: col.id.clone(),
}),
format!(
"output column '{}' only produces the fixed strings {} — define a dictionary with these values and type the column with it ('out {}: <dictionary>') for membership checking and labeled editing",
col.field,
DictionaryCandidate::format_values(&values),
col.field
),
);
}
}

let covered = col.collect
|| !cx.is_enriched()
|| self.column_covered(col, cx, &input_field_types);
Expand Down Expand Up @@ -1263,3 +1321,76 @@ impl DecisionTableIr {
}
}
}

pub(crate) struct DictionaryCandidate;

impl DictionaryCandidate {
const MAX_SHOWN: usize = 6;

pub(crate) fn from_const_cells(cell_types: &[VariableType]) -> Option<Vec<std::rc::Rc<str>>> {
if cell_types.len() < 2 {
return None;
}
let mut values: Vec<std::rc::Rc<str>> = Vec::new();
for cell in cell_types {
let VariableType::Const(value) = cell else {
return None;
};
if !values.iter().any(|seen| seen == value) {
values.push(value.clone());
}
}
(values.len() >= 2 && !Self::all_date_like(&values)).then_some(values)
}

pub(crate) fn from_literal_tests(tests: &[ArmTest]) -> Option<Vec<std::rc::Rc<str>>> {
let mut values: Vec<std::rc::Rc<str>> = Vec::new();
let mut literal_cells = 0usize;
for test in tests {
match test {
ArmTest::Enum {
values: cell_values,
..
} => {
literal_cells += 1;
for value in cell_values {
if !values.iter().any(|seen| seen == value) {
values.push(value.clone());
}
}
}
ArmTest::Default => {}
_ => return None,
}
}
(literal_cells >= 2 && values.len() >= 2 && !Self::all_date_like(&values)).then_some(values)
}

/// Literal-date value sets are calendars, not enums.
fn all_date_like(values: &[std::rc::Rc<str>]) -> bool {
let is_date_like = |value: &str| {
let bytes = value.as_bytes();
bytes.len() >= 10
&& bytes[..4].iter().all(u8::is_ascii_digit)
&& bytes[4] == b'-'
&& bytes[5..7].iter().all(u8::is_ascii_digit)
&& bytes[7] == b'-'
&& bytes[8..10].iter().all(u8::is_ascii_digit)
};
values.iter().all(|value| is_date_like(value))
}

pub(crate) fn format_values(values: &[std::rc::Rc<str>]) -> String {
let shown = values
.iter()
.take(Self::MAX_SHOWN)
.map(|value| format!("\"{value}\""))
.collect::<Vec<_>>()
.join(" | ");
if values.len() > Self::MAX_SHOWN {
format!("{shown} | …")
} else {
shown
}
}
}
2 changes: 1 addition & 1 deletion core/engine/src/policy/blocks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ pub use context::{
AnalysisContext, AnalysisSummary, ExecutionContext, ExecutionError, ExpressionLocation,
InstanceSource, PropertyRead, SharedDictionaryTypes, SharedIntelliSense, WriteTarget,
};
pub(crate) use decision_table::TableSelection;
pub use decision_table::{DecisionTableDoc, DecisionTableIr, DeclaredType};
pub(crate) use decision_table::{DictionaryCandidate, TableSelection};
pub use expression::{ExpressionDoc, ExpressionIr};
pub(crate) use match_block::MatchSelection;
pub use match_block::{MatchDoc, MatchIr};
Expand Down
Loading
Loading