skilldrop-cli 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/catalogue.json +9 -1
- package/model-routing.json +8 -0
- package/package.json +1 -1
- package/packs/startup-legal/pack.json +58 -0
- package/packs/startup-legal/skills/cap-table/SKILL.md +131 -0
- package/packs/startup-legal/skills/cap-table/evals/eval_queries.json +30 -0
- package/packs/startup-legal/skills/cap-table/evals/evals.json +46 -0
- package/packs/startup-legal/skills/cap-table/examples/seed-to-series-a.md +81 -0
- package/packs/startup-legal/skills/cap-table/manifest.json +34 -0
- package/packs/startup-legal/skills/cap-table/scripts/cap_table.py +344 -0
- package/packs/startup-legal/skills/cap-table/templates/cap.json +23 -0
- package/packs/startup-legal/skills/contract-check/SKILL.md +118 -0
- package/packs/startup-legal/skills/contract-check/evals/eval_queries.json +30 -0
- package/packs/startup-legal/skills/contract-check/evals/evals.json +46 -0
- package/packs/startup-legal/skills/contract-check/evals/files/1/saas-agreement.md +68 -0
- package/packs/startup-legal/skills/contract-check/evals/files/2/consulting-agreement.md +64 -0
- package/packs/startup-legal/skills/contract-check/evals/files/3/reseller-agreement.md +69 -0
- package/packs/startup-legal/skills/contract-check/examples/inputs/saas-agreement.md +68 -0
- package/packs/startup-legal/skills/contract-check/examples/saas-agreement.md +72 -0
- package/packs/startup-legal/skills/contract-check/manifest.json +34 -0
- package/packs/startup-legal/skills/contract-check/reference.md +109 -0
- package/packs/startup-legal/skills/contract-check/scripts/contract_check.py +413 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check an agreement for the mechanical faults that slip past a read-through. Stdlib only.
|
|
3
|
+
|
|
4
|
+
Reads a contract as Markdown, plain text or Word (.docx) and reports, each with where it is:
|
|
5
|
+
- blanks left in: [●], [•], [___], ____, [DATE], [insert …], TBD
|
|
6
|
+
- cross-references to a clause, section, schedule, exhibit, annex or appendix that doesn't exist
|
|
7
|
+
- clause numbering that skips or repeats
|
|
8
|
+
- a defined term defined twice, defined and never used, or used in lower case once defined
|
|
9
|
+
- a role or object used as if defined (Customer, Services, Confidential Information …) but never defined
|
|
10
|
+
- an amount or period in words that disagrees with its figure: "thirty (60) days", "ten thousand ($1,000)"
|
|
11
|
+
- a date that doesn't exist (31 June, 29 February in a non-leap year)
|
|
12
|
+
and lists every notice period, so deadlines can be worked out.
|
|
13
|
+
|
|
14
|
+
It checks the document's consistency, not whether its terms are fair or lawful.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python3 contract_check.py agreement.docx [--json]
|
|
18
|
+
Exit codes: 0 no failures, 1 a failure, 2 the file can't be read.
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import zipfile
|
|
25
|
+
from xml.etree import ElementTree
|
|
26
|
+
|
|
27
|
+
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_text(path):
|
|
31
|
+
if path.lower().endswith(".docx"):
|
|
32
|
+
with zipfile.ZipFile(path) as z:
|
|
33
|
+
root = ElementTree.fromstring(z.read("word/document.xml"))
|
|
34
|
+
lines = []
|
|
35
|
+
for p in root.iter(W + "p"):
|
|
36
|
+
text = "".join(t.text or "" for t in p.iter(W + "t"))
|
|
37
|
+
lines.append(text)
|
|
38
|
+
return lines
|
|
39
|
+
with open(path, encoding="utf-8-sig") as f:
|
|
40
|
+
return f.read().splitlines()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------- numbering and cross-references ----------
|
|
44
|
+
|
|
45
|
+
MULTI = re.compile(r"^\s*(?:#+\s*)?(?:\*\*)?\s*(?:(?:clause|section|article)\s+)?(\d+(?:\.\d+){1,3})\.?(?=\s|\*\*|$)", re.I)
|
|
46
|
+
SINGLE = re.compile(r"^\s*(#+\s*)?(\*\*)?\s*((?:clause|section|article)\s+)?(\d{1,3})\.?(?=\s|\*\*|$)(.*)$", re.I)
|
|
47
|
+
APPENDIX_HEAD = re.compile(r"^\s*(?:#+\s*)?(?:\*\*)?\s*(schedule|exhibit|annex|appendix|annexure)\s+([A-Z0-9]{1,3})\b", re.I)
|
|
48
|
+
REF = re.compile(r"\b(clauses?|sections?|articles?)\s+(\d+(?:\.\d+){0,3})((?:\s*(?:,|and|or|to|-|–)\s*\d+(?:\.\d+){0,3})*)", re.I)
|
|
49
|
+
APPENDIX_REF = re.compile(r"\b(schedule|exhibit|annex|appendix|annexure)\s+([A-Z0-9]{1,3})\b", re.I)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def clause_number(line):
|
|
53
|
+
"""The clause number a line starts, or None. "7.2 …" anywhere counts; a bare "7." counts only
|
|
54
|
+
as a heading (Markdown #, bold, "Clause 7", or a short title line), so a numbered list inside
|
|
55
|
+
a clause isn't read as clauses."""
|
|
56
|
+
m = MULTI.match(line)
|
|
57
|
+
if m:
|
|
58
|
+
return m.group(1)
|
|
59
|
+
m = SINGLE.match(line)
|
|
60
|
+
if not m:
|
|
61
|
+
return None
|
|
62
|
+
hashes, bold, word, n, rest = m.groups()
|
|
63
|
+
rest = rest.strip().strip("*").strip()
|
|
64
|
+
if hashes or bold or word:
|
|
65
|
+
return n
|
|
66
|
+
if rest and len(rest) <= 60 and not rest.endswith((".", ";", ",")) and rest[:1].isupper():
|
|
67
|
+
return n
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def appendix_at(line):
|
|
72
|
+
a = APPENDIX_HEAD.match(line)
|
|
73
|
+
if a and len(line.strip()) < 120:
|
|
74
|
+
return (a.group(1).lower().replace("annexure", "annex"), a.group(2).upper())
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def scan(lines):
|
|
79
|
+
"""Clause numbers in the main body, appendices present, and each numbered line with its scope."""
|
|
80
|
+
nums, apps, numbered = {}, {}, []
|
|
81
|
+
scope = "body"
|
|
82
|
+
for i, line in enumerate(lines, 1):
|
|
83
|
+
key = appendix_at(line)
|
|
84
|
+
if key:
|
|
85
|
+
apps.setdefault(key, i)
|
|
86
|
+
scope = key
|
|
87
|
+
continue
|
|
88
|
+
n = clause_number(line)
|
|
89
|
+
if n:
|
|
90
|
+
numbered.append((scope, n, i))
|
|
91
|
+
if scope == "body":
|
|
92
|
+
nums.setdefault(n, i)
|
|
93
|
+
return nums, apps, numbered
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def numbering_faults(numbered):
|
|
97
|
+
out = []
|
|
98
|
+
seen = {}
|
|
99
|
+
last = {}
|
|
100
|
+
for scope, n, i in numbered:
|
|
101
|
+
if (scope, n) in seen:
|
|
102
|
+
out.append(("FAIL", i, f"clause {n} is numbered twice (also line {seen[(scope, n)]})"))
|
|
103
|
+
continue
|
|
104
|
+
seen[(scope, n)] = i
|
|
105
|
+
parts = [int(x) for x in n.split(".")]
|
|
106
|
+
parent = (scope,) + tuple(parts[:-1])
|
|
107
|
+
prev = last.get(parent)
|
|
108
|
+
if prev is not None and parts[-1] > prev + 1:
|
|
109
|
+
want = ".".join(map(str, parts[:-1] + [prev + 1]))
|
|
110
|
+
out.append(("WARN", i, f"clause numbering jumps to {n}; {want} is missing"))
|
|
111
|
+
last[parent] = parts[-1]
|
|
112
|
+
for k in [k for k in last if len(k) > len(parent) and k[:len(parent)] == parent and k[len(parent)] != parts[-1]]:
|
|
113
|
+
del last[k]
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def ref_faults(lines, nums, apps):
|
|
118
|
+
out = []
|
|
119
|
+
tops = {n.split(".")[0] for n in nums}
|
|
120
|
+
if not nums:
|
|
121
|
+
return [("WARN", 0, "no numbered clauses found, so cross-references were not checked")]
|
|
122
|
+
for i, line in enumerate(lines, 1):
|
|
123
|
+
body = MULTI.sub("", line, count=1) if MULTI.match(line) else line
|
|
124
|
+
for m in REF.finditer(body):
|
|
125
|
+
targets = [m.group(2)] + re.findall(r"\d+(?:\.\d+){0,3}", m.group(3) or "")
|
|
126
|
+
for t in targets:
|
|
127
|
+
if t in nums or (("." not in t) and t in tops):
|
|
128
|
+
continue
|
|
129
|
+
out.append(("FAIL", i, f"refers to {m.group(1).rstrip('s').lower()} {t}, which doesn't exist"))
|
|
130
|
+
for m in APPENDIX_REF.finditer(line):
|
|
131
|
+
key = (m.group(1).lower().replace("annexure", "annex"), m.group(2).upper())
|
|
132
|
+
if appendix_at(line) and m.start() < 12:
|
|
133
|
+
continue
|
|
134
|
+
if key not in apps and not re.match(r"^(this|the|each|any|a)$", m.group(2), re.I):
|
|
135
|
+
out.append(("FAIL", i, f"refers to {key[0].title()} {key[1]}, which isn't in the document"))
|
|
136
|
+
return dedupe(out)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------- blanks ----------
|
|
140
|
+
|
|
141
|
+
BLANKS = [
|
|
142
|
+
(re.compile(r"\[\s*[●•]+\s*\]|[●•]{1,}"), "a [●] placeholder"),
|
|
143
|
+
(re.compile(r"\[\s*_+\s*\]|_{4,}"), "a blank line to fill in"),
|
|
144
|
+
(re.compile(r"\[(?:insert|enter|add|date|name|address|amount|company|party|tbc|tbd|xx+)[^\]]{0,40}\]", re.I), "a bracketed placeholder"),
|
|
145
|
+
(re.compile(r"\bTB[CD]\b"), "TBD/TBC"),
|
|
146
|
+
(re.compile(r"\bX{2,}\b"), "an XX placeholder"),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def blank_faults(lines):
|
|
151
|
+
out = []
|
|
152
|
+
for i, line in enumerate(lines, 1):
|
|
153
|
+
for rx, what in BLANKS:
|
|
154
|
+
m = rx.search(line)
|
|
155
|
+
if m:
|
|
156
|
+
out.append(("FAIL", i, f"{what} is still in: “{snippet(line, m.start())}”"))
|
|
157
|
+
break
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------- defined terms ----------
|
|
162
|
+
|
|
163
|
+
DEF_PATTERNS = [
|
|
164
|
+
re.compile(r"\(\s*(?:the\s+|each\s+a\s+|together\s+the\s+|hereinafter\s+(?:the\s+)?)?[\"“]([A-Z][A-Za-z0-9&'\- ]{1,60}?)[\"”]\s*\)"),
|
|
165
|
+
re.compile(r"[\"“]([A-Z][A-Za-z0-9&'\- ]{1,60}?)[\"”]\s+(?:means|shall mean|has the meaning|includes|refers to)\b"),
|
|
166
|
+
re.compile(r"\*\*[\"“]?([A-Z][A-Za-z0-9&'\- ]{1,60}?)[\"”]?\*\*\s+(?:means|shall mean|has the meaning)\b"),
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
COMMON = ["Agreement", "Customer", "Client", "Company", "Supplier", "Vendor", "Provider", "Service Provider",
|
|
170
|
+
"Licensee", "Licensor", "Contractor", "Consultant", "Services", "Deliverables", "Software", "Fees",
|
|
171
|
+
"Effective Date", "Term", "Territory", "Confidential Information", "Intellectual Property",
|
|
172
|
+
"Intellectual Property Rights", "Affiliate", "Personal Data", "Products", "Order Form",
|
|
173
|
+
"Statement of Work", "Disclosing Party", "Receiving Party", "Purpose", "Business Day",
|
|
174
|
+
"Commencement Date", "Investor", "Founder", "Employee", "Platform", "Subscription Term",
|
|
175
|
+
"Documentation", "Customer Data", "Work Product", "Background IP", "Licensed Materials"]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def definitions(lines):
|
|
179
|
+
defs = {}
|
|
180
|
+
for i, line in enumerate(lines, 1):
|
|
181
|
+
for rx in DEF_PATTERNS:
|
|
182
|
+
for m in rx.finditer(line):
|
|
183
|
+
term = m.group(1).strip()
|
|
184
|
+
if len(term) < 2 or term.lower() in ("the", "a", "an"):
|
|
185
|
+
continue
|
|
186
|
+
defs.setdefault(term, []).append(i)
|
|
187
|
+
return defs
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def term_faults(lines, defs):
|
|
191
|
+
out = []
|
|
192
|
+
text = "\n".join(lines)
|
|
193
|
+
for term, where in defs.items():
|
|
194
|
+
places = sorted(set(where))
|
|
195
|
+
if len(places) > 1:
|
|
196
|
+
out.append(("FAIL", places[1], f"“{term}” is defined more than once (lines {', '.join(map(str, places))})"))
|
|
197
|
+
uses = len(re.findall(r"(?<![A-Za-z])" + re.escape(term) + r"(?![A-Za-z])", text))
|
|
198
|
+
if uses <= len(where):
|
|
199
|
+
out.append(("WARN", places[0], f"“{term}” is defined but never used"))
|
|
200
|
+
lower = [n for n, l in enumerate(lines, 1) if n not in places and
|
|
201
|
+
re.search(r"(?<![A-Za-z])" + re.escape(term.lower()) + r"(?![A-Za-z])", l) and len(term.split()) > 1]
|
|
202
|
+
if lower:
|
|
203
|
+
out.append(("WARN", lower[0], f"“{term.lower()}” appears in lower case although “{term}” is a "
|
|
204
|
+
f"defined term (lines {', '.join(map(str, lower[:5]))})"))
|
|
205
|
+
defined_lower = {t.lower() for t in defs}
|
|
206
|
+
for term in COMMON:
|
|
207
|
+
if term.lower() in defined_lower:
|
|
208
|
+
continue
|
|
209
|
+
rx = re.compile(r"(?<=[a-z,;] )(?:the )?" + re.escape(term) + r"(?![A-Za-z])")
|
|
210
|
+
hits = [(n, len(rx.findall(l))) for n, l in enumerate(lines, 1) if rx.search(l)]
|
|
211
|
+
count = sum(c for _, c in hits)
|
|
212
|
+
if count >= 2:
|
|
213
|
+
out.append(("WARN", hits[0][0], f"“{term}” is used as a defined term (capitalised, {count} times) but "
|
|
214
|
+
"never defined"))
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------- amounts in words and figures ----------
|
|
219
|
+
|
|
220
|
+
UNITS = {w: i for i, w in enumerate("zero one two three four five six seven eight nine ten eleven twelve thirteen "
|
|
221
|
+
"fourteen fifteen sixteen seventeen eighteen nineteen".split())}
|
|
222
|
+
TENS = {w: 10 * i for i, w in enumerate("_ _ twenty thirty forty fifty sixty seventy eighty ninety".split()) if w != "_"}
|
|
223
|
+
SCALES = {"hundred": 100, "thousand": 1000, "lakh": 100000, "lakhs": 100000, "million": 10 ** 6,
|
|
224
|
+
"crore": 10 ** 7, "crores": 10 ** 7, "billion": 10 ** 9}
|
|
225
|
+
WORD = r"(?:zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|" \
|
|
226
|
+
r"seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand|lakhs?|" \
|
|
227
|
+
r"million|crores?|billion|and|point)"
|
|
228
|
+
WORDS_FIG = re.compile(r"\b((?:" + WORD + r")(?:[\s-]+" + WORD + r")*)\b(?:\s+[A-Za-z]+){0,3}?\s*\(\s*([$€£₹]?\s?[\d,]+(?:\.\d+)?)\s*\)", re.I)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def words_to_number(words):
|
|
232
|
+
total, current = 0, 0
|
|
233
|
+
toks = [t for t in re.split(r"[\s-]+", words.lower()) if t and t != "and"]
|
|
234
|
+
if not toks or "point" in toks:
|
|
235
|
+
return None
|
|
236
|
+
for t in toks:
|
|
237
|
+
if t in UNITS:
|
|
238
|
+
current += UNITS[t]
|
|
239
|
+
elif t in TENS:
|
|
240
|
+
current += TENS[t]
|
|
241
|
+
elif t == "hundred":
|
|
242
|
+
current = (current or 1) * 100
|
|
243
|
+
elif t in SCALES:
|
|
244
|
+
total += (current or 1) * SCALES[t]
|
|
245
|
+
current = 0
|
|
246
|
+
else:
|
|
247
|
+
return None
|
|
248
|
+
return total + current
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def amount_faults(lines):
|
|
252
|
+
out = []
|
|
253
|
+
for i, line in enumerate(lines, 1):
|
|
254
|
+
for m in WORDS_FIG.finditer(line):
|
|
255
|
+
words = m.group(1)
|
|
256
|
+
if words.strip().lower() in ("and", "point", "one"):
|
|
257
|
+
if words.strip().lower() != "one":
|
|
258
|
+
continue
|
|
259
|
+
val = words_to_number(words)
|
|
260
|
+
fig = float(re.sub(r"[^\d.]", "", m.group(2)) or "nan")
|
|
261
|
+
if val is None or fig != fig:
|
|
262
|
+
continue
|
|
263
|
+
if abs(val - fig) > 1e-9:
|
|
264
|
+
out.append(("FAIL", i, f"“{m.group(0).strip()}”: the words say {val:,} and the figure says {fig:,.0f}"))
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------- dates and notice periods ----------
|
|
269
|
+
|
|
270
|
+
MONTHS = {m: i for i, m in enumerate(["january", "february", "march", "april", "may", "june", "july", "august",
|
|
271
|
+
"september", "october", "november", "december"], 1)}
|
|
272
|
+
DATE1 = re.compile(r"\b(\d{1,2})(?:st|nd|rd|th)?\s+(?:day\s+of\s+)?(" + "|".join(MONTHS) + r"),?\s+(\d{4})\b", re.I)
|
|
273
|
+
DATE2 = re.compile(r"\b(" + "|".join(MONTHS) + r")\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\b", re.I)
|
|
274
|
+
NOTICE = re.compile(r"\(?(\d{1,3})\)?\s+(business\s+days?|calendar\s+days?|days?|weeks?|months?)[’']?\s*"
|
|
275
|
+
r"(?:(?:prior\s+)?(?:written\s+)?notice|(?:prior\s+to|before)\s+(?:the\s+)?(?:end|expiry|expiration|renewal))",
|
|
276
|
+
re.I)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def date_faults(lines):
|
|
280
|
+
import calendar
|
|
281
|
+
out = []
|
|
282
|
+
for i, line in enumerate(lines, 1):
|
|
283
|
+
for m in list(DATE1.finditer(line)) + list(DATE2.finditer(line)):
|
|
284
|
+
if m.re is DATE1:
|
|
285
|
+
d, mon, y = int(m.group(1)), MONTHS[m.group(2).lower()], int(m.group(3))
|
|
286
|
+
else:
|
|
287
|
+
mon, d, y = MONTHS[m.group(1).lower()], int(m.group(2)), int(m.group(3))
|
|
288
|
+
if not 1 <= d <= calendar.monthrange(y, mon)[1]:
|
|
289
|
+
out.append(("FAIL", i, f"“{m.group(0)}” is not a real date"))
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def notices(lines, term_end=None):
|
|
294
|
+
found = []
|
|
295
|
+
for i, line in enumerate(lines, 1):
|
|
296
|
+
for m in NOTICE.finditer(line):
|
|
297
|
+
n = {"line": i, "period": f"{m.group(1)} {m.group(2).lower()}", "text": snippet(line, m.start(), 90)}
|
|
298
|
+
before_end = re.search(r"before|prior to|renew|expir|end of", line[m.start():], re.I)
|
|
299
|
+
if term_end and before_end:
|
|
300
|
+
n["notice_by"] = notice_by(term_end, int(m.group(1)), m.group(2).lower()).isoformat()
|
|
301
|
+
found.append(n)
|
|
302
|
+
return found
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def notice_by(end, n, unit):
|
|
306
|
+
"""The last day notice can be given so that n units still remain before `end`."""
|
|
307
|
+
import calendar
|
|
308
|
+
import datetime as dt
|
|
309
|
+
if unit.startswith("business"):
|
|
310
|
+
d, left = end, n
|
|
311
|
+
while left:
|
|
312
|
+
d -= dt.timedelta(days=1)
|
|
313
|
+
if d.weekday() < 5:
|
|
314
|
+
left -= 1
|
|
315
|
+
return d
|
|
316
|
+
if unit.startswith("week"):
|
|
317
|
+
return end - dt.timedelta(weeks=n)
|
|
318
|
+
if unit.startswith("month"):
|
|
319
|
+
y, mth = divmod(end.year * 12 + end.month - 1 - n, 12)
|
|
320
|
+
return end.replace(year=y, month=mth + 1, day=min(end.day, calendar.monthrange(y, mth + 1)[1]))
|
|
321
|
+
return end - dt.timedelta(days=n)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ---------- output ----------
|
|
325
|
+
|
|
326
|
+
def snippet(line, at, width=60):
|
|
327
|
+
start = max(0, at - 20)
|
|
328
|
+
s = line[start:start + width].strip()
|
|
329
|
+
return ("…" if start else "") + s + ("…" if start + width < len(line) else "")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def dedupe(items):
|
|
333
|
+
seen, out = set(), []
|
|
334
|
+
for it in items:
|
|
335
|
+
if it not in seen:
|
|
336
|
+
seen.add(it)
|
|
337
|
+
out.append(it)
|
|
338
|
+
return out
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def check(lines, term_end=None):
|
|
342
|
+
nums, apps, numbered = scan(lines)
|
|
343
|
+
defs = definitions(lines)
|
|
344
|
+
findings = (blank_faults(lines) + ref_faults(lines, nums, apps) + numbering_faults(numbered) +
|
|
345
|
+
term_faults(lines, defs) + amount_faults(lines) + date_faults(lines))
|
|
346
|
+
findings = dedupe(findings)
|
|
347
|
+
findings.sort(key=lambda f: (f[0] != "FAIL", f[1]))
|
|
348
|
+
return {"findings": findings, "clauses": len(nums), "appendices": sorted(f"{k[0].title()} {k[1]}" for k in apps),
|
|
349
|
+
"defined_terms": sorted(defs), "notices": notices(lines, term_end)}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def clause_at(lines, n):
|
|
353
|
+
"""The nearest numbered clause at or above line n, for a reader who has the document open."""
|
|
354
|
+
for i in range(min(n, len(lines)), 0, -1):
|
|
355
|
+
if appendix_at(lines[i - 1]):
|
|
356
|
+
return None
|
|
357
|
+
c = clause_number(lines[i - 1])
|
|
358
|
+
if c:
|
|
359
|
+
return c
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def report(path, lines, r):
|
|
364
|
+
L = [f"# Contract check: {path}", "",
|
|
365
|
+
f"{r['clauses']} numbered clauses, {len(r['defined_terms'])} defined terms, "
|
|
366
|
+
f"{len(r['appendices'])} schedules or exhibits ({', '.join(r['appendices']) or 'none'}).", ""]
|
|
367
|
+
if not any(f[0] == "FAIL" for f in r["findings"]):
|
|
368
|
+
L.append("PASS no blanks, broken cross-references, repeated clause numbers, doubly defined terms, "
|
|
369
|
+
"mismatched amounts or impossible dates.")
|
|
370
|
+
for level, line, msg in r["findings"]:
|
|
371
|
+
where = f"line {line}" if line else "document"
|
|
372
|
+
c = clause_at(lines, line) if line else None
|
|
373
|
+
if c:
|
|
374
|
+
where += f", clause {c}"
|
|
375
|
+
L.append(f"{level} [{where}] {msg}")
|
|
376
|
+
if r["notices"]:
|
|
377
|
+
L += ["", "Notice periods:"]
|
|
378
|
+
for n in r["notices"]:
|
|
379
|
+
c = clause_at(lines, n["line"])
|
|
380
|
+
by = f" — give notice by {n['notice_by']}" if n.get("notice_by") else ""
|
|
381
|
+
L.append(f"- {n['period']} (line {n['line']}{', clause ' + c if c else ''}){by}: {n['text']}")
|
|
382
|
+
return "\n".join(L) + "\n"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def main(argv=None):
|
|
386
|
+
ap = argparse.ArgumentParser(description="Check an agreement's mechanics.")
|
|
387
|
+
ap.add_argument("document")
|
|
388
|
+
ap.add_argument("--json", action="store_true")
|
|
389
|
+
ap.add_argument("--term-end", help="YYYY-MM-DD the current term ends, to date each notice deadline")
|
|
390
|
+
args = ap.parse_args(argv)
|
|
391
|
+
term_end = None
|
|
392
|
+
if args.term_end:
|
|
393
|
+
import datetime as dt
|
|
394
|
+
try:
|
|
395
|
+
term_end = dt.date.fromisoformat(args.term_end)
|
|
396
|
+
except ValueError:
|
|
397
|
+
print("FAIL --term-end must be a date as YYYY-MM-DD")
|
|
398
|
+
return 2
|
|
399
|
+
try:
|
|
400
|
+
lines = read_text(args.document)
|
|
401
|
+
except (OSError, UnicodeDecodeError, zipfile.BadZipFile, KeyError, ElementTree.ParseError) as e:
|
|
402
|
+
print(f"FAIL cannot read {args.document}: {e}")
|
|
403
|
+
return 2
|
|
404
|
+
r = check(lines, term_end)
|
|
405
|
+
if args.json:
|
|
406
|
+
print(json.dumps(r, indent=2, ensure_ascii=False))
|
|
407
|
+
else:
|
|
408
|
+
print(report(args.document, lines, r), end="")
|
|
409
|
+
return 1 if any(f[0] == "FAIL" for f in r["findings"]) else 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
if __name__ == "__main__":
|
|
413
|
+
sys.exit(main())
|