Contract check
Reads what a contract says. It finds the defined terms (where each is defined, what the definition says and where it is used) and reports what is wrong with them: a term used but never defined, defined but never used, defined twice, used before its definition. Uses are underlined and hovering one shows its definition; findings are listed in a panel and saved as a Markdown or JSON report. Detection is deterministic: the same document gives the same terms and findings, in the same order, with no model involved.
Packages
| Package | Contents |
|---|---|
@stesura/contract-check | contractCheckPlugin, buildTermIndex, findings, review state, reports, language profiles, settings |
@stesura/contract-check-react | createContractCheckUIExtension: the Review-tab button, findings panel, status-bar count, hover card |
Setup
import { contractCheckPlugin } from "@stesura/contract-check";
import { contractCheckUIExtension } from "@stesura/contract-check-react";
// in the mount hook's extraPlugins
contractCheckPlugin();
// in uiExtensions (module level)
const uiExtensions = [contractCheckUIExtension];To take the organisation’s settings from an endpoint, create the extension with it instead:
import { createContractCheckUIExtension } from "@stesura/contract-check-react";
const contractCheckUI = createContractCheckUIExtension({ settingsEndpoint: "/api/contract-check/settings" });The plugin rebuilds the index a debounce after the last edit (500 ms by default), in the root editor only, and maps positions in between. Underlines are drawn for what is on screen: the UI extension reports the visible range as the reader scrolls. Without it (headless, tests) the whole document is underlined.
The card opens when the pointer is over a term or the caret is in one; a live region reads it,
and Escape closes it. Keyboard users go to the definition with goToDefinitionAtSelection,
which the host binds to a key of its choice.
Findings
| Rule | Severity | Reports |
|---|---|---|
| CHK-1 Used not defined | hint | A capitalised phrase used like a term (at least twice, after an article at least half the time) that nothing defines. A phrase before a defined term counts on its own (Subsidiary in “Subsidiary of the Borrower”); a clause’s run-in heading does not. Off for German, where every noun is capitalised. |
| CHK-2 Defined not used | warning | A definition with no use outside headers, footers and its own body (for a definition in a sentence, the words before the term). Not reported: a party’s name (Blackpebble SARL), or one half of a singular and plural defined together when the other half is used. |
| CHK-3 Duplicated term | warning | A term defined twice in one scope, or singular and plural both defined in different places. Template alternatives and lower-case terms are not duplicates. |
| CHK-4 Inconsistent capitalization | hint | A multi-word term written with other capitals (permitted security). One word in lower case is the ordinary word. |
| CHK-5 Orphan short form | warning | An all-caps abbreviation that is never defined and not a known short form. Off by default: switch it on in the settings. |
| CHK-6 Used before defined | warning | A term defined in a sentence (not a definitions list) and used before it, unless followed by a forward reference (“as defined below”, “as hereinafter defined”). Not reported when the term is used more before its definition than after it (the definition belongs to a later part, such as a schedule), for a clause’s run-in heading, or for a lower-case word. |
| CHK-10 Inconsistent separators | hint | An entry whose separator (:, means, a tab) differs from most entries of the same definitions list. |
| CHK-22 Structure drift | warning | A clause number in one language version and not the other (“7.1” only in the French version). Numbers only: limbs and ordinal words are not compared. |
| CHK-30 Typed reference | hint | A typed reference (“Clause 5.2(a)”) that resolves to exactly one clause: a live cross-reference would follow renumbering. One finding per target. Fixable when the clause’s counter reads what is typed: the number becomes a live cross-reference. |
| CHK-31 Reference not found | error | A typed reference to a clause this document does not have. Not reported: references to another document or a statute (of the Intercreditor Agreement, du Code civil, § 5 BGB, artikel 30 lid 5 AVG), a number far past the document’s last clause, units inside the current article (Abs. 2, comma 6, §1 of this article), and limbs typed in the text of a clause that exists. |
| CHK-32 Ambiguous reference | warning | A typed reference that matches several clauses. A schedule’s restarted numbering does not count (the body’s clause wins), nor a smaller list beside the main one. |
| CHK-33 Broken cross-reference | error | A live cross-reference whose target no longer exists. |
| CHK-34 Schedule references | warning | A schedule, annex, appendix or exhibit cited but missing, or present but never cited. Missing ones are reported only when the document holds at least two of that kind: a document whose schedules are separate cites them freely. |
| CHK-35 Wrong direction | warning | A live “above”/“below” reference whose target lies the other way. |
| CHK-36 Definition reference | warning | “X as defined in Clause N” (or “has the meaning given to it in”, “a le sens qui lui est donné à l’article”, “wie in Ziffer … definiert”, per language) where Clause N, with the clauses numbered under it, does not define X. A list cites every clause in it (“Clauses 2.1 and 1.1”), and “Schedule 1” the whole schedule. The message names where X is defined, or says nothing in the document defines it. A clause counts as defining X when its text quotes, bolds or underlines X, or follows it with a definition verb (“Agent means”): detection misses some definitions. Not reported: a clause that does not exist (CHK-31) or is ambiguous (CHK-32), another document’s clause, a typed reference in an unassembled template, a clause that includes another template, a paragraph of another definition (“paragraph (a) of the definition of Acceptable Bank”), and a definition that itself points at the cited clause. |
| CHK-40 Incomplete items | warning | A blank left in the text: [●], [•], [ ], [__], ___, [insert …], […], TBD, TBC, {{…}}, and per language [à compléter], [Platzhalter], Dutch PM. XX counts, except right after a reference word (Article XX) in a document that uses two or more Roman references (Article XIX, Article XXI). One finding per kind of blank per paragraph. Highlights are not read: format-lint’s FMT-23 reports highlighted paragraphs. |
| CHK-41 Unmatched brackets | warning | A parenthesis left open or closing nothing in its paragraph or the few around it (a lead-in’s parenthesis may close in a later limb). List labels (a), iv), 13.2.b)) are not brackets. Square brackets and quotation marks pair across paragraphs (a template’s optional text and a quoted clause run over several), under the language’s quotation styles; a straight " pairs with any mark. Paragraphs carrying template markup (<…, ` |
| CHK-42 Words and figures differ | error | Number words that say another number than the figure after them: ten (11), dix (11), zehn (11). The figure is read with the language’s separators (30.000 is thirty thousand in French, thirty in English); Belgian septante and nonante are read. Not reported: words that do not read as one number, an article alone (een (2)), a percentage (cinq pour cent (5)), and words where the part after “and” matches (one hundred and twenty (20)). |
| CHK-43 Editing artifacts | warning | Strikethrough set by hand (not a tracked deletion), and drafting notes: NTD, [Note:, [Anm.:, [Noot:, ??. Paragraphs struck through whole and in a row are one finding. |
Each finding has an id that hashes its rule, scope and term (plus the block id for CHK-40 to
CHK-43), so it survives edits elsewhere and re-imports. Heuristic findings (CHK-1, CHK-5) carry a
confidence and a band (recommend, shown as “likely”, or possible). CHK-30 findings can
be fixed (fixable): see Cross-references.
The panel lists errors and warnings, then hints, in document order. Each finding can be ignored
or flagged; that state is the doc attr contractCheckReview (ignored or flagged per finding
id), shared with collaborators (last write wins) and not part of undo. Open errors and warnings
are tinted in the text; hovering a finding in the panel outlines its ranges. A finding that points
to several places (a term defined twice) steps through them with ‹ n/m ›
(checkFindingRange(state, id, index), focusCheckFinding(id, index)). The status bar counts
open errors and warnings.
Checking can be switched off from the status bar or the Review group’s menu
(setContractCheckEnabled(view, on), or contractCheckPlugin({ enabled: false })): off, nothing
is built or drawn. The UI keeps the choice as a user preference in localStorage, not in the
document, and every mounted editor follows it; until the user switches it on, it is off. Pass contractCheckPlugin({ enabled: readContractCheckEnabled }) (from @stesura/contract-check-react) so an editor the user switched
off never runs its first build.
Read them from code with selectCheckFindings(state) (ranges mapped to the current document,
review state attached), or headless with runContractCheck(doc).
Cross-references
Typed references are matched against the clause labels core derives from the numbering
(getClauseLabels), and read with the same tokenizeReference the AI navigation tools use, so
“paragraph (b) of Clause 5.2” and “5.2(b)” are one clause. Numbers typed by hand (“5.2 The
Borrower…”, “Art. 7 Durata”) count as clauses too. Labels need the counters the numbering
plugin writes: in the editor they are there; headless (a CLI, a server), run the numbering
plugin over the imported document before checking it.
A typed reference can be made live: the panel’s Fix (or Fix all) replaces its number with a
cross-reference to the clause, the way a Word user types “Clause ” and inserts the number as a
field. “paragraph (b) of Clause 5.2” gets two, one to the limb and one to its clause; a
schedule’s number stays typed. A number converts only when the live reference would read exactly
what is typed: it shows the clause’s number without a trailing full stop, as Word does (“Clause
17.3” against a heading numbered “17.3.” converts; “1.2(a)” against “1.2 (a)” does not). Numbers
typed by hand have no counter to point at, and a number with a tracked change inside stays typed.
From code,
applyCheckFixes(ids | "all") collects the findings again, applies them in one transaction (one
undo step) and leaves a summary (getCheckFixSummary). While track changes is on it refuses,
since every conversion would be a tracked deletion and insertion; { force: true } applies
anyway, and the panel offers “Apply anyway”.
Reports
renderCheckReport(findings, index, { format }) writes Markdown for a reader or an agent, or
JSON for a program. Findings are in document order and quotes cite their block with the same
{#id} tags read_document emits; there are no timestamps, so the same document gives the
same bytes. The panel saves both.
How a term is found
Every candidate scores weighted signals: quotes, bold, a definition verb (means, désigne,
bedeutet, …), a parenthesis ((the “Borrower”), (la **Société**), (les Statuts)), a
separator, a run of neighbouring definitions, and penalties for headings, form labels and template
markup. A score of 5 or more makes a term; 4 to 5 is a possible definition, kept for findings. A
lower-case term introduced by “includes” (a “person” includes …, comprend, umfasst,
omvat, comprende, incluye) is the construction clause widening an ordinary word, not a
term; lower-case terms introduced otherwise (referred to as “family members”) stay terms.
The weights were calibrated on a labelled corpus and live in the settings, per language.
Uses match the longest term first (Permitted Security is not also a use of Security), with
plurals, possessives and the profile’s other forms. Matching is case-sensitive. Text in a tracked
deletion is not read; header and footer text is underlined but never counts as a use.
Languages and scopes
English, French, German, Dutch, Italian and Spanish each have a LanguageProfile. English and
French are validated against labelled contracts; German, Dutch, Italian and Spanish ship
unvalidated (profile.validated is false): they were measured on one contract each and fall
below the precision or recall the other two reach. The language comes from the text, not the
document’s proofing attributes, which imports often get wrong. A bilingual document whose
versions follow each other gets one scope per version, linked as translations: a French use
matches the French definitions, and a footnote belongs to the version its anchor is in.
Settings
CheckSettings is JSON, meant to come from an organisation’s settings endpoint
(settingsEndpoint above; parseCheckSettings drops anything malformed). It overrides weights
and thresholds, switches rules on or off, and adds common terms (read by CHK-1) and short forms
(read by CHK-5) to the profiles.
contractCheckPlugin({
settings: {
commonTerms: { fr: ["Commission de Surveillance du Secteur Financier"] },
weights: { de: { bold: 2 } },
thresholds: { recommend: 5.5 },
rules: { "CHK-5": true },
},
});