Public: anyone can read this; the crew can change it.

Show what the Fair Housing check catches on one plain-language page, linked from every finding #70

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/67-show-what-the-fair-housing-check-catches → main 3 commits fork

  • Merged. mason/67-show-what-the-fair-housing-check-catches was merged into main as 7c9c244.

Review

Sign in to review this change.
7 files changed +304 −3
README.md +3 −1
@@ -229,7 +229,9 @@ second pair of eyes, not legal advice: it knows phrases in context
229229 "family room" passes, "family-friendly" does not; "integrated appliances"
230230 passes, "integrated neighborhood" does not; "not suitable for children" and
231231 "couples welcome" do not; "master bedroom" is a
232 flag-with-note — and a person approves every word.
232 flag-with-note — and a person approves every word. `/fair-housing` says what
233 it looks for, class by class, in words you can read to a client; every finding
234 links to it. It is built from the rules themselves, so it is never out of date.
233235
234236 ## For whoever runs it
235237
src/a11y.test.ts +1
@@ -46,6 +46,7 @@ async function paths(): Promise<string[]> {
4646 return [
4747 "/",
4848 "/notes",
49 "/fair-housing",
4950 ...ids.flatMap((id) => [
5051 `/listings/${id}`,
5152 `/listings/${id}?tab=social`,
src/fairhousingpage.test.ts added +98
@@ -0,0 +1,98 @@
1 import { expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { check, RULES, type Rule } from "./fairhousing.ts";
6 import { CLEARED, classHref, fairHousingPage, phrasesOf } from "./pages/fairhousing.ts";
7 import { asideOf } from "./pages/listing.ts";
8 import { serve } from "./server.ts";
9 import { sign } from "./who.ts";
10
11 const anon = { role: "anon" } as never;
12
13 test("GET /fair-housing answers anyone, with each class, a flagged and a cleared phrase", async () => {
14 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "fh-")), identityKey: "k" });
15 try {
16 const res = await fetch(`http://${server.hostname}:${server.port}/fair-housing`);
17 expect(res.status).toBe(200);
18 const body = await res.text();
19 for (const id of ["familial-status", "religion", "national-origin", "disability", "race", "sex"])
20 expect(body).toContain(`id="${id}"`);
21 expect(body).toContain("family-friendly");
22 expect(body).toContain("family room");
23 expect(body).toContain("integrated appliances");
24 expect(body).toContain("master");
25 expect(body).toContain("not legal advice");
26 } finally {
27 server.stop(true);
28 }
29 });
30
31 test("every cleared example really is cleared by the check", () => {
32 for (const c of CLEARED) expect(check(c.text).clear).toBe(true);
33 });
34
35 test("a rule added to the table appears without touching the page", () => {
36 const extra: Rule = {
37 pattern: /\bzebra[- ]striped (?:pool|deck)\b/i,
38 touches: "religion",
39 level: "risk",
40 why: "tests <b>escaping</b>",
41 say: "describe the pool",
42 };
43 const page = fairHousingPage({ who: anon, rules: [...RULES, extra] });
44 expect(page).toContain("zebra-striped pool");
45 expect(page).toContain("tests &lt;b&gt;escaping&lt;/b&gt;");
46 expect(page).not.toContain("<b>escaping</b>");
47 expect(fairHousingPage({ who: anon })).not.toContain("zebra");
48 });
49
50 test("every rule shows plain example phrases, each of which the rule flags", () => {
51 for (const rule of RULES) {
52 const phrases = phrasesOf(rule);
53 expect(phrases.length).toBeGreaterThan(0);
54 for (const p of phrases) {
55 expect(p).not.toMatch(/[\[\]()*+|\\]/);
56 expect(check(p, [rule]).clear).toBe(false);
57 }
58 }
59 const family = RULES.find((r) => r.pattern.test("family-friendly"))!;
60 expect(phrasesOf(family)).toEqual(["family-friendly"]);
61 const citizens = RULES.find((r) => r.pattern.test("must speak english"))!;
62 expect(phrasesOf(citizens)).toContain("us citizens only");
63 });
64
65 test("quoted alternatives in a suggestion keep their quotes, and context-cleared rules say so", () => {
66 const page = fairHousingPage({ who: anon });
67 expect(page).toContain("‘guest suite’ or ‘accessory dwelling unit’");
68 expect(page).toContain("‘private’ or name the feature: ‘gated entry’");
69 expect(page).toContain("cleared when the words around it make the meaning plain");
70 });
71
72 test("the flyer and the email show no findings, so carry no link to the page", async () => {
73 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "fh-")), identityKey: "k" });
74 try {
75 const base = `http://${server.hostname}:${server.port}`;
76 const identity = `fh|realtor|admin|${Date.now() + 600_000}`;
77 const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, "k") };
78 const form = new URLSearchParams({ text: "A family-friendly home, no children." });
79 await fetch(`${base}/listings/1/assets/mls`, { method: "POST", headers, body: form, redirect: "manual" });
80 const listing = await (await fetch(`${base}/listings/1`, { headers })).text();
81 expect(listing).toContain("What the check looks for");
82 for (const path of ["/flyer", "/email"]) {
83 const body = await (await fetch(`${base}/listings/1${path}`, { headers })).text();
84 expect(body).not.toContain("What the check looks for");
85 expect(body).not.toContain("to review");
86 }
87 } finally {
88 server.stop(true);
89 }
90 });
91
92 test("a finding links to its class, and so does a clear check", () => {
93 const text = "A family-friendly home.";
94 const aside = asideOf("mls", text, check(text));
95 expect(aside).toContain(`href="${classHref("familial status")}"`);
96 expect(aside).toContain("What the check looks for");
97 expect(asideOf("mls", "A quiet home.", check("A quiet home."))).toContain('href="/fair-housing"');
98 });
src/pages/fairhousing.ts added +191
@@ -0,0 +1,191 @@
1 /**
2 * What the Fair Housing check looks for, at `/fair-housing`: every rule in
3 * `RULES` grouped by the class it touches, with the reason it gives and the
4 * wording it suggests, then the phrases it deliberately clears. Nothing here
5 * is a second copy of a rule: the phrases are read off each rule's pattern,
6 * so a rule added to `RULES` shows up without touching this page.
7 */
8
9 import { check, RULES, type ProtectedClass, type Rule } from "../fairhousing.ts";
10 import { escape } from "../http.ts";
11 import type { Who } from "../who.ts";
12 import { layout } from "./layout.ts";
13
14 /** Each class, in the order the page shows them, with what it protects in a sentence. */
15 const CLASSES: Readonly<Record<ProtectedClass, string>> = {
16 "familial status": "Households with children, and how a household is made up.",
17 religion: "What anyone believes, and where they worship.",
18 "national origin": "Where people, or their families, come from, and the language they speak.",
19 disability: "People with a physical or mental disability.",
20 race: "Race and colour, and who is said to live nearby.",
21 sex: "Sex, and the roles a home presumes for one.",
22 };
23
24 /** The anchor for a class: "familial status" is `#familial-status`. */
25 export const classId = (c: ProtectedClass): string => c.replace(/\s+/g, "-");
26
27 /** The link from a finding to its class on this page. */
28 export const classHref = (c?: ProtectedClass): string => (c === undefined ? "/fair-housing" : `/fair-housing#${classId(c)}`);
29
30 /** Phrases the check clears on purpose, because the context makes them plain. */
31 export const CLEARED: readonly { readonly text: string; readonly because: string }[] = [
32 { text: "family room", because: "a room in the house, not a preference for who lives in it." },
33 { text: "integrated appliances", because: "a kitchen feature, not the people next door." },
34 { text: "Church Street", because: "a street named in an address is a street." },
35 { text: "exclusive listing", because: "the trade's own words for who holds the listing." },
36 ];
37
38 const EXPAND_CAP = 4000;
39
40 /** Every string a pattern source can match, shortest first; optional parts both ways, repeats once. */
41 function expand(src: string): string[] {
42 let i = 0;
43 const product = (a: string[], b: string[]): string[] => {
44 const out = new Set<string>();
45 for (const x of a) for (const y of b) if (out.size < EXPAND_CAP) out.add(x + y);
46 return [...out];
47 };
48 const alts = (): string[] => {
49 const out: string[] = [];
50 let seq: string[] = [""];
51 while (i < src.length && src[i] !== ")") {
52 const ch = src[i]!;
53 if (ch === "|") {
54 out.push(...seq);
55 seq = [""];
56 i++;
57 continue;
58 }
59 let atom: string[];
60 if (ch === "(") {
61 i++;
62 if (src.startsWith("?:", i)) i += 2;
63 atom = alts();
64 i++; // the closing paren
65 } else if (ch === "[") {
66 const end = src.indexOf("]", i);
67 const set = src.slice(i + 1, end);
68 atom = set === "- " ? ["-"] : [...set]; // a hyphen or a space: show the hyphen
69 i = end + 1;
70 } else if (ch === "\\") {
71 const e = src[i + 1]!;
72 atom = [e === "s" ? " " : e === "b" ? "" : e];
73 i += 2;
74 } else {
75 atom = [ch];
76 i++;
77 }
78 const q = src[i];
79 if (q === "?" || q === "*") {
80 i++;
81 atom = ["", ...atom];
82 } else if (q === "+") i++;
83 else if (q === "{") {
84 const end = src.indexOf("}", i);
85 const min = Number(src.slice(i + 1, end).split(",")[0]);
86 i = end + 1;
87 if (min === 0) atom = ["", ...atom];
88 }
89 seq = product(seq, atom);
90 }
91 out.push(...seq);
92 return out;
93 };
94 return alts();
95 }
96
97 /**
98 * Example phrases the rule flags, in words a person reads: the pattern is
99 * expanded into what it matches, shortest first, a few per alternative, and
100 * each is kept only if the rule really flags it. Read from the pattern
101 * itself, so it cannot drift from the check.
102 */
103 export function phrasesOf(rule: Rule): string[] {
104 const src = rule.pattern.source.replace(/^\\b/, "").replace(/\\b$/, "");
105 const tops = splitTop(src);
106 const per = Math.min(3, Math.max(1, Math.floor(8 / tops.length)));
107 const out: string[] = [];
108 for (const top of tops) {
109 const all = new Set(expand(top).map((p) => p.replace(/\s+/g, " ").trim()));
110 const found = [...all]
111 .filter((p) => p !== "" && !all.has(`${p}s`) && check(p, [rule]).findings.length > 0)
112 .sort((x, y) => x.length - y.length);
113 for (const p of found.slice(0, per)) if (!out.includes(p)) out.push(p);
114 }
115 return out;
116 }
117
118 /** The pattern's top-level alternatives: split on `|` outside any parentheses or brackets. */
119 function splitTop(src: string): string[] {
120 const parts: string[] = [];
121 let depth = 0;
122 let cur = "";
123 for (let i = 0; i < src.length; i++) {
124 const ch = src[i]!;
125 if (ch === "\\") {
126 cur += ch + (src[++i] ?? "");
127 continue;
128 }
129 if (ch === "(" || ch === "[") depth++;
130 if (ch === ")" || ch === "]") depth--;
131 if (ch === "|" && depth === 0) {
132 parts.push(cur);
133 cur = "";
134 } else cur += ch;
135 }
136 parts.push(cur);
137 return parts;
138 }
139
140 /** What to say instead, as the check words it: advice in plain text, quoted alternatives in ‘ ’. */
141 function suggestion(rule: Rule): string {
142 let say = rule.say;
143 const first = say.indexOf("'");
144 if (first > 0 && say[first - 1] !== " ") say = `'${say}'`;
145 return say
146 .split("'")
147 .map((part, n) => (n % 2 === 1 ? `‘${escape(part)}’` : escape(part)))
148 .join("");
149 }
150
151 function ruleItem(rule: Rule): string {
152 const clears = rule.unless === undefined ? "" : " Some uses are cleared when the words around it make the meaning plain (see below).";
153 return `<li>
154 <p><span class="fh-tag">${rule.level === "note" ? "note" : "flagged"}</span> ${phrasesOf(rule)
155 .map((p) => `<b>${escape(p)}</b>`)
156 .join(" · ")}</p>
157 <p class="muted">Because it ${escape(rule.why)}. Say instead: ${suggestion(rule)}.${clears}</p>
158 </li>`;
159 }
160
161 /** The page. `rules` is `RULES`; a test may pass its own table. */
162 export function fairHousingPage(input: { who: Who; rules?: readonly Rule[] }): string {
163 const rules = input.rules ?? RULES;
164 const sections = (Object.keys(CLASSES) as ProtectedClass[])
165 .map((c) => {
166 const mine = rules.filter((r) => r.touches === c);
167 return `<section class="card" id="${classId(c)}" aria-labelledby="${classId(c)}-h">
168 <h2 id="${classId(c)}-h">${escape(c[0]!.toUpperCase() + c.slice(1))}</h2>
169 <p class="muted">${escape(CLASSES[c])}</p>
170 <ul class="fh-list" aria-label="What is flagged for ${escape(c)}">${mine.map(ruleItem).join("")}</ul>
171 </section>`;
172 })
173 .join("\n ");
174 const cleared = CLEARED.filter((c) => check(c.text, rules).clear)
175 .map((c) => `<li><b>${escape(c.text)}</b> <span class="muted">is fine: ${escape(c.because)}</span></li>`)
176 .join("");
177 return layout(
178 `<div class="narrow stack">
179 <p class="lede">Every word an asset says is read for language that states or implies a preference by a protected class. It looks at a phrase in its context, not at a list of banned words.</p>
180 <p><b>A second pair of eyes, not legal advice.</b> It flags; it never rewrites by itself, and a person approves every word. A <span class="fh-tag">note</span> is lawful but retired by many MLSs, such as “master bedroom”; both turn the check amber.</p>
181 ${sections}
182 <section class="card" id="cleared" aria-labelledby="cleared-h">
183 <h2 id="cleared-h">What it deliberately clears</h2>
184 <p class="muted">The same words can describe a house or a buyer. These describe the house.</p>
185 <ul class="fh-list" aria-label="Phrases that pass">${cleared}</ul>
186 </section>
187 </div>`,
188 input.who,
189 { path: "/fair-housing", heading: "What the Fair Housing check looks for" },
190 );
191 }
src/pages/listing.ts +4 −2
@@ -6,6 +6,7 @@
66
77 import type { Version } from "../actions.ts";
88 import type { Check, Finding } from "../fairhousing.ts";
9 import { classHref } from "./fairhousing.ts";
910 import {
1011 factItems,
1112 factsLine,
@@ -65,13 +66,14 @@ const useButton = (f: Finding, to: string): string =>
6566 */
6667 export function fairHousing(text: string, check: Check, fix?: string): string {
6768 if (check.clear)
68 return `<p class="fh-status fh-ok" role="status">✓ Fair Housing: clear</p>`;
69 return `<p class="fh-status fh-ok" role="status">✓ Fair Housing: clear</p>
70 <p><a href="${classHref()}">What the check looks for</a></p>`;
6971 const n = check.findings.length;
7072 return `<p class="fh-status fh-warn" role="status">Fair Housing: ${n} ${n === 1 ? "phrase" : "phrases"} to review</p>
7173 <ul class="fh-list" aria-label="Phrases to review">${check.findings
7274 .map(
7375 (f) =>
74 `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)}${fix === undefined ? "" : useButton(f, fix)}</li>`,
76 `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)} <a href="${classHref(f.touches)}" aria-label="What the check looks for in ${escape(f.touches)}">What the check looks for</a>${fix === undefined ? "" : useButton(f, fix)}</li>`,
7577 )
7678 .join("")}</ul>
7779 <div class="fh-text" role="group" aria-label="The words, with what was flagged underlined">${annotate(text, check)}</div>`;
src/routes.ts +6
@@ -66,6 +66,7 @@ import { events } from "./live.ts";
6666 import { rpc } from "./mcp.ts";
6767 import { draftOf, home, type Draft } from "./pages/home.ts";
6868 import { layout } from "./pages/layout.ts";
69 import { fairHousingPage } from "./pages/fairhousing.ts";
6970 import { factsPage } from "./pages/facts.ts";
7071 import { emailPage } from "./pages/email.ts";
7172 import { publicOrigin } from "./env.ts";
@@ -780,6 +781,11 @@ export const routes: readonly Route[] = [
780781 });
781782 },
782783 },
784 {
785 method: "GET",
786 path: "/fair-housing",
787 handle: (c) => html(fairHousingPage({ who: c.who })),
788 },
783789 {
784790 method: "GET",
785791 path: "/notes",
src/speed.test.ts +1
@@ -21,6 +21,7 @@ const PAGES = [
2121 "/listings/1/flyer",
2222 "/listings/1/card",
2323 "/listings/1/email",
24 "/fair-housing",
2425 "/listings/1/schedule",
2526 "/listings/1/schedule.ics",
2627 "/api/listings",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs