not-manage 0.2.1 → 0.2.3

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.
@@ -0,0 +1,178 @@
1
+ const { fetchWhoAmI, getValidAccessToken } = require("./clio-api");
2
+ const { CliError, UsageError } = require("./cli-errors");
3
+ const { findConfig, getTokenSet } = require("./store");
4
+ const { version } = require("../package.json");
5
+
6
+ const SAFE_READ_COMMAND = "not-manage users list --limit 1 --json";
7
+
8
+ function maskEmail(email) {
9
+ const text = String(email || "");
10
+ const atIndex = text.indexOf("@");
11
+ if (atIndex <= 1) {
12
+ return text ? "[redacted email]" : null;
13
+ }
14
+
15
+ return `${text[0]}****${text.slice(atIndex)}`;
16
+ }
17
+
18
+ function tokenState(tokenSet) {
19
+ if (!tokenSet || !tokenSet.accessToken) {
20
+ return {
21
+ loggedIn: false,
22
+ status: "missing",
23
+ };
24
+ }
25
+
26
+ const now = Math.floor(Date.now() / 1000);
27
+ const expiresAt = tokenSet.expiresAt || null;
28
+ const expiresSoon = expiresAt ? expiresAt <= now + 60 : false;
29
+
30
+ return {
31
+ loggedIn: true,
32
+ source: tokenSet.source || "unknown",
33
+ status: expiresSoon ? "expires_soon" : "present",
34
+ expiresAt,
35
+ };
36
+ }
37
+
38
+ function configState(config) {
39
+ if (!config) {
40
+ return {
41
+ configured: false,
42
+ status: "missing",
43
+ };
44
+ }
45
+
46
+ return {
47
+ configured: true,
48
+ source: config.source,
49
+ region: config.region,
50
+ regionLabel: config.regionLabel,
51
+ host: config.host,
52
+ redirectUri: config.redirectUri,
53
+ status: "present",
54
+ };
55
+ }
56
+
57
+ function severity(report) {
58
+ if (report.errors.length > 0) {
59
+ return "error";
60
+ }
61
+ if (report.warnings.length > 0) {
62
+ return "warn";
63
+ }
64
+ return "ok";
65
+ }
66
+
67
+ function maybeFail(report, failOn) {
68
+ if (!failOn) {
69
+ return;
70
+ }
71
+
72
+ if (!["warn", "error"].includes(failOn)) {
73
+ throw new UsageError("`--fail-on` must be `warn` or `error`.", {
74
+ code: "invalid_fail_on",
75
+ hint: "not-manage doctor --help",
76
+ });
77
+ }
78
+
79
+ const status = severity(report);
80
+ const shouldFail = failOn === "warn" ? status !== "ok" : status === "error";
81
+ if (!shouldFail) {
82
+ return;
83
+ }
84
+
85
+ throw new CliError(`Doctor found ${status === "warn" ? "warnings" : "errors"}.`, {
86
+ code: `doctor_${status}`,
87
+ details: {
88
+ status,
89
+ warnings: report.warnings,
90
+ errors: report.errors,
91
+ },
92
+ });
93
+ }
94
+
95
+ function printHuman(report) {
96
+ console.log(`not-manage v${report.version}`);
97
+ console.log(`Status: ${report.status}`);
98
+ console.log(`Config: ${report.config.status}`);
99
+ if (report.config.configured) {
100
+ console.log(`Region: ${report.config.region} (${report.config.regionLabel})`);
101
+ console.log(`Host: ${report.config.host}`);
102
+ console.log(`Redirect URI: ${report.config.redirectUri}`);
103
+ }
104
+ console.log(`Auth: ${report.auth.status}`);
105
+ if (report.api.status) {
106
+ console.log(`API: ${report.api.status}`);
107
+ }
108
+ report.warnings.forEach((warning) => console.log(`Warning: ${warning}`));
109
+ report.errors.forEach((error) => console.log(`Error: ${error}`));
110
+ report.suggestions.forEach((suggestion) => console.log(`Suggestion: ${suggestion}`));
111
+ }
112
+
113
+ async function doctor(options = {}) {
114
+ const warnings = [];
115
+ const errors = [];
116
+ const suggestions = [];
117
+ const config = await findConfig();
118
+ const configReport = configState(config);
119
+ const tokenSet = await getTokenSet();
120
+ const authReport = tokenState(tokenSet);
121
+ const apiReport = {};
122
+
123
+ if (!config) {
124
+ warnings.push("Clio app credentials are not configured.");
125
+ suggestions.push("Run `not-manage auth setup`.");
126
+ }
127
+
128
+ if (config && !authReport.loggedIn) {
129
+ warnings.push("No local OAuth token was found.");
130
+ suggestions.push("Run `not-manage auth login`.");
131
+ }
132
+
133
+ if (config && authReport.loggedIn) {
134
+ try {
135
+ const accessToken = await getValidAccessToken(config, tokenSet);
136
+ const payload = await fetchWhoAmI(config, accessToken);
137
+ const user = payload?.data || payload;
138
+ apiReport.status = "reachable";
139
+ apiReport.user = {
140
+ id: user?.id || null,
141
+ name: user?.name || null,
142
+ email: maskEmail(user?.email),
143
+ };
144
+ suggestions.push(`Run \`${SAFE_READ_COMMAND}\` to verify a resource read.`);
145
+ } catch (error) {
146
+ apiReport.status = "failed";
147
+ errors.push(error.message);
148
+ suggestions.push("Run `not-manage auth login` if the token is expired or revoked.");
149
+ }
150
+ }
151
+
152
+ const report = {
153
+ version,
154
+ status: "ok",
155
+ config: configReport,
156
+ auth: authReport,
157
+ api: apiReport,
158
+ warnings,
159
+ errors,
160
+ suggestions,
161
+ };
162
+ report.status = severity(report);
163
+
164
+ if (options.json) {
165
+ console.log(JSON.stringify(report, null, options.compact ? 0 : 2));
166
+ } else {
167
+ printHuman(report);
168
+ }
169
+
170
+ maybeFail(report, options.failOn);
171
+ }
172
+
173
+ module.exports = {
174
+ doctor,
175
+ __private: {
176
+ severity,
177
+ },
178
+ };
@@ -0,0 +1,144 @@
1
+ const fs = require("node:fs/promises");
2
+
3
+ const { __private, getValidAccessToken } = require("./clio-api");
4
+ const { UsageError } = require("./cli-errors");
5
+ const { maybeRedactPayload } = require("./redaction");
6
+ const { getConfig, getTokenSet } = require("./store");
7
+
8
+ const READ_METHODS = new Set(["GET", "HEAD"]);
9
+ const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
10
+
11
+ function parseQuery(queryText) {
12
+ if (!queryText) {
13
+ return {};
14
+ }
15
+
16
+ return String(queryText)
17
+ .split(",")
18
+ .map((part) => part.trim())
19
+ .filter(Boolean)
20
+ .reduce((query, part) => {
21
+ const separatorIndex = part.search(/[:=]/);
22
+ if (separatorIndex <= 0) {
23
+ throw new UsageError("`--query` entries must look like `key=value`.", {
24
+ code: "invalid_query",
25
+ });
26
+ }
27
+
28
+ query[part.slice(0, separatorIndex).trim()] = part.slice(separatorIndex + 1).trim();
29
+ return query;
30
+ }, {});
31
+ }
32
+
33
+ function normalizePath(config, path, query) {
34
+ if (!path) {
35
+ throw new UsageError("Usage: not-manage request <method> <path> [options]", {
36
+ code: "missing_request_path",
37
+ hint: "not-manage request --help",
38
+ });
39
+ }
40
+
41
+ const base = `https://${config.host}`;
42
+ const url = path.startsWith("http://") || path.startsWith("https://")
43
+ ? path
44
+ : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
45
+
46
+ const trusted = __private.parseTrustedApiUrl(config, url, "/api/v4/");
47
+ return __private.buildUrlWithQuery(trusted, query);
48
+ }
49
+
50
+ async function readBody(bodyFile) {
51
+ if (!bodyFile) {
52
+ return undefined;
53
+ }
54
+
55
+ const text = bodyFile === "-"
56
+ ? await new Promise((resolve, reject) => {
57
+ let body = "";
58
+ process.stdin.setEncoding("utf8");
59
+ process.stdin.on("data", (chunk) => {
60
+ body += chunk;
61
+ });
62
+ process.stdin.on("end", () => resolve(body));
63
+ process.stdin.on("error", reject);
64
+ })
65
+ : await fs.readFile(bodyFile, "utf8");
66
+
67
+ return text ? JSON.parse(text) : undefined;
68
+ }
69
+
70
+ function buildDryRunOutput(method, url, body) {
71
+ return {
72
+ dry_run: true,
73
+ would_send: {
74
+ method,
75
+ url: __private.sanitizeUrlForError(url),
76
+ write: WRITE_METHODS.has(method),
77
+ has_body: body !== undefined,
78
+ },
79
+ };
80
+ }
81
+
82
+ async function rawRequest(options = {}) {
83
+ const method = String(options.method || "").toUpperCase();
84
+ if (!READ_METHODS.has(method) && !WRITE_METHODS.has(method)) {
85
+ throw new UsageError("Usage: not-manage request <method> <path> [options]", {
86
+ code: "invalid_request_method",
87
+ hint: "Use GET, HEAD, POST, PUT, PATCH, or DELETE.",
88
+ });
89
+ }
90
+
91
+ if (WRITE_METHODS.has(method) && options.write !== true && options.dryRun !== true) {
92
+ throw new UsageError("Write methods require `--write`.", {
93
+ code: "write_confirmation_required",
94
+ hint: "Re-run with `--dry-run` to preview, or `--write` only when you intend to modify Clio data.",
95
+ });
96
+ }
97
+
98
+ const config = await getConfig();
99
+ const url = normalizePath(config, options.path, parseQuery(options.query));
100
+ const body = await readBody(options.bodyFile);
101
+
102
+ if (options.dryRun === true) {
103
+ console.log(JSON.stringify(buildDryRunOutput(method, url, body), null, options.compact ? 0 : 2));
104
+ return;
105
+ }
106
+
107
+ const tokenSet = await getTokenSet();
108
+ const accessToken = await getValidAccessToken(config, tokenSet);
109
+ const response = await fetch(url, {
110
+ method,
111
+ headers: {
112
+ accept: "application/json",
113
+ authorization: `Bearer ${accessToken}`,
114
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
115
+ },
116
+ body: body === undefined ? undefined : JSON.stringify(body),
117
+ });
118
+ const text = method === "HEAD" ? "" : await response.text();
119
+ let payload = text;
120
+
121
+ if (text) {
122
+ try {
123
+ payload = JSON.parse(text);
124
+ } catch (_error) {
125
+ payload = { body: text };
126
+ }
127
+ }
128
+
129
+ const output = {
130
+ status: response.status,
131
+ ok: response.ok,
132
+ data: maybeRedactPayload(payload, options, "request"),
133
+ };
134
+
135
+ console.log(JSON.stringify(output, null, options.compact ? 0 : 2));
136
+ }
137
+
138
+ module.exports = {
139
+ rawRequest,
140
+ __private: {
141
+ buildDryRunOutput,
142
+ parseQuery,
143
+ },
144
+ };
@@ -0,0 +1,89 @@
1
+ const COMPACT_KEYS = new Set([
2
+ "id",
3
+ "data",
4
+ "name",
5
+ "first_name",
6
+ "last_name",
7
+ "email",
8
+ "type",
9
+ "status",
10
+ "state",
11
+ "priority",
12
+ "date",
13
+ "due_at",
14
+ "start_at",
15
+ "end_at",
16
+ "created_at",
17
+ "updated_at",
18
+ "display_number",
19
+ "number",
20
+ "subject",
21
+ "summary",
22
+ "description",
23
+ "balance",
24
+ "total",
25
+ "paid",
26
+ "pending",
27
+ "matter",
28
+ "client",
29
+ "contact",
30
+ "user",
31
+ "assignee",
32
+ "assigner",
33
+ "responsible_attorney",
34
+ "responsible_staff",
35
+ "originating_attorney",
36
+ ]);
37
+
38
+ const NESTED_KEYS = new Set([
39
+ "id",
40
+ "name",
41
+ "first_name",
42
+ "last_name",
43
+ "email",
44
+ "type",
45
+ "status",
46
+ "state",
47
+ "display_number",
48
+ "number",
49
+ "description",
50
+ ]);
51
+
52
+ function isPlainObject(value) {
53
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
54
+ }
55
+
56
+ function compactObject(object, allowedKeys) {
57
+ return Object.entries(object).reduce((output, [key, value]) => {
58
+ if (!allowedKeys.has(key)) {
59
+ return output;
60
+ }
61
+
62
+ output[key] = compactValue(value, key === "data" ? COMPACT_KEYS : NESTED_KEYS);
63
+ return output;
64
+ }, {});
65
+ }
66
+
67
+ function compactValue(value, allowedKeys = COMPACT_KEYS) {
68
+ if (Array.isArray(value)) {
69
+ return value.map((item) => compactValue(item, allowedKeys));
70
+ }
71
+
72
+ if (!isPlainObject(value)) {
73
+ return value;
74
+ }
75
+
76
+ const compacted = compactObject(value, allowedKeys);
77
+ return Object.keys(compacted).length > 0 ? compacted : value;
78
+ }
79
+
80
+ function maybeCompactPayload(payload, options = {}) {
81
+ return options.compact ? compactValue(payload) : payload;
82
+ }
83
+
84
+ module.exports = {
85
+ maybeCompactPayload,
86
+ __private: {
87
+ compactValue,
88
+ },
89
+ };
package/src/prompt.js CHANGED
@@ -2,6 +2,10 @@ const { Writable } = require("node:stream");
2
2
  const { createInterface } = require("node:readline/promises");
3
3
  const { stdin, stdout } = require("node:process");
4
4
 
5
+ const isTTYColor = stdout.isTTY && !process.env.NO_COLOR;
6
+ const bold = (text) => (isTTYColor ? `\x1b[1m${text}\x1b[22m` : text);
7
+ const dim = (text) => (isTTYColor ? `\x1b[2m${text}\x1b[22m` : text);
8
+
5
9
  class PromptOutput extends Writable {
6
10
  constructor(target) {
7
11
  super();
@@ -94,10 +98,54 @@ async function askSecret(rl, label) {
94
98
  }
95
99
  }
96
100
 
101
+ async function selectOption(_rl, label, options, defaultIndex = 0) {
102
+ if (!Array.isArray(options) || options.length === 0) {
103
+ throw new Error(`No options available for ${label}.`);
104
+ }
105
+
106
+ if (!stdin.isTTY) {
107
+ return options[defaultIndex].value;
108
+ }
109
+
110
+ console.log(bold(label));
111
+ options.forEach((option, index) => {
112
+ const marker = index === defaultIndex ? "*" : " ";
113
+ stdout.write(` ${marker} ${index + 1}. ${option.label}\n`);
114
+ });
115
+
116
+ const fallback = String(defaultIndex + 1);
117
+
118
+ while (true) {
119
+ const answer = String(await ask(_rl, "Choose an option", fallback)).trim();
120
+ const numericIndex = Number.parseInt(answer, 10);
121
+
122
+ if (Number.isInteger(numericIndex) && numericIndex >= 1 && numericIndex <= options.length) {
123
+ return options[numericIndex - 1].value;
124
+ }
125
+
126
+ const normalized = answer.toLowerCase();
127
+ const matchingOption = options.find((option) => {
128
+ return (
129
+ String(option.value).trim().toLowerCase() === normalized ||
130
+ String(option.label).trim().toLowerCase() === normalized
131
+ );
132
+ });
133
+
134
+ if (matchingOption) {
135
+ return matchingOption.value;
136
+ }
137
+
138
+ console.log(`Enter a number from 1 to ${options.length}, or one of the listed values.`);
139
+ }
140
+ }
141
+
97
142
  module.exports = {
98
143
  PromptOutput,
99
144
  ask,
100
145
  askSecret,
146
+ bold,
101
147
  decodePromptChunk,
148
+ dim,
149
+ selectOption,
102
150
  withPrompt,
103
151
  };
package/src/redaction.js CHANGED
@@ -71,6 +71,7 @@ const NAME_HEURISTIC_EXCLUDED_TOKENS = new Set([
71
71
  ]);
72
72
 
73
73
  const PLACEHOLDERS = {
74
+ creditCard: "[REDACTED_CREDIT_CARD]",
74
75
  email: "[REDACTED_EMAIL]",
75
76
  name: "[REDACTED_NAME]",
76
77
  phone: "[REDACTED_PHONE]",
@@ -78,6 +79,11 @@ const PLACEHOLDERS = {
78
79
  taxId: "[REDACTED_TAX_ID]",
79
80
  };
80
81
  const PERSON_NAME_SUFFIXES = new Set(["esq", "ii", "iii", "iv", "jr", "sr"]);
82
+ const COMPANY_NOISE_TOKENS = new Set([
83
+ "and", "co", "company", "corp", "corporation", "dba", "group",
84
+ "inc", "incorporated", "limited", "llc", "llp", "lp", "ltd",
85
+ "of", "pa", "pc", "plc", "pllc", "the",
86
+ ]);
81
87
 
82
88
  function escapeRegExp(value) {
83
89
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -116,13 +122,19 @@ function collectContactLikeReplacements(node, replacements, dedupe) {
116
122
 
117
123
  pushReplacement(replacements, dedupe, node.name, PLACEHOLDERS.name);
118
124
 
119
- const fullName = [node.first_name, node.last_name]
120
- .map((value) => normalizeString(value))
121
- .filter(Boolean)
122
- .join(" ")
123
- .trim();
125
+ const firstName = normalizeString(node.first_name);
126
+ const lastName = normalizeString(node.last_name);
127
+
128
+ const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
124
129
  pushReplacement(replacements, dedupe, fullName, PLACEHOLDERS.name);
125
130
 
131
+ if (lastName && lastName.length >= 2) {
132
+ pushReplacement(replacements, dedupe, lastName, PLACEHOLDERS.name);
133
+ }
134
+ if (firstName && firstName.length >= 2) {
135
+ pushReplacement(replacements, dedupe, firstName, PLACEHOLDERS.name);
136
+ }
137
+
126
138
  EMAIL_FIELDS.forEach((field) => {
127
139
  pushReplacement(replacements, dedupe, node[field], PLACEHOLDERS.email);
128
140
  });
@@ -220,11 +232,15 @@ function collectSafeIdentityNames(
220
232
  return preserved;
221
233
  }
222
234
 
223
- function derivePersonSurname(name) {
224
- const tokens = normalizeString(name)
225
- .split(/\s+/)
235
+ function tokenizeName(name, separator = /\s+/) {
236
+ return normalizeString(name)
237
+ .split(separator)
226
238
  .map((token) => token.replace(/^[^A-Za-z]+|[^A-Za-z]+$/g, ""))
227
239
  .filter(Boolean);
240
+ }
241
+
242
+ function derivePersonSurname(name) {
243
+ const tokens = tokenizeName(name);
228
244
 
229
245
  if (tokens.length < 2) {
230
246
  return "";
@@ -238,7 +254,15 @@ function derivePersonSurname(name) {
238
254
  return index > 0 ? tokens[index] : "";
239
255
  }
240
256
 
241
- function collectPersonClientLabelReplacements(
257
+ function deriveCompanyNameTokens(name) {
258
+ return tokenizeName(name, /[\s&,]+/).filter(
259
+ (token) =>
260
+ token.length >= 3 &&
261
+ !COMPANY_NOISE_TOKENS.has(token.toLowerCase())
262
+ );
263
+ }
264
+
265
+ function collectClientLabelReplacements(
242
266
  value,
243
267
  policy,
244
268
  clientContext = false,
@@ -247,7 +271,7 @@ function collectPersonClientLabelReplacements(
247
271
  ) {
248
272
  if (Array.isArray(value)) {
249
273
  value.forEach((item) => {
250
- collectPersonClientLabelReplacements(
274
+ collectClientLabelReplacements(
251
275
  item,
252
276
  policy,
253
277
  clientContext,
@@ -271,8 +295,14 @@ function collectPersonClientLabelReplacements(
271
295
  );
272
296
  }
273
297
 
298
+ if (clientContext && value.type === "Company") {
299
+ deriveCompanyNameTokens(value.name).forEach((token) => {
300
+ pushReplacement(replacements, dedupe, token, PLACEHOLDERS.name);
301
+ });
302
+ }
303
+
274
304
  Object.entries(value).forEach(([key, child]) => {
275
- collectPersonClientLabelReplacements(
305
+ collectClientLabelReplacements(
276
306
  child,
277
307
  policy,
278
308
  clientContext || policy.clientObjectKeys.has(key),
@@ -284,14 +314,19 @@ function collectPersonClientLabelReplacements(
284
314
  return replacements;
285
315
  }
286
316
 
287
- function replaceKnownSensitiveValues(text, replacements) {
317
+ function applyReplacements(text, replacements, wordBoundary = false) {
288
318
  return replacements
289
319
  .slice()
290
320
  .sort((left, right) => right.value.length - left.value.length)
291
321
  .reduce((output, replacement) => {
292
- const matcher = new RegExp(escapeRegExp(replacement.value), "gi");
293
- return output.replace(matcher, replacement.placeholder);
294
- }, text);
322
+ const escaped = escapeRegExp(replacement.value);
323
+ const pattern = wordBoundary ? `\\b${escaped}\\b` : escaped;
324
+ return output.replace(new RegExp(pattern, "gi"), replacement.placeholder);
325
+ }, String(text));
326
+ }
327
+
328
+ function replaceKnownSensitiveValues(text, replacements) {
329
+ return applyReplacements(text, replacements);
295
330
  }
296
331
 
297
332
  function redactPatternPii(text) {
@@ -304,8 +339,16 @@ function redactPatternPii(text) {
304
339
  /\b(?:\+?1[-.\s]*)?(?:\(\d{3}\)|\d{3})[-.\s]*\d{3}[-.\s]*\d{4}\b/g,
305
340
  PLACEHOLDERS.phone
306
341
  );
307
- output = output.replace(/\b\d{3}-\d{2}-\d{4}\b/g, PLACEHOLDERS.ssn);
342
+ output = output.replace(/\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/g, PLACEHOLDERS.ssn);
308
343
  output = output.replace(/\b\d{2}-\d{7}\b/g, PLACEHOLDERS.taxId);
344
+ output = output.replace(
345
+ /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g,
346
+ PLACEHOLDERS.creditCard
347
+ );
348
+ output = output.replace(
349
+ /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/g,
350
+ PLACEHOLDERS.creditCard
351
+ );
309
352
 
310
353
  return output;
311
354
  }
@@ -387,13 +430,7 @@ function redactLikelyBareNames(text, preservedNames) {
387
430
  }
388
431
 
389
432
  function replaceMatterLabelDerivedNames(text, replacements) {
390
- return replacements
391
- .slice()
392
- .sort((left, right) => right.value.length - left.value.length)
393
- .reduce((output, replacement) => {
394
- const matcher = new RegExp(`\\b${escapeRegExp(replacement.value)}\\b`, "gi");
395
- return output.replace(matcher, replacement.placeholder);
396
- }, String(text));
433
+ return applyReplacements(text, replacements, true);
397
434
  }
398
435
 
399
436
  function isMatterLabelContext(policy, path, key) {
@@ -516,7 +553,7 @@ function redactValue(
516
553
  function redactPayload(value, resourceType) {
517
554
  const policy = getRedactionPolicy(resourceType);
518
555
  const replacements = collectSensitiveReplacements(value, resourceType);
519
- const derivedLabelReplacements = collectPersonClientLabelReplacements(value, policy);
556
+ const derivedLabelReplacements = collectClientLabelReplacements(value, policy);
520
557
  const preservedNames = collectSafeIdentityNames(
521
558
  value,
522
559
  policy,
@@ -557,7 +594,7 @@ module.exports = {
557
594
  maybeRedactData,
558
595
  maybeRedactPayload,
559
596
  __private: {
560
- collectPersonClientLabelReplacements,
597
+ collectClientLabelReplacements,
561
598
  collectSafeIdentityNames,
562
599
  derivePersonSurname,
563
600
  redactLikelyBareNames,