Public: anyone can read this; the crew can change it.
The Fair Housing check catches 'not suitable for children' and 'couples welcome', and a phrase table proves what it catches and what it clears #33
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/31-the-fair-housing-check-catches-not-suita → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +75
−1
README.md +2 −1
| @@ -116,7 +116,8 @@ MLS, or edit images. You copy, print and send. The Fair Housing check is a | ||
| 116 | 116 | second pair of eyes, not legal advice: it knows phrases in context |
| 117 | 117 | (familial status, religion, national origin, disability, race, sex) — |
| 118 | 118 | "family room" passes, "family-friendly" does not; "integrated appliances" |
| 119 | passes, "integrated neighborhood" does not; "master bedroom" is a | |
| 119 | passes, "integrated neighborhood" does not; "not suitable for children" and | |
| 120 | "couples welcome" do not; "master bedroom" is a | |
| 120 | 121 | flag-with-note — and a person approves every word. |
| 121 | 122 | |
| 122 | 123 | ## For whoever runs it |
src/fairhousing.test.ts +55
| @@ -93,3 +93,58 @@ test("a finding carries a drop-in phrase only where the rule's rewrite can stand | ||
| 93 | 93 | expect(check("A Christian home.").findings[0]?.dropIn).toBeNull(); |
| 94 | 94 | expect(check("Perfect for a young family.").findings[0]?.dropIn).toBeNull(); |
| 95 | 95 | }); |
| 96 | ||
| 97 | test("a phrase table: what must be flagged, with its class, and what must stay clear", () => { | |
| 98 | const flag: [string, string, "risk" | "note"][] = [ | |
| 99 | ["Not suitable for children.", "familial status", "risk"], | |
| 100 | ["Unsuitable for kids.", "familial status", "risk"], | |
| 101 | ["Not for children.", "familial status", "risk"], | |
| 102 | ["No children.", "familial status", "risk"], | |
| 103 | ["Not ideal for young families.", "familial status", "risk"], | |
| 104 | ["Couples welcome.", "familial status", "risk"], | |
| 105 | ["Perfect for couples.", "familial status", "risk"], | |
| 106 | ["Great for young couples.", "familial status", "risk"], | |
| 107 | ["A childless household preferred.", "familial status", "risk"], | |
| 108 | ["No families.", "familial status", "risk"], | |
| 109 | ["Family-friendly street.", "familial status", "risk"], | |
| 110 | ["Kid-friendly yard.", "familial status", "risk"], | |
| 111 | ["No kids.", "familial status", "risk"], | |
| 112 | ["Adults only.", "familial status", "risk"], | |
| 113 | ["Perfect for empty nesters.", "familial status", "risk"], | |
| 114 | ["Near the church.", "religion", "risk"], | |
| 115 | ["A Christian home.", "religion", "risk"], | |
| 116 | ["A Hispanic neighborhood.", "national origin", "risk"], | |
| 117 | ["English-speaking only.", "national origin", "risk"], | |
| 118 | ["A white neighborhood.", "race", "risk"], | |
| 119 | ["A safe neighborhood.", "race", "risk"], | |
| 120 | ["Not suitable for the disabled.", "disability", "risk"], | |
| 121 | ["Handicapped accessible.", "disability", "note"], | |
| 122 | ["Bachelor pad.", "sex", "risk"], | |
| 123 | ]; | |
| 124 | for (const [text, touches, level] of flag) { | |
| 125 | const f = check(text).findings[0]; | |
| 126 | expect([text, f?.touches, f?.level]).toEqual([text, touches, level]); | |
| 127 | expect(f?.line).toContain("; say '"); | |
| 128 | } | |
| 129 | for (const text of [ | |
| 130 | "A family room.", | |
| 131 | "Walk-in closets.", | |
| 132 | "Integrated appliances.", | |
| 133 | "On Church Street.", | |
| 134 | "A quiet street.", | |
| 135 | "Within walking distance of shops.", | |
| 136 | "Great schools nearby.", | |
| 137 | "Spanish-style home.", | |
| 138 | "Colonial home.", | |
| 139 | "A 55+ community with senior living amenities.", | |
| 140 | "Two bedrooms for the whole household.", | |
| 141 | "Not far from the park, for kids and adults to enjoy.", | |
| 142 | "Not for sale before March.", | |
| 143 | ]) | |
| 144 | expect([text, flagged(text)]).toEqual([text, []]); | |
| 145 | }); | |
| 146 | ||
| 147 | test("the new familial-status rules are advice: no drop-in", () => { | |
| 148 | for (const text of ["Not suitable for children.", "Couples welcome.", "Childless.", "No families."]) | |
| 149 | expect([text, check(text).findings[0]?.dropIn]).toEqual([text, null]); | |
| 150 | }); |
src/fairhousing.ts +18
| @@ -120,6 +120,24 @@ export const RULES: readonly Rule[] = [ | ||
| 120 | 120 | why: "excludes households with children (unless a qualified 55+ community)", |
| 121 | 121 | say: "describe the property; drop the restriction", |
| 122 | 122 | }, |
| 123 | { | |
| 124 | pattern: rx( | |
| 125 | "(?:not (?:suitable|suited|appropriate|recommended|ideal|safe)? ?for|unsuitable for) (?:the |any |young |small )*(?:children|kids|toddlers|babies|families)", | |
| 126 | ), | |
| 127 | touches: "familial status", | |
| 128 | level: "risk", | |
| 129 | why: "states a preference against households with children", | |
| 130 | say: "describe the property: 'steep stairs and an unfenced pond'", | |
| 131 | }, | |
| 132 | { | |
| 133 | pattern: rx( | |
| 134 | "(?:couples?|singles?|adults?|professionals?) (?:only|welcome|preferred|wanted)|childless|no families", | |
| 135 | ), | |
| 136 | touches: "familial status", | |
| 137 | level: "risk", | |
| 138 | why: "states a preference by household", | |
| 139 | say: "describe the home, not who should live there", | |
| 140 | }, | |
| 123 | 141 | { |
| 124 | 142 | pattern: rx( |
| 125 | 143 | "empty[- ]nesters?|newlyweds?|young (?:couples?|professionals?)|singles|single (?:person|man|woman|professional)s?|mature (?:couples?|persons?|adults?)", |
Medium: the expected level is computed from the actual one (
f?.level === "note" ? "note" : "risk"), so this assertion can never fail on level. A rule that dropped from risk to note, or a flag that is only a note, would pass. Put the expected level in the table, e.g.[text, touches, level]tuples ("risk" for all except the real notes), and comparef?.levelagainst it.