Skip to content

Commit dcf856f

Browse files
clay-goodclaude
andcommitted
fix(extract): count a plural use of a singular-defined term as a use
STRUCT-005 flags defined terms with no downstream use as probable template leftovers. Usage was matched by the exact term (`\bTerm\b`), so a term defined in the singular but used only in the plural — "Confidential Material" … "the Confidential Materials", "Disclosing Party" … "the Disclosing Parties" — counted as zero uses and was wrongly reported as unused. (Possessives already matched, since the apostrophe is a word boundary.) Match the term's regular plural alongside the term (final-word +s / y→ies / sibilant +es). Terms whose final word already ends in "s" are left alone (ambiguous). A prefix-sharing word like "Feedback" is not counted as a use of "Fee" because the alternation is word-boundary anchored. Zero golden churn: no fixture across the 327-doc corpus defines a term in the singular and uses it only in the plural, so this is a prospective FP reduction. `unused_terms` feeds STRUCT-005 (finding-level). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4a952ca commit dcf856f

3 files changed

Lines changed: 58 additions & 3 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
**Vaulytica is the second pair of eyes you can cite.**
66

7-
`1,111 deterministic rules` · `20 cross-document checks` · `5 pre-disclosure checks` · `3 execution-readiness reconciliations` · `5 derived-deadline families` · `16 document sub-domains` · `88 state-law overlays (non-compete · security deposit · usury · will formalities)` · `10 export formats` · `0 servers` · `0 AI` · `5,856 passing tests` · `v9.41.0` · `MIT`
7+
`1,111 deterministic rules` · `20 cross-document checks` · `5 pre-disclosure checks` · `3 execution-readiness reconciliations` · `5 derived-deadline families` · `16 document sub-domains` · `88 state-law overlays (non-compete · security deposit · usury · will formalities)` · `10 export formats` · `0 servers` · `0 AI` · `5,858 passing tests` · `v9.41.0` · `MIT`
88

99
![Vaulytica landing page — "Drop legal docs. Get a report. Nothing leaves your browser."](docs/images/hero.png)
1010

@@ -1332,7 +1332,7 @@ npm run dev # open the printed URL
13321332
npm run build # static site → dist/
13331333
npm run typecheck # tsc --noEmit
13341334
npm run lint # eslint
1335-
npm run test # vitest — 5,856 tests, ~35s
1335+
npm run test # vitest — 5,858 tests, ~35s
13361336
npm run coverage # vitest + V8 coverage, enforces the regression floor
13371337
npm run accuracy # v5 Ground Truth harness → tools/accuracy/SCOREBOARD.md
13381338
npm run mutation # Stryker mutation score (scoped to extractors; slow, off the per-push path)

src/extract/definitions.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,32 @@ describe("extractDefinitions", () => {
3636
expect(terms).not.toContain("Headings");
3737
});
3838

39+
it("counts a plural use of a singular-defined term as a use", () => {
40+
// A term defined in the singular but used only in the plural was reported by
41+
// STRUCT-005 as an unused template leftover even though it is used.
42+
const tree = buildTree([
43+
"Definitions",
44+
'"Confidential Material" means any non-public information.',
45+
'"Disclosing Party" means the party sharing information.',
46+
"Each party shall protect the Confidential Materials it receives from the Disclosing Parties.",
47+
]);
48+
const map = extractDefinitions(tree);
49+
expect(map.unused_terms).not.toContain("Confidential Material");
50+
expect(map.unused_terms).not.toContain("Disclosing Party");
51+
expect(map.entries.find((e) => e.term === "Confidential Material")?.used_at.length).toBe(1);
52+
});
53+
54+
it("does not treat an unrelated word sharing a prefix as a plural use", () => {
55+
const tree = buildTree([
56+
"Definitions",
57+
'"Fee" means the amount payable for the Services.',
58+
"The Customer may submit Feedback about the platform.",
59+
]);
60+
const map = extractDefinitions(tree);
61+
// "Feedback" is not a use of "Fee"/"Fees"; the term stays unused.
62+
expect(map.unused_terms).toContain("Fee");
63+
});
64+
3965
it("records defined-but-never-used terms", () => {
4066
const tree = buildTree([
4167
"Definitions",

src/extract/definitions.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,15 @@ export function extractDefinitions(tree: DocumentTree): DefinitionMap {
542542

543543
// Pass 3: record every use of each term outside its definition.
544544
for (const entry of definitions.values()) {
545-
const needle = new RegExp(`\\b${escapeRegExp(entry.term)}\\b`, "g");
545+
// Match the term as defined and its regular plural — a term defined in the
546+
// singular is routinely used in the plural ("… the Confidential Materials
547+
// it receives"), which is still a use, not a template leftover.
548+
const plural = regularPlural(entry.term);
549+
const alternatives =
550+
plural && plural !== entry.term
551+
? `${escapeRegExp(entry.term)}|${escapeRegExp(plural)}`
552+
: escapeRegExp(entry.term);
553+
const needle = new RegExp(`\\b(?:${alternatives})\\b`, "g");
546554
forEachParagraph(tree, (ctx) => {
547555
// Skip the definition itself. For an express definition that is the
548556
// whole paragraph — a term repeated inside its own definition body
@@ -1240,3 +1248,24 @@ function registerDefinition(map: Map<string, DefinitionEntry>, entry: Definition
12401248
function escapeRegExp(s: string): string {
12411249
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12421250
}
1251+
1252+
/**
1253+
* Regular English plural of a term's final word — "Confidential Material" →
1254+
* "Confidential Materials", "Disclosing Party" → "Disclosing Parties",
1255+
* "Franchise" → "Franchises". Returns null when no simple rule applies (the
1256+
* final word already ends in "s", so its plural is ambiguous — "Losses" vs an
1257+
* already-plural "Fees"). Used only to count a singular-defined term that the
1258+
* body uses in the plural as a genuine use, so STRUCT-005 does not report it as
1259+
* an unused template leftover.
1260+
*/
1261+
function regularPlural(term: string): string | null {
1262+
const m = /^(.*?)(\S+)$/.exec(term);
1263+
if (!m) return null;
1264+
const [, head, last] = m as unknown as [string, string, string];
1265+
if (/s$/i.test(last)) return null;
1266+
let plural: string;
1267+
if (/[^aeiou]y$/i.test(last)) plural = last.replace(/y$/i, "ies");
1268+
else if (/(x|z|ch|sh)$/i.test(last)) plural = last + "es";
1269+
else plural = last + "s";
1270+
return head + plural;
1271+
}

0 commit comments

Comments
 (0)