@dszp/csl-screen 0.1.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.
@@ -0,0 +1,66 @@
1
+ /** Legal-form suffixes and filler words that carry no identity for name matching. */
2
+ export const DEFAULT_STOP_WORDS = [
3
+ 'llc', 'inc', 'incorporated', 'co', 'corp', 'corporation', 'company',
4
+ 'ltd', 'limited', 'plc', 'pc', 'pllc', 'lp', 'llp', 'the', 'and', 'of',
5
+ 'sa', 'ag', 'gmbh', 'srl', 'bv', 'nv', 'oao', 'ooo', 'zao', 'jsc', 'pjsc',
6
+ ];
7
+ /** Latin letters with no NFKD decomposition, and their ASCII spelling. */
8
+ const LATIN_FOLD = {
9
+ 'ß': 'ss', 'ẞ': 'SS', 'æ': 'ae', 'Æ': 'AE', 'œ': 'oe', 'Œ': 'OE', 'ø': 'o', 'Ø': 'O',
10
+ 'ł': 'l', 'Ł': 'L', 'đ': 'd', 'Đ': 'D', 'ð': 'd', 'Ð': 'D', 'þ': 'th', 'Þ': 'TH', 'ı': 'i',
11
+ };
12
+ const LATIN_FOLD_RE = new RegExp(`[${Object.keys(LATIN_FOLD).join('')}]`, 'g');
13
+ /**
14
+ * NFKD, every combining mark (\p{M}) and format character (\p{Cf}) deleted, then the Latin fold:
15
+ * the form every name is compared in. Deleting rather than separating means an invisible character
16
+ * typed inside a word (a zero-width space, soft hyphen, bidi mark or variation selector in "Ros\u200Bneft")
17
+ * leaves the word whole instead of splitting it into fragments that match nothing.
18
+ */
19
+ function foldLatin(name) {
20
+ return name
21
+ .normalize('NFKD')
22
+ .replace(/[\p{M}\p{Cf}]/gu, '')
23
+ .replace(LATIN_FOLD_RE, (c) => LATIN_FOLD[c]);
24
+ }
25
+ /**
26
+ * True when, after the fold, the name still has a letter outside A-Z/a-z, or a control (other than tab,
27
+ * LF and CR), private-use or unassigned character. Such a name cannot be tokenised safely: a look-alike
28
+ * letter from another script (Cyrillic о in "Rоsneft") or an unreadable character would split the word
29
+ * and hide a listed entity, so the caller must screen it by hand.
30
+ */
31
+ export function hasUnreadableCharacters(name) {
32
+ return /[^\P{L}A-Za-z]|[^\P{Cc}\t\n\r]|[\p{Co}\p{Cn}]/u.test(foldLatin(name));
33
+ }
34
+ /**
35
+ * Lowercase ASCII tokens of a name: diacritics stripped, `&` read as "and",
36
+ * everything outside [a-z0-9] treated as a separator, runs of single-character
37
+ * tokens joined ("L.L.C." is `llc`, "D. K. Smith" is `dk smith`), stop words dropped.
38
+ * Letters outside the Latin alphabet and other unreadable characters act as separators; see `hasUnreadableCharacters`.
39
+ */
40
+ export function nameTokens(name, stopWords) {
41
+ const raw = foldLatin(name)
42
+ .toLowerCase()
43
+ .replace(/&/g, ' and ')
44
+ .replace(/[^a-z0-9]+/g, ' ')
45
+ .split(' ')
46
+ .filter((t) => t !== '');
47
+ // Join before dropping stop words, so initials ("L.L.C.") become the word they spell.
48
+ const joined = [];
49
+ let run = '';
50
+ for (const t of raw) {
51
+ if (t.length === 1)
52
+ run += t;
53
+ else {
54
+ if (run)
55
+ joined.push(run);
56
+ run = '';
57
+ joined.push(t);
58
+ }
59
+ }
60
+ if (run)
61
+ joined.push(run);
62
+ return joined.filter((t) => !stopWords.has(t));
63
+ }
64
+ export function normalizeName(name, stopWords) {
65
+ return nameTokens(name, stopWords).join(' ');
66
+ }
@@ -0,0 +1,12 @@
1
+ import type { ScreenResult } from './model.js';
2
+ export declare function toCsv(result: ScreenResult, opts: {
3
+ runDate: string;
4
+ }): string;
5
+ export declare function toMarkdown(result: ScreenResult, opts: {
6
+ runDate: string;
7
+ title?: string;
8
+ expected?: number;
9
+ sourceRecords?: number;
10
+ /** Caller lines (for example where the parties and the list came from), printed as list items under the title. */
11
+ preamble?: readonly string[];
12
+ }): string;
package/dist/render.js ADDED
@@ -0,0 +1,93 @@
1
+ import { partyOutcome } from './decisions.js';
2
+ const COLUMNS = [
3
+ 'party_id', 'party_name', 'source', 'result', 'matched_name', 'list', 'entry_key', 'rule', 'score',
4
+ 'reason', 'entry_countries', 'party_country', 'decision', 'decided_by', 'decided_at', 'run_date',
5
+ ];
6
+ /** A leading = + - @ (or tab/CR/LF) makes a spreadsheet read the cell as a formula; a leading apostrophe stops that. */
7
+ function guardFormula(s) {
8
+ return /^[=+\-@\t\r\n]/.test(s) ? `'${s}` : s;
9
+ }
10
+ /** Guard first, then RFC 4180 quoting, so the apostrophe sits inside the quotes. */
11
+ function csvCell(s) {
12
+ const g = guardFormula(s);
13
+ return /[",\r\n]/.test(g) ? `"${g.replace(/"/g, '""')}"` : g;
14
+ }
15
+ export function toCsv(result, opts) {
16
+ const lines = [COLUMNS.join(',')];
17
+ const row = (cells) => lines.push(cells.map(csvCell).join(','));
18
+ for (const r of result.parties) {
19
+ const outcome = partyOutcome(r);
20
+ const head = [r.party.id, r.party.name, r.party.source, outcome];
21
+ if (r.status !== 'flagged') {
22
+ row([...head, '', '', '', '', '', '', '', '', '', '', opts.runDate]);
23
+ continue;
24
+ }
25
+ for (const m of r.matches) {
26
+ row([
27
+ ...head, m.matchedName, m.listSource, m.entryKey, m.rule, m.score.toFixed(2), m.reason,
28
+ m.entryCountries.join(';'), r.party.country ?? '',
29
+ m.decision?.decision ?? '', m.decision?.reviewer ?? '', m.decision?.decidedAt ?? '', opts.runDate,
30
+ ]);
31
+ }
32
+ }
33
+ return lines.join('\r\n') + '\r\n';
34
+ }
35
+ /**
36
+ * Names come from client records and the public list, so they are untrusted. Line breaks become spaces
37
+ * (a table row or list item must stay on one line), then every character Markdown or inline HTML could
38
+ * act on is backslash-escaped. `&` is included so an entity such as `<` stays literal text.
39
+ */
40
+ function md(s) {
41
+ return s
42
+ .replace(/\r\n|\r|\n/g, ' ')
43
+ .replace(/[\\|`*_~<>[\]&]/g, '\\$&')
44
+ .replace(/^(\s*)#/, '$1\\#');
45
+ }
46
+ const UNSCREENABLE_REASON = {
47
+ 'empty-name': 'name is empty after normalisation',
48
+ 'unreadable-characters': 'name contains non-Latin or unreadable characters; screen it by hand',
49
+ };
50
+ const list = (xs) => (xs.length ? md(xs.join(', ')) : 'none');
51
+ function matchLine(r, m) {
52
+ const p = r.party;
53
+ const d = m.decision;
54
+ const decision = d ? `${d.decision} by ${md(d.reviewer)} at ${md(d.decidedAt)}: ${md(d.reason)}` : 'none';
55
+ const partyCountry = p.country ? md(p.country) : 'not provided';
56
+ const mismatch = m.countryMismatch ? ' (country mismatch)' : '';
57
+ return [
58
+ `- **${md(p.name)}** (id ${md(p.id)}, source ${md(p.source)}) matched **${md(m.matchedName)}**`,
59
+ ` - List: ${md(m.listSource)}; programs: ${list(m.programs)}; entry type: ${m.entryType ? md(m.entryType) : 'unknown'}`,
60
+ ` - Countries: entry ${list(m.entryCountries)}; party ${partyCountry}${mismatch}`,
61
+ ` - Rule: ${m.rule}, score ${m.score.toFixed(2)}; reason: ${md(m.reason)}`,
62
+ ` - Decision: ${decision}`,
63
+ ].join('\n');
64
+ }
65
+ export function toMarkdown(result, opts) {
66
+ const { counts, snapshot: s, matcher } = result;
67
+ const out = [];
68
+ if (opts.expected !== undefined && opts.expected !== counts.parties) {
69
+ out.push(`INCOMPLETE: ${counts.parties} parties in run of expected ${opts.expected}`, '');
70
+ }
71
+ out.push(`# ${md(opts.title || 'Restricted-party screening report')}`, '');
72
+ if (opts.preamble?.length)
73
+ out.push(...opts.preamble.map((l) => `- ${md(l)}`), '');
74
+ out.push(`Run date: ${md(opts.runDate)}`, '');
75
+ const excluded = opts.sourceRecords !== undefined ? opts.sourceRecords - counts.parties : 0;
76
+ out.push(...(opts.sourceRecords !== undefined ? [`- Source records: ${opts.sourceRecords}`] : []), opts.expected !== undefined
77
+ ? `- Parties in run: ${counts.parties} of ${opts.expected} expected`
78
+ : `- Parties in run: ${counts.parties}`, ...(excluded > 0 ? [`- Excluded by scope: ${excluded}`] : []), `- Screened: ${counts.screened}`, `- Clear: ${counts.clear}`, `- Flagged: ${counts.flagged}`, `- Unscreenable: ${counts.unscreenable}`, '', '## Consolidated Screening List snapshot', '', `- Search performed at: ${md(s.searchPerformedAt || 'unknown')}`, `- Entries: ${s.entryCount}`, `- SHA-256: ${s.sha256}`, '- Sources:', ...s.sources.map((x) => ` - ${md(x.source)}: last updated ${md(x.sourceLastUpdated ?? 'unknown')}`), '', '## Matcher', '', `- Version ${md(matcher.version)}`, `- Threshold: ${matcher.threshold}`, '');
79
+ const flagged = result.parties.filter((r) => r.status === 'flagged');
80
+ const section = (title, state) => {
81
+ const lines = flagged.flatMap((r) => r.matches.filter((m) => m.state === state).map((m) => matchLine(r, m)));
82
+ if (lines.length)
83
+ out.push(`## ${title}`, '', ...lines, '');
84
+ };
85
+ section('New flags', 'new');
86
+ section('Escalated (open)', 'escalated');
87
+ const unscreenable = result.parties.filter((r) => r.status === 'unscreenable');
88
+ if (unscreenable.length) {
89
+ out.push('## Unscreenable parties', '', ...unscreenable.map((r) => `- ${r.party.name.trim() ? md(r.party.name) : '(blank name)'} (id ${md(r.party.id)}, source ${md(r.party.source)}): ${UNSCREENABLE_REASON[r.reason]}`), '');
90
+ }
91
+ section('Cleared (still matching)', 'cleared');
92
+ return out.join('\n');
93
+ }
@@ -0,0 +1,11 @@
1
+ import type { Party, ScreenOptions, ScreenResult } from './model.js';
2
+ import type { Snapshot } from './snapshot.js';
3
+ /**
4
+ * One-word listed entity names (after stop words are dropped) too generic to flag anywhere but a party
5
+ * name's first token. Each was measured on real customer names: as a later token it flagged only
6
+ * unrelated organisations using it as an ordinary word.
7
+ * A word joins this list only from such a measurement, because each one is a name that can be hidden
8
+ * behind another word.
9
+ */
10
+ export declare const DEFAULT_GENERIC_TOKENS: readonly string[];
11
+ export declare function screen(parties: Party[], snapshot: Snapshot, options?: ScreenOptions): ScreenResult;
package/dist/screen.js ADDED
@@ -0,0 +1,181 @@
1
+ import { jaroWinkler } from './jaroWinkler.js';
2
+ import { MATCHER_VERSION } from './model.js';
3
+ import { DEFAULT_STOP_WORDS, hasUnreadableCharacters, nameTokens } from './normalize.js';
4
+ const RULE_RANK = { exact: 3, contains: 2, within: 1, similar: 0 };
5
+ /** A one-token listed name on these entry types is too generic (a ship, a nickname) to match inside a longer party name. */
6
+ const NON_ENTITY_TYPES = new Set(['Individual', 'Vessel', 'Aircraft']);
7
+ /**
8
+ * One-word listed entity names (after stop words are dropped) too generic to flag anywhere but a party
9
+ * name's first token. Each was measured on real customer names: as a later token it flagged only
10
+ * unrelated organisations using it as an ordinary word.
11
+ * A word joins this list only from such a measurement, because each one is a name that can be hidden
12
+ * behind another word.
13
+ */
14
+ export const DEFAULT_GENERIC_TOKENS = ['center', 'focus', 'technology', 'united'];
15
+ /** Longest run of adjacent party tokens joined when looking for a listed word split by separators. */
16
+ const MAX_RUN = 3;
17
+ const spaceless = (key) => key.replace(/ /g, '');
18
+ const push = (m, k, v) => {
19
+ const bucket = m.get(k);
20
+ if (bucket)
21
+ bucket.push(v);
22
+ else
23
+ m.set(k, [v]);
24
+ };
25
+ export function screen(parties, snapshot, options = {}) {
26
+ const threshold = options.threshold ?? 0.9;
27
+ const minTokenLength = options.minTokenLength ?? 3;
28
+ if (!(typeof threshold === 'number' && threshold > 0 && threshold <= 1)) {
29
+ throw new TypeError(`threshold must be a number in (0, 1], got ${String(threshold)}`);
30
+ }
31
+ if (!Number.isInteger(minTokenLength) || minTokenLength < 1) {
32
+ throw new TypeError(`minTokenLength must be an integer >= 1, got ${String(minTokenLength)}`);
33
+ }
34
+ const withinMinLength = Math.max(4, minTokenLength);
35
+ // Caller stop words go through the same normalisation as names, so 'Inc' and 'L.L.C.' behave.
36
+ const noStops = new Set();
37
+ const stopWords = new Set((options.stopWords ?? DEFAULT_STOP_WORDS).flatMap((w) => nameTokens(w, noStops)));
38
+ const genericTokens = new Set((options.genericTokens ?? DEFAULT_GENERIC_TOKENS).flatMap((w) => nameTokens(w, noStops)));
39
+ const seenIds = new Set();
40
+ for (const party of parties) {
41
+ if (seenIds.has(party.id))
42
+ throw new Error(`Duplicate party id: ${party.id}`);
43
+ seenIds.add(party.id);
44
+ }
45
+ const index = new Map();
46
+ const bySpaceless = new Map();
47
+ let longestSpaceless = 0;
48
+ for (const entry of snapshot.entries) {
49
+ for (const name of [entry.name, ...entry.altNames]) {
50
+ const tokens = nameTokens(name, stopWords);
51
+ if (tokens.length === 0)
52
+ continue;
53
+ const indexed = { entry, name, key: tokens.join(' '), tokens: new Set(tokens), indexable: tokens.some((t) => t.length >= minTokenLength) };
54
+ push(bySpaceless, spaceless(indexed.key), indexed);
55
+ longestSpaceless = Math.max(longestSpaceless, spaceless(indexed.key).length);
56
+ for (const token of indexed.tokens)
57
+ if (token.length >= minTokenLength)
58
+ push(index, token, indexed);
59
+ }
60
+ }
61
+ const results = parties.map((party) => {
62
+ const tokens = nameTokens(party.name, stopWords);
63
+ const partyNameKey = tokens.join(' ');
64
+ const ptoks = new Set(tokens);
65
+ // A non-Latin letter or unreadable character would split a word into fragments (or vanish), so a listed name could be missed.
66
+ if (hasUnreadableCharacters(party.name))
67
+ return { party, partyNameKey, status: 'unscreenable', reason: 'unreadable-characters' };
68
+ // An empty key cannot be screened; it must not read as clear.
69
+ if (partyNameKey === '')
70
+ return { party, partyNameKey, status: 'unscreenable', reason: 'empty-name' };
71
+ // `within` needs a party token long enough to be distinctive: at least 4, and never shorter than an indexed token.
72
+ const hasLongToken = tokens.some((t) => t.length >= withinMinLength);
73
+ // Each party token, and each run of two or three adjacent tokens joined without spaces, with the position
74
+ // it starts at. A run catches a separator inserted inside a listed word ("Ros-neft Trading" holds
75
+ // "rosneft"). At most MAX_RUN units per position keeps the work linear in the party name's length; a run
76
+ // longer than any listed name cannot match and is not looked up.
77
+ const units = new Map();
78
+ for (let i = 0; i < tokens.length; i++) {
79
+ let run = '';
80
+ for (let j = i; j < tokens.length && j - i < MAX_RUN; j++) {
81
+ run += tokens[j];
82
+ if (run.length > longestSpaceless && j > i)
83
+ break;
84
+ if (!units.has(run))
85
+ units.set(run, i);
86
+ }
87
+ }
88
+ const candidates = new Set();
89
+ for (const u of units.keys()) {
90
+ if (u.length >= minTokenLength)
91
+ for (const c of index.get(u) ?? [])
92
+ candidates.add(c);
93
+ for (const c of bySpaceless.get(u) ?? [])
94
+ candidates.add(c);
95
+ }
96
+ for (const c of bySpaceless.get(spaceless(partyNameKey)) ?? [])
97
+ candidates.add(c);
98
+ const best = new Map();
99
+ for (const cand of candidates) {
100
+ let shared = 0;
101
+ for (const t of cand.tokens)
102
+ if (ptoks.has(t))
103
+ shared++;
104
+ const cover = shared / Math.max(ptoks.size, cand.tokens.size);
105
+ const score = Math.min(jaroWinkler(partyNameKey, cand.key), 0.5 + cover / 2);
106
+ // Contained when every listed token is a party token, or a run of them, or the whole listed name is one run.
107
+ const candSpaceless = spaceless(cand.key);
108
+ const inUnits = [...cand.tokens].every((t) => units.has(t)) || units.has(candSpaceless);
109
+ // A one-token name on an entity counts in any position, except a generic word, which counts only first.
110
+ const at = units.get(candSpaceless);
111
+ const containedAlone = cand.tokens.size > 1 ||
112
+ (!NON_ENTITY_TYPES.has(cand.entry.type ?? '') && at !== undefined && (at === 0 || !genericTokens.has(candSpaceless)));
113
+ let rule = null;
114
+ let reason = null;
115
+ if (partyNameKey === cand.key)
116
+ rule = 'exact';
117
+ else if (spaceless(partyNameKey) === spaceless(cand.key)) {
118
+ rule = 'exact';
119
+ reason = 'exact ignoring spaces';
120
+ }
121
+ else if (!cand.indexable)
122
+ continue;
123
+ else if (shared === cand.tokens.size && containedAlone)
124
+ rule = 'contains';
125
+ else if (inUnits && containedAlone) {
126
+ rule = 'contains';
127
+ reason = 'contains listed name ignoring spaces';
128
+ }
129
+ else if (shared === ptoks.size && hasLongToken)
130
+ rule = 'within';
131
+ else if (score >= threshold)
132
+ rule = 'similar';
133
+ if (rule === null)
134
+ continue;
135
+ const cur = best.get(cand.entry.key);
136
+ if (!cur || RULE_RANK[rule] > RULE_RANK[cur.rule] || (rule === cur.rule && score > cur.score)) {
137
+ best.set(cand.entry.key, { rule, score, name: cand, reason });
138
+ }
139
+ }
140
+ if (best.size === 0)
141
+ return { party, partyNameKey, status: 'clear' };
142
+ const country = party.country?.toUpperCase();
143
+ const matches = [...best.values()].map(({ rule, score, name, reason }) => ({
144
+ entryKey: name.entry.key,
145
+ entryId: name.entry.id,
146
+ entryFingerprint: name.entry.fingerprint,
147
+ matchedName: name.name,
148
+ listSource: name.entry.source,
149
+ programs: name.entry.programs,
150
+ entryType: name.entry.type,
151
+ entryCountries: name.entry.countries,
152
+ countryMismatch: !!country && name.entry.countries.length > 0 && !name.entry.countries.includes(country),
153
+ rule,
154
+ // An exact rule is a full match whatever the token-cover heuristic says (spaceless matches cover fewer tokens).
155
+ score: rule === 'exact' ? 1 : score,
156
+ reason: reason ??
157
+ (rule === 'exact' ? 'exact after normalisation'
158
+ : rule === 'contains' ? 'contains listed name'
159
+ : rule === 'within' ? 'listed name contains party name'
160
+ : `similar ${score.toFixed(2)}`),
161
+ state: 'new',
162
+ }));
163
+ matches.sort((a, b) => RULE_RANK[b.rule] - RULE_RANK[a.rule] || b.score - a.score || (a.entryKey < b.entryKey ? -1 : a.entryKey > b.entryKey ? 1 : 0));
164
+ return { party, partyNameKey, status: 'flagged', matches };
165
+ });
166
+ const count = (s) => results.filter((r) => r.status === s).length;
167
+ const unscreenable = count('unscreenable');
168
+ return {
169
+ check: 'csl',
170
+ matcher: { version: MATCHER_VERSION, threshold, minTokenLength, stopWords: [...stopWords].sort(), genericTokens: [...genericTokens].sort() },
171
+ snapshot: snapshot.identity,
172
+ counts: {
173
+ parties: results.length,
174
+ screened: results.length - unscreenable,
175
+ unscreenable,
176
+ clear: count('clear'),
177
+ flagged: count('flagged'),
178
+ },
179
+ parties: results,
180
+ };
181
+ }
@@ -0,0 +1,59 @@
1
+ export interface SnapshotIdentity {
2
+ searchPerformedAt: string;
3
+ sources: {
4
+ source: string;
5
+ sourceLastUpdated: string | null;
6
+ lastImported: string | null;
7
+ }[];
8
+ entryCount: number;
9
+ /** Entries per CSL `source` (list name), keys sorted. Feeds the next run's `previousEntriesBySource`. */
10
+ entriesBySource: Record<string, number>;
11
+ /** SHA-256 of the downloaded bytes. Changes on every republish, even when no entry changed. */
12
+ sha256: string;
13
+ /** Hash of the entries alone (see `contentHashOf`). Unchanged by `search_performed_at` and per-list `last_imported`. */
14
+ contentSha256: string;
15
+ }
16
+ export interface SlimEntry {
17
+ /** `${source}|${id}`. The CSL `id` alone is not unique: an entry on two lists shares it. */
18
+ key: string;
19
+ id: string;
20
+ name: string;
21
+ altNames: string[];
22
+ source: string;
23
+ type: string | null;
24
+ countries: string[];
25
+ programs: string[];
26
+ sourceListUrl: string | null;
27
+ /** SHA-256 of the canonical JSON of the whole raw entry; changes when any field changes. */
28
+ fingerprint: string;
29
+ }
30
+ export interface Snapshot {
31
+ identity: SnapshotIdentity;
32
+ entries: SlimEntry[];
33
+ }
34
+ export declare class CslSnapshotError extends Error {
35
+ kind: 'parse' | 'shape' | 'total-mismatch' | 'sharp-drop' | 'source-drop';
36
+ constructor(kind: 'parse' | 'shape' | 'total-mismatch' | 'sharp-drop' | 'source-drop', message: string);
37
+ }
38
+ export interface LoadSnapshotOptions {
39
+ /** Entry count of the snapshot being replaced; enables the sharp-drop check. */
40
+ previousEntryCount?: number;
41
+ /** Largest tolerated fractional shrink against previousEntryCount. Default 0.1. */
42
+ maxDropRatio?: number;
43
+ /**
44
+ * `entriesBySource` of the snapshot being replaced; enables the source-drop check. A source in this map that is
45
+ * missing from the new file, or has fewer than half its previous entries, fails the load. Catches the loss of
46
+ * one small list, which the whole-file sharp-drop check cannot see.
47
+ */
48
+ previousEntriesBySource?: Record<string, number>;
49
+ }
50
+ /**
51
+ * SHA-256 hex over the lines `${key}\t${fingerprint}\n`, sorted by key in byte order (plain `<` on strings, not
52
+ * `localeCompare`, so the result does not depend on the runtime's locale), concatenated. Entry order in the file
53
+ * does not matter; any change to an entry's fingerprint, or to the set of keys, does.
54
+ */
55
+ export declare function contentHashOf(entries: readonly {
56
+ key: string;
57
+ fingerprint: string;
58
+ }[]): Promise<string>;
59
+ export declare function loadSnapshot(input: Uint8Array | ArrayBuffer | string, opts?: LoadSnapshotOptions): Promise<Snapshot>;
@@ -0,0 +1,130 @@
1
+ import { canonicalJson, sha256Hex } from './canonical.js';
2
+ export class CslSnapshotError extends Error {
3
+ kind;
4
+ constructor(kind, message) {
5
+ super(message);
6
+ this.kind = kind;
7
+ this.name = 'CslSnapshotError';
8
+ }
9
+ }
10
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
11
+ const strOrNull = (v) => (typeof v === 'string' ? v : null);
12
+ const MIN_SOURCE_RATIO = 0.5;
13
+ /** Null or absent is an empty list; anything else must be an array of strings, so a format change fails loudly. */
14
+ function strings(v, where) {
15
+ if (v === null || v === undefined)
16
+ return [];
17
+ if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
18
+ throw new CslSnapshotError('shape', `${where} must be an array of strings, null or absent`);
19
+ }
20
+ return v;
21
+ }
22
+ /**
23
+ * SHA-256 hex over the lines `${key}\t${fingerprint}\n`, sorted by key in byte order (plain `<` on strings, not
24
+ * `localeCompare`, so the result does not depend on the runtime's locale), concatenated. Entry order in the file
25
+ * does not matter; any change to an entry's fingerprint, or to the set of keys, does.
26
+ */
27
+ export async function contentHashOf(entries) {
28
+ const sorted = [...entries].sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
29
+ return sha256Hex(sorted.map((e) => `${e.key}\t${e.fingerprint}\n`).join(''));
30
+ }
31
+ export async function loadSnapshot(input, opts = {}) {
32
+ const bytes = typeof input === 'string' ? new TextEncoder().encode(input)
33
+ : input instanceof Uint8Array ? input
34
+ : new Uint8Array(input);
35
+ const sha256 = await sha256Hex(bytes);
36
+ let doc;
37
+ try {
38
+ doc = JSON.parse(new TextDecoder().decode(bytes));
39
+ }
40
+ catch (e) {
41
+ throw new CslSnapshotError('parse', `CSL file is not valid JSON: ${e.message}`);
42
+ }
43
+ if (!isObj(doc) || !Array.isArray(doc.results)) {
44
+ throw new CslSnapshotError('shape', 'CSL file has no "results" array');
45
+ }
46
+ const raw = doc.results;
47
+ if (raw.length === 0) {
48
+ throw new CslSnapshotError('shape', 'CSL file has an empty "results" array');
49
+ }
50
+ if (typeof doc.search_performed_at !== 'string') {
51
+ throw new CslSnapshotError('shape', 'CSL file has no "search_performed_at" string');
52
+ }
53
+ const searchPerformedAt = doc.search_performed_at;
54
+ if (typeof doc.total !== 'number' || raw.length !== doc.total) {
55
+ throw new CslSnapshotError('total-mismatch', `results has ${raw.length} entries but total is ${String(doc.total)}`);
56
+ }
57
+ const previous = opts.previousEntryCount;
58
+ const maxDrop = opts.maxDropRatio ?? 0.1;
59
+ // NaN or a ratio of 1 or more would silently disable the check.
60
+ if (!(typeof maxDrop === 'number' && maxDrop >= 0 && maxDrop < 1)) {
61
+ throw new TypeError(`maxDropRatio must be a number in [0, 1), got ${String(maxDrop)}`);
62
+ }
63
+ if (previous !== undefined && previous > 0 && raw.length < previous * (1 - maxDrop)) {
64
+ throw new CslSnapshotError('sharp-drop', `entry count fell from ${previous} to ${raw.length}, beyond the ${maxDrop} allowed drop`);
65
+ }
66
+ const entries = [];
67
+ const seenKeys = new Set();
68
+ for (const [i, r] of raw.entries()) {
69
+ if (!isObj(r) || typeof r.id !== 'string' || typeof r.name !== 'string' || typeof r.source !== 'string') {
70
+ throw new CslSnapshotError('shape', `results[${i}] lacks a string id, name or source`);
71
+ }
72
+ const countries = new Set();
73
+ const addCountry = (c) => {
74
+ if (typeof c === 'string' && c)
75
+ countries.add(c.toUpperCase());
76
+ };
77
+ addCountry(r.country);
78
+ if (Array.isArray(r.addresses))
79
+ for (const a of r.addresses)
80
+ if (isObj(a))
81
+ addCountry(a.country);
82
+ const key = `${r.source}|${r.id}`;
83
+ if (seenKeys.has(key)) {
84
+ throw new CslSnapshotError('shape', `duplicate entry key ${key} at results[${i}]`);
85
+ }
86
+ seenKeys.add(key);
87
+ entries.push({
88
+ key,
89
+ id: r.id,
90
+ name: r.name,
91
+ altNames: strings(r.alt_names, `results[${i}].alt_names`),
92
+ source: r.source,
93
+ type: strOrNull(r.type),
94
+ countries: [...countries],
95
+ programs: strings(r.programs, `results[${i}].programs`),
96
+ sourceListUrl: strOrNull(r.source_list_url),
97
+ fingerprint: await sha256Hex(canonicalJson(r)),
98
+ });
99
+ }
100
+ // Null-prototype maps: a source named "constructor" or "__proto__" is an ordinary key.
101
+ const counted = Object.create(null);
102
+ for (const e of entries)
103
+ counted[e.source] = (counted[e.source] ?? 0) + 1;
104
+ // The published identity is a plain object (callers may use its prototype methods); fromEntries still makes
105
+ // "__proto__" an own data property. Lookups below stay on the null-prototype map.
106
+ const entriesBySource = Object.fromEntries(Object.keys(counted).sort().map((k) => [k, counted[k]]));
107
+ for (const [source, before] of Object.entries(opts.previousEntriesBySource ?? {})) {
108
+ if (typeof before !== 'number' || !Number.isFinite(before) || before < 0) {
109
+ throw new TypeError(`previousEntriesBySource["${source}"] must be a non-negative number, got ${JSON.stringify(before)}`);
110
+ }
111
+ const now = counted[source] ?? 0;
112
+ if (now < before * MIN_SOURCE_RATIO) {
113
+ throw new CslSnapshotError('source-drop', `source "${source}" fell from ${before} to ${now} entries${now === 0 ? ' (missing from the file)' : ''}, below ${MIN_SOURCE_RATIO * 100}% of the previous count`);
114
+ }
115
+ }
116
+ const sourcesUsed = Array.isArray(doc.sources_used) ? doc.sources_used : [];
117
+ const identity = {
118
+ searchPerformedAt,
119
+ sources: sourcesUsed.filter(isObj).map((s) => ({
120
+ source: typeof s.source === 'string' ? s.source : '',
121
+ sourceLastUpdated: strOrNull(s.source_last_updated),
122
+ lastImported: strOrNull(s.last_imported),
123
+ })),
124
+ entryCount: entries.length,
125
+ entriesBySource,
126
+ sha256,
127
+ contentSha256: await contentHashOf(entries),
128
+ };
129
+ return { identity, entries };
130
+ }
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@dszp/csl-screen",
3
+ "version": "0.1.0",
4
+ "description": "Restricted-party screening against the US Consolidated Screening List: name normalisation, fuzzy matching and reviewable decisions. Zero runtime dependencies; runs in Node and Cloudflare Workers.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dszp/csl-screen.git"
10
+ },
11
+ "homepage": "https://github.com/dszp/csl-screen#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/dszp/csl-screen/issues"
14
+ },
15
+ "author": {
16
+ "name": "David Szpunar",
17
+ "url": "https://david.szpunar.com"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "engines": {
23
+ "node": ">=20.19"
24
+ },
25
+ "keywords": [
26
+ "restricted-party-screening",
27
+ "consolidated-screening-list",
28
+ "export-compliance",
29
+ "cloudflare-workers"
30
+ ],
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js",
35
+ "require": "./dist/index.js",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "./autotask": {
39
+ "types": "./dist/adapters/autotask.d.ts",
40
+ "import": "./dist/adapters/autotask.js",
41
+ "require": "./dist/adapters/autotask.js",
42
+ "default": "./dist/adapters/autotask.js"
43
+ },
44
+ "./onebill": {
45
+ "types": "./dist/adapters/onebill.d.ts",
46
+ "import": "./dist/adapters/onebill.js",
47
+ "require": "./dist/adapters/onebill.js",
48
+ "default": "./dist/adapters/onebill.js"
49
+ },
50
+ "./package.json": "./package.json"
51
+ },
52
+ "types": "./dist/index.d.ts",
53
+ "module": "./dist/index.js",
54
+ "main": "./dist/index.js",
55
+ "sideEffects": false,
56
+ "files": [
57
+ "dist/**/*.js",
58
+ "dist/**/*.d.ts",
59
+ "dist/**/*.map",
60
+ "src",
61
+ "!src/**/*.test.ts",
62
+ "!src/testkit.ts",
63
+ "README.md",
64
+ "CHANGELOG.md"
65
+ ],
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.json",
68
+ "build:watch": "tsc -p tsconfig.json --watch",
69
+ "prepublishOnly": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json --sourceMap false --declarationMap false",
70
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
71
+ "test": "vitest run",
72
+ "test:watch": "vitest"
73
+ },
74
+ "devDependencies": {
75
+ "typescript": "^5",
76
+ "vitest": "^3"
77
+ },
78
+ "packageManager": "pnpm@11.11.0"
79
+ }
@@ -0,0 +1,23 @@
1
+ import type { Party } from '../model.js';
2
+
3
+ /** The fields of an Autotask Companies record the adapter reads; extra fields pass through untouched. */
4
+ export interface AutotaskCompanyLike {
5
+ id: number | string;
6
+ companyName?: string | null;
7
+ [k: string]: unknown;
8
+ }
9
+
10
+ /**
11
+ * Maps Autotask companies to screening parties. `include` is required so the caller states which
12
+ * companies are in scope (for example active customers) instead of screening everything by default.
13
+ */
14
+ export function fromAutotaskCompanies<T extends AutotaskCompanyLike>(
15
+ records: readonly T[],
16
+ opts: { include: (r: T) => boolean; source?: string; country?: (r: T) => string | undefined },
17
+ ): Party[] {
18
+ const source = opts.source ?? 'autotask';
19
+ return records.filter(opts.include).map((r) => {
20
+ const country = opts.country?.(r);
21
+ return { id: String(r.id), name: r.companyName ?? '', source, ...(country ? { country } : {}) };
22
+ });
23
+ }