Repository: Automattic/harper (Rust)

Issue:
The annotation `6` for swears/curses does not propagate
**Describe the bug**

In `dictionary.dict` the annotation flag `6` indicates bad words.
Other flags are for affixes for derivation and inflection, such as `S`, `D`, `G` for -(e)s, -ed, -ing.
But when using `6` along with those flags, the 'bad word' property does not propagate.

**To Reproduce**
Steps to reproduce the behavior:
1. Type your favourite bad word in the uninflected dictionary form: `fart`
2. Notice it's flagged with "Try to avoid offensive language".
3. Now type inflected forms: `farts`, `farted`, `farting`.
4. Notice none of these are flagged.

**Expected behavior**
Inflected forms of offensive words should be just as offensive as the singular noun form or present indicative form.

**Screenshots**

<img width="362" alt="Image" src="https://github.com/user-attachments/assets/5ec52817-3ecd-4c30-8aaf-5ff78d41c36e" />

**Platform**
Windsurf (VS Code form)



Discussion: I have a feeling this will be true for all "properties". That's my term for all the flags in the affix annotations that don't indicate either a prefix or a suffix. Currently all "properties" have the `suffix` field set to `true` even though they don't have suffixes. I [filed a PR a couple of days ago](https://github.com/Automattic/harper/pull/972) to clarify some of this. It does not touch on property propagation though.



Fix (patch):
diff --git a/harper-core/affixes.json b/harper-core/affixes.json
index 50f4e0ce8..a7c9db4ec 100644
--- a/harper-core/affixes.json
+++ b/harper-core/affixes.json
@@ -553,14 +553,12 @@
 			],
 			"target": [],
 			"base_metadata": {}
-		},
+		}
+	},
+	"properties": {
 		"N": {
 			"#": "(singular) noun property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"noun": {
 					"is_plural": false
 				}
@@ -568,11 +566,7 @@
 		},
 		"O": {
 			"#": "proper noun property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"noun": {
 					"is_proper": true
 				}
@@ -581,11 +575,7 @@
 		"l": {
 			"#": "linking verb property",
 			"//": "see also m: auxiliary verb property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"verb": {
 					"is_linking": true
 				}
@@ -593,67 +583,38 @@
 		},
 		"V": {
 			"#": "verb property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"verb": {}
 			}
 		},
 		"J": {
 			"#": "adjective property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"adjective": {}
 			}
 		},
 		"x": {
 			"#": "swear word property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [
-				{
-					"metadata": {
-						"swear": true
-					}
-				}
-			],
-			"base_metadata": {
+			"propagate": true,
+			"metadata": {
 				"swear": true
 			}
 		},
 		"C": {
 			"#": "conjunction property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"conjunction": {}
 			}
 		},
 		"I": {
 			"#": "pronoun property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"pronoun": {}
 			}
 		},
 		"9": {
 			"#": "plural noun property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"noun": {
 					"is_plural": true
 				}
@@ -661,97 +622,52 @@
 		},
 		"~": {
 			"#": "common word property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"common": true
 			}
 		},
 		"P": {
 			"#": "preposition property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"preposition": true
 			}
 		},
 		"D": {
 			"#": "determiner property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"determiner": true
 			}
 		},
 		"R": {
 			"#": "adverb property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"adverb": {}
 			}
 		},
 		"s": {
 			"#": "subject case property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"pronoun": {}
 			}
 		},
 		"o": {
 			"#": "object case property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"pronoun": {}
 			}
 		},
-		"1": {
-			"#": "first-person singular property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+		"f": {
+			"#": "first-person property",
+			"metadata": {
 				"pronoun": {
 					"is_plural": false,
 					"person": "First"
 				}
 			}
 		},
-		"w": {
-			"#": "first-person plural property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
-				"pronoun": {
-					"is_plural": true,
-					"person": "First"
-				}
-			}
-		},
 		"2": {
 			"#": "second-person property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"pronoun": {
 					"person": "Second"
 				}
@@ -759,42 +675,26 @@
 		},
 		"3": {
 			"#": "third-person property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"pronoun": {}
 			}
 		},
 		"c": {
 			"#": "comparative property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"adjective": {}
 			}
 		},
 		"u": {
 			"#": "superlative property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"adjective": {}
 			}
 		},
 		"*": {
 			"#": "singular third-person subject pronoun property",
 			"//": "so we can do verb agreement",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"pronoun": {
 					"is_plural": false,
 					"person": "Third"
@@ -803,11 +703,7 @@
 		},
 		":": {
 			"#": "plural pronoun property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"pronoun": {
 					"is_plural": true
 				}
@@ -816,11 +712,7 @@
 		"m": {
 			"#": "auxiliary verb property",
 			"//": "see also 3: linking verb property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"metadata": {
 				"verb": {
 					"is_auxiliary": true
 				}
@@ -828,43 +720,41 @@
 		},
 		"<": {
 			"#": "American property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"propagate": true,
+			"metadata": {
 				"dialects": "AMERICAN"
 			}
 		},
 		"!": {
 			"#": "GB property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"propagate": true,
+			"metadata": {
 				"dialects": "BRITISH"
 			}
 		},
 		"@": {
 			"#": "CA property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"propagate": true,
+			"metadata": {
 				"dialects": "CANADIAN"
 			}
 		},
 		"_": {
 			"#": "AU property",
-			"kind": "property",
-			"cross_product": true,
-			"replacements": [],
-			"target": [],
-			"base_metadata": {
+			"propagate": true,
+			"metadata": {
 				"dialects": "AUSTRALIAN"
 			}
+		},
+		"w": {
+			"#": "first-person plural property",
+			"propagate": true,
+			"metadata": {
+				"pronoun": {
+					"is_plural": true,
+					"person": "First"
+				}
+			}
 		}
 	}
 }
diff --git a/harper-core/dictionary.dict b/harper-core/dictionary.dict
index b4711273d..b6fac51d9 100644
--- a/harper-core/dictionary.dict
+++ b/harper-core/dictionary.dict
@@ -4734,7 +4734,7 @@ Hymen/Og
 Hyperion/Og
 Hyundai/Ng
 Hz/g
-I/I~1
+I/I~f
 I'd/~
 I'd've/
 I'll/~
@@ -13561,8 +13561,8 @@ awning/Ng
 awoke/~V
 awoken/V
 awry/~J
-ax/~NVgdSG
-axe/NVdSgG!@_
+ax/~NVgdSG<
+axe/NVdSgG!
 axial/~JNY
 axiom/~NSg
 axiomatic/JQ
@@ -39784,7 +39784,8 @@ progovernment/J
 program/~NVerS
 programmable/~JNgS
 programmatic/JQ
-programme/NVBzd>SgZG!_
+programme/NVBzd>SgZG
+programme/!_
 programmed/~VJre
 programmer/~NgS
 programming/~NVSg
diff --git a/harper-core/src/spell/rune/attribute_list.rs b/harper-core/src/spell/rune/attribute_list.rs
index 5fe6fa54b..cebc223bb 100644
--- a/harper-core/src/spell/rune/attribute_list.rs
+++ b/harper-core/src/spell/rune/attribute_list.rs
@@ -5,6 +5,7 @@ use smallvec::ToSmallVec;
 use super::super::word_map::{WordMap, WordMapEntry};
 use super::Error;
 use super::affix_replacement::AffixReplacement;
+use super::expansion::Property;
 use super::expansion::{
     AffixEntryKind,
     AffixEntryKind::{Prefix, Suffix},
@@ -17,6 +18,7 @@ use crate::{CharString, Span, WordId, WordMetadata};
 pub struct AttributeList {
     /// Key = Affix Flag
     affixes: HashMap<char, Expansion>,
+    properties: HashMap<char, Property>,
 }
 
 impl AttributeList {
@@ -27,6 +29,7 @@ impl AttributeList {
                 .into_iter()
                 .map(|(affix, exp)| (affix, exp.into_human_readable()))
                 .collect(),
+            properties: self.properties,
         }
     }
 
@@ -48,6 +51,14 @@ impl AttributeList {
 
         let mut conditional_expansion_metadata = Vec::new();
 
+        for attr in &word.attributes {
+            let Some(property) = self.properties.get(attr) else {
+                continue;
+            };
+
+            gifted_metadata.append(&property.metadata);
+        }
+
         for attr in &word.attributes {
             let Some(expansion) = self.affixes.get(attr) else {
                 continue;
@@ -78,6 +89,16 @@ impl AttributeList {
             if expansion.cross_product {
                 let mut opp_attr = Vec::new();
 
+                for attr in &word.attributes {
+                    let Some(property) = self.properties.get(attr) else {
+                        continue;
+                    };
+                    // This is the same logic as below, plus propagation
+                    if expansion.kind == Prefix || property.propagate {
+                        opp_attr.push(*attr);
+                    }
+                }
+
                 for attr in &word.attributes {
                     let Some(attr_def) = self.affixes.get(attr) else {
                         continue;
@@ -210,6 +231,7 @@ impl AttributeList {
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct HumanReadableAttributeList {
     affixes: HashMap<char, HumanReadableExpansion>,
+    properties: HashMap<char, Property>,
 }
 
 impl HumanReadableAttributeList {
@@ -220,6 +242,9 @@ impl HumanReadableAttributeList {
             affixes.insert(affix, expansion.into_normal()?);
         }
 
-        Ok(AttributeList { affixes })
+        Ok(AttributeList {
+            affixes,
+            properties: self.properties,
+        })
     }
 }
diff --git a/harper-core/src/spell/rune/expansion.rs b/harper-core/src/spell/rune/expansion.rs
index 772563a55..a143e4af8 100644
--- a/harper-core/src/spell/rune/expansion.rs
+++ b/harper-core/src/spell/rune/expansion.rs
@@ -7,7 +7,6 @@ use crate::WordMetadata;
 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
 #[serde(rename_all = "snake_case")]
 pub enum AffixEntryKind {
-    Property,
     Suffix,
     Prefix,
 }
@@ -75,3 +74,12 @@ impl HumanReadableExpansion {
         })
     }
 }
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Property {
+    /// Whether the metadata will propagate to all derived words.
+    #[serde(default)]
+    pub propagate: bool,
+    /// The metadata applied to the word.
+    pub metadata: WordMetadata,
+}
diff --git a/harper-core/src/spell/rune/mod.rs b/harper-core/src/spell/rune/mod.rs
index d987dad98..851c1444b 100644
--- a/harper-core/src/spell/rune/mod.rs
+++ b/harper-core/src/spell/rune/mod.rs
@@ -70,7 +70,8 @@ mod tests {
                     ],
                     "base_metadata": {}
                 }
-            }
+            },
+            "properties": {}
         })
     });
 
@@ -196,7 +197,8 @@ mod tests {
                         "target": [],
                         "base_metadata": {}
                     }
-                }
+                },
+                "properties": {}
             })
             .to_string(),
         )
diff --git a/packages/web/src/routes/docs/contributors/dictionary/+page.md b/packages/web/src/routes/docs/contributors/dictionary/+page.md
index 7fd87f092..649f5de61 100644
--- a/packages/web/src/routes/docs/contributors/dictionary/+page.md
+++ b/packages/web/src/routes/docs/contributors/dictionary/+page.md
@@ -43,9 +43,7 @@ In `affixes.json`, this expansion rule looks like this:
 Those familiar with `hunspell` might notice some similarities with their dictionary format.
 The main differences are the [metadata fields.](https://docs.rs/harper-core/latest/harper_core/struct.WordMetadata.html)
 
-Most words in `dictionary.dict` have simple rules applied to them that result in no expansion, but apply metadata through the `gifts_metadata`.
-Those particular rules are done automatically.
-When adding words to the dictionary, just worry about expansions, not metadata.
+There is a separate section, `properties` that is specifically for special rules that add only metadata to the words they're applied to.
 
 ## Adding Nouns