@rudra-js/attested 0.0.0 → 0.5.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/LICENSE +21 -0
- package/README.md +362 -4
- package/dist/hidden.d.ts +3 -0
- package/dist/hidden.d.ts.map +1 -0
- package/dist/hidden.js +22 -0
- package/dist/hidden.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/numerals.d.ts +12 -0
- package/dist/numerals.d.ts.map +1 -0
- package/dist/numerals.js +220 -0
- package/dist/numerals.js.map +1 -0
- package/dist/phrases.d.ts +31 -0
- package/dist/phrases.d.ts.map +1 -0
- package/dist/phrases.js +199 -0
- package/dist/phrases.js.map +1 -0
- package/dist/verify.d.ts +50 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +153 -0
- package/dist/verify.js.map +1 -0
- package/package.json +39 -3
- package/src/hidden.ts +26 -0
- package/src/index.ts +15 -0
- package/src/numerals.ts +247 -0
- package/src/phrases.ts +227 -0
- package/src/verify.ts +223 -0
package/src/phrases.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { stripInvisible, stripMarks } from './hidden.js';
|
|
2
|
+
|
|
3
|
+
/** Claims with no value behind them. Stored in the form `normalisePhrasing` produces. */
|
|
4
|
+
export const BANNED_PHRASES: readonly string[] = [
|
|
5
|
+
'cheap',
|
|
6
|
+
'cheaper',
|
|
7
|
+
'cheapest',
|
|
8
|
+
'affordable',
|
|
9
|
+
'bargain',
|
|
10
|
+
'great value',
|
|
11
|
+
'best value',
|
|
12
|
+
'low price',
|
|
13
|
+
'lower price',
|
|
14
|
+
'lowest price',
|
|
15
|
+
'best price',
|
|
16
|
+
'price drop',
|
|
17
|
+
'price dropped',
|
|
18
|
+
'just dropped',
|
|
19
|
+
'price cut',
|
|
20
|
+
'reduced price',
|
|
21
|
+
'sale',
|
|
22
|
+
'marked down',
|
|
23
|
+
'half price',
|
|
24
|
+
'half off',
|
|
25
|
+
'discount',
|
|
26
|
+
'discounted',
|
|
27
|
+
'clearance',
|
|
28
|
+
'free delivery',
|
|
29
|
+
'free shipping',
|
|
30
|
+
'free postage',
|
|
31
|
+
'free returns',
|
|
32
|
+
'ships free',
|
|
33
|
+
'fast delivery',
|
|
34
|
+
'next day',
|
|
35
|
+
'same day',
|
|
36
|
+
'overnight delivery',
|
|
37
|
+
'arrives tomorrow',
|
|
38
|
+
'delivered tomorrow',
|
|
39
|
+
'by tomorrow',
|
|
40
|
+
'in stock',
|
|
41
|
+
'out of stock',
|
|
42
|
+
'low stock',
|
|
43
|
+
'limited stock',
|
|
44
|
+
'running low',
|
|
45
|
+
'running out',
|
|
46
|
+
'back in stock',
|
|
47
|
+
'restocked',
|
|
48
|
+
'sold out',
|
|
49
|
+
'selling out',
|
|
50
|
+
'almost sold out',
|
|
51
|
+
'nearly sold out',
|
|
52
|
+
'selling fast',
|
|
53
|
+
'going fast',
|
|
54
|
+
'going quick',
|
|
55
|
+
'flying off',
|
|
56
|
+
'while stocks last',
|
|
57
|
+
'almost gone',
|
|
58
|
+
'nearly gone',
|
|
59
|
+
'few left',
|
|
60
|
+
'last few',
|
|
61
|
+
'last chance',
|
|
62
|
+
'limited time',
|
|
63
|
+
'today only',
|
|
64
|
+
'ends soon',
|
|
65
|
+
'hurry',
|
|
66
|
+
'act fast',
|
|
67
|
+
'do not miss',
|
|
68
|
+
"don't miss",
|
|
69
|
+
'best seller',
|
|
70
|
+
'best selling',
|
|
71
|
+
'bestseller',
|
|
72
|
+
'bestselling',
|
|
73
|
+
'most popular',
|
|
74
|
+
'popular pick',
|
|
75
|
+
'top pick',
|
|
76
|
+
'top rated',
|
|
77
|
+
'highly rated',
|
|
78
|
+
'highest rated',
|
|
79
|
+
'rated highest',
|
|
80
|
+
'best rated',
|
|
81
|
+
'five star',
|
|
82
|
+
'star rating',
|
|
83
|
+
'customer favourite',
|
|
84
|
+
'customer favorite',
|
|
85
|
+
'money back',
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Letters that render as a Latin letter but are not one. Folded so a Cyrillic о in
|
|
90
|
+
* "in stоck" cannot walk a claim past its own entry on the list. Both the text and
|
|
91
|
+
* the phrase are folded, so a genuine Cyrillic phrase still matches itself.
|
|
92
|
+
*/
|
|
93
|
+
const CONFUSABLES: Record<string, string> = {
|
|
94
|
+
а: 'a',
|
|
95
|
+
в: 'b',
|
|
96
|
+
е: 'e',
|
|
97
|
+
к: 'k',
|
|
98
|
+
м: 'm',
|
|
99
|
+
н: 'h',
|
|
100
|
+
о: 'o',
|
|
101
|
+
р: 'p',
|
|
102
|
+
с: 'c',
|
|
103
|
+
т: 't',
|
|
104
|
+
х: 'x',
|
|
105
|
+
у: 'y',
|
|
106
|
+
ѕ: 's',
|
|
107
|
+
і: 'i',
|
|
108
|
+
ј: 'j',
|
|
109
|
+
ԁ: 'd',
|
|
110
|
+
һ: 'h',
|
|
111
|
+
ӏ: 'l',
|
|
112
|
+
α: 'a',
|
|
113
|
+
ε: 'e',
|
|
114
|
+
ι: 'i',
|
|
115
|
+
κ: 'k',
|
|
116
|
+
ν: 'v',
|
|
117
|
+
ο: 'o',
|
|
118
|
+
ρ: 'p',
|
|
119
|
+
τ: 't',
|
|
120
|
+
υ: 'u',
|
|
121
|
+
χ: 'x',
|
|
122
|
+
ϲ: 'c',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** A run of whitespace holding one of these is a break the shopper sees. */
|
|
126
|
+
const LINE_BREAK = /[\n\v\f\r\u0085\u2028\u2029]/;
|
|
127
|
+
|
|
128
|
+
/** One shape for text and phrases, so a hyphen or a stray mark cannot hide a claim. */
|
|
129
|
+
export function normalisePhrasing(text: string): string {
|
|
130
|
+
// Marks come off after composition, so an accented e keeps its accent while an
|
|
131
|
+
// overline, which composes with nothing, does not hide `free` from its own entry.
|
|
132
|
+
const lowered = stripMarks(stripInvisible(text).normalize('NFKC').toLowerCase().normalize('NFC'));
|
|
133
|
+
|
|
134
|
+
let folded = '';
|
|
135
|
+
for (const char of lowered) folded += CONFUSABLES[char] ?? char;
|
|
136
|
+
|
|
137
|
+
// A line break survives as itself. Matching reads straight through it, and only
|
|
138
|
+
// the allowance in verify.ts cares, because it will not forgive across a break.
|
|
139
|
+
return folded
|
|
140
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
141
|
+
.replace(/[-_\u2010-\u2015]+/g, ' ')
|
|
142
|
+
.replace(/\s+/g, (run) => (LINE_BREAK.test(run) ? '\n' : ' '))
|
|
143
|
+
.trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ASCII_WORD = /[a-z0-9]/;
|
|
147
|
+
|
|
148
|
+
function isGlued(char: string | undefined): boolean {
|
|
149
|
+
return char !== undefined && ASCII_WORD.test(char);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface Span {
|
|
153
|
+
start: number;
|
|
154
|
+
/** The last character of the match, not one past it. */
|
|
155
|
+
end: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface Indexed {
|
|
159
|
+
/** The text as it was handed in, which is what spans index and the word gap reads. */
|
|
160
|
+
text: string;
|
|
161
|
+
/** The same characters with the gaps dropped, which is what matching runs against. */
|
|
162
|
+
haystack: string;
|
|
163
|
+
/** Where each character of `haystack` really sat in `text`. */
|
|
164
|
+
spots: number[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build this once and read every phrase off it. The text must already be normalised.
|
|
169
|
+
*
|
|
170
|
+
* Matching runs with every space dropped, because 送料 無料 is 送料無料 to anyone and a
|
|
171
|
+
* space is free. `spots` carries each character back to where it really sat, so the
|
|
172
|
+
* word-gap guard still reads the neighbours the shopper sees.
|
|
173
|
+
*/
|
|
174
|
+
export function indexPhrasing(text: string): Indexed {
|
|
175
|
+
const tight: string[] = [];
|
|
176
|
+
const spots: number[] = [];
|
|
177
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
178
|
+
const char = text[index] ?? '';
|
|
179
|
+
// A line break reads like a space here, so a claim split over two lines is caught.
|
|
180
|
+
if (char === ' ' || char === '\n') continue;
|
|
181
|
+
tight.push(char);
|
|
182
|
+
spots.push(index);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { text, haystack: tight.join(''), spots };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Spans index the text the index was built from. The phrase must already be normalised. */
|
|
189
|
+
// The word-gap rule only guards an ASCII edge, so `cheap` misses `cheapskate`
|
|
190
|
+
// while a Japanese phrase — which has no word gaps to find — still matches.
|
|
191
|
+
export function spansIn(indexed: Indexed, phrase: string): Span[] {
|
|
192
|
+
const { text, haystack, spots } = indexed;
|
|
193
|
+
|
|
194
|
+
const spans: Span[] = [];
|
|
195
|
+
const needle = phrase.replaceAll(' ', '').replaceAll('\n', '');
|
|
196
|
+
if (needle.length === 0) return spans;
|
|
197
|
+
|
|
198
|
+
const guardStart = ASCII_WORD.test(needle[0] ?? '');
|
|
199
|
+
const guardEnd = ASCII_WORD.test(needle[needle.length - 1] ?? '');
|
|
200
|
+
|
|
201
|
+
let from = 0;
|
|
202
|
+
for (;;) {
|
|
203
|
+
const at = haystack.indexOf(needle, from);
|
|
204
|
+
if (at === -1) return spans;
|
|
205
|
+
|
|
206
|
+
const start = spots[at] ?? 0;
|
|
207
|
+
const end = spots[at + needle.length - 1] ?? 0;
|
|
208
|
+
const next = text[end + 1];
|
|
209
|
+
// A trailing s is a plural, not a different word: `discount` catches `discounts`.
|
|
210
|
+
const plural = next === 's' && !isGlued(text[end + 2]);
|
|
211
|
+
|
|
212
|
+
const before = guardStart && isGlued(text[start - 1]);
|
|
213
|
+
const after = guardEnd && isGlued(next) && !plural;
|
|
214
|
+
if (!before && !after) spans.push({ start, end });
|
|
215
|
+
|
|
216
|
+
from = at + 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Both arguments must already be normalised. Spans index the text passed in. */
|
|
221
|
+
export function phraseSpans(text: string, phrase: string): Span[] {
|
|
222
|
+
return spansIn(indexPhrasing(text), phrase);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function phraseIn(text: string, phrase: string): boolean {
|
|
226
|
+
return phraseSpans(text, phrase).length > 0;
|
|
227
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { numeralsIn, supportedValues, type Numeral } from './numerals.js';
|
|
2
|
+
import { BANNED_PHRASES, indexPhrasing, normalisePhrasing, spansIn, type Span } from './phrases.js';
|
|
3
|
+
|
|
4
|
+
// Two layers, reported apart, because only one of them is a proof.
|
|
5
|
+
|
|
6
|
+
export type Layer = 'quantity' | 'wording';
|
|
7
|
+
|
|
8
|
+
export interface Finding {
|
|
9
|
+
layer: Layer;
|
|
10
|
+
/** The numeral as the text wrote it, or the phrase as the denylist holds it. */
|
|
11
|
+
token: string;
|
|
12
|
+
/** Audit evidence. Names the layer and the token. */
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LayerReport {
|
|
17
|
+
supported: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* What this layer's verdict is worth. `proof` means every numeral in the text is
|
|
20
|
+
* a value the host supplied — not that the sentence around it is true.
|
|
21
|
+
*/
|
|
22
|
+
strength: 'proof' | 'best-effort';
|
|
23
|
+
/** Numerals found, or phrases screened against. Zero means nothing was checked. */
|
|
24
|
+
checked: number;
|
|
25
|
+
/** One per distinct token that failed. */
|
|
26
|
+
findings: Finding[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VerifyResult {
|
|
30
|
+
/** True when both layers are. */
|
|
31
|
+
supported: boolean;
|
|
32
|
+
quantity: LayerReport;
|
|
33
|
+
wording: LayerReport;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Facts {
|
|
37
|
+
/** In any shape they are written: `39`, `'$1,299.00'`, `'4,8'`. Every numeral in one counts. */
|
|
38
|
+
values: readonly (string | number | bigint)[];
|
|
39
|
+
/** Claims to ban on top of the built-in English list, for the host's own language. */
|
|
40
|
+
bannedPhrases?: readonly string[];
|
|
41
|
+
/** Wording the shop stands behind. A banned claim inside one of these is not reported. */
|
|
42
|
+
allowedPhrases?: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FieldResult {
|
|
46
|
+
field: string;
|
|
47
|
+
result: VerifyResult;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BatchResult {
|
|
51
|
+
/** True when every field is, and when the fields read as one carry no banned claim. */
|
|
52
|
+
supported: boolean;
|
|
53
|
+
fields: FieldResult[];
|
|
54
|
+
/** The wording layer over every field joined, so a phrase split across two fields still reads. */
|
|
55
|
+
acrossFields: LayerReport;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One reading per fact list, keyed on the list itself, because core hands `verify` the
|
|
60
|
+
* same array again for every field it screens. Held against a copy of what the list
|
|
61
|
+
* carried: `readonly` on `Facts.values` is a compile-time view of one reference, and a
|
|
62
|
+
* host holding the array can write to it between two calls.
|
|
63
|
+
*/
|
|
64
|
+
const readings = new WeakMap<object, { held: readonly unknown[]; supported: Set<string> }>();
|
|
65
|
+
|
|
66
|
+
function unchanged(held: readonly unknown[], values: readonly unknown[]): boolean {
|
|
67
|
+
if (held.length !== values.length) return false;
|
|
68
|
+
for (let i = 0; i < held.length; i += 1) {
|
|
69
|
+
if (held[i] !== values[i]) return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function valuesOf(facts: Facts): Set<string> {
|
|
75
|
+
const values = facts.values;
|
|
76
|
+
// A caller outside TypeScript can pass something with no identity to key on.
|
|
77
|
+
if (!Array.isArray(values)) return supportedValues(values);
|
|
78
|
+
|
|
79
|
+
const read = readings.get(values);
|
|
80
|
+
if (read !== undefined && unchanged(read.held, values)) return read.supported;
|
|
81
|
+
|
|
82
|
+
const supported = supportedValues(values);
|
|
83
|
+
readings.set(values, { held: [...values], supported });
|
|
84
|
+
return supported;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function phrasesOf(facts: Facts): string[] {
|
|
88
|
+
const phrases = new Set<string>(BANNED_PHRASES);
|
|
89
|
+
|
|
90
|
+
for (const phrase of facts.bannedPhrases ?? []) {
|
|
91
|
+
const normalised = normalisePhrasing(phrase);
|
|
92
|
+
if (normalised.length > 0) phrases.add(normalised);
|
|
93
|
+
}
|
|
94
|
+
return [...phrases];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function allowedOf(facts: Facts): string[] {
|
|
98
|
+
const allowed: string[] = [];
|
|
99
|
+
for (const phrase of facts.allowedPhrases ?? []) {
|
|
100
|
+
const normalised = normalisePhrasing(phrase);
|
|
101
|
+
if (normalised.length > 0) allowed.push(normalised);
|
|
102
|
+
}
|
|
103
|
+
return allowed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function reasonFor(numeral: Numeral): string {
|
|
107
|
+
if (numeral.kind === 'other-numeral') {
|
|
108
|
+
return `quantity: "${numeral.token}" is a numeric character this layer cannot read, so no fact can back it`;
|
|
109
|
+
}
|
|
110
|
+
if (numeral.kind === 'magnitude') {
|
|
111
|
+
return `quantity: "${numeral.token}" ends in a magnitude mark this layer cannot read, so the value it shows is unchecked`;
|
|
112
|
+
}
|
|
113
|
+
return `quantity: "${numeral.token}" is not a value the supplied facts carry`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function checkQuantities(text: string, supported: Set<string>): LayerReport {
|
|
117
|
+
const numerals = numeralsIn(text);
|
|
118
|
+
const findings: Finding[] = [];
|
|
119
|
+
const reported = new Set<string>();
|
|
120
|
+
|
|
121
|
+
for (const numeral of numerals) {
|
|
122
|
+
let backed = false;
|
|
123
|
+
for (const form of numeral.forms) {
|
|
124
|
+
if (supported.has(form)) backed = true;
|
|
125
|
+
}
|
|
126
|
+
if (backed || reported.has(numeral.token)) continue;
|
|
127
|
+
|
|
128
|
+
reported.add(numeral.token);
|
|
129
|
+
findings.push({ layer: 'quantity', token: numeral.token, reason: reasonFor(numeral) });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
supported: findings.length === 0,
|
|
134
|
+
strength: 'proof',
|
|
135
|
+
checked: numerals.length,
|
|
136
|
+
findings,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function standsBehind(allowed: Span[], hit: Span): boolean {
|
|
141
|
+
for (const span of allowed) {
|
|
142
|
+
if (span.start <= hit.start && hit.end <= span.end) return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function checkWording(text: string, phrases: string[], allowed: string[]): LayerReport {
|
|
148
|
+
const normalised = normalisePhrasing(text);
|
|
149
|
+
const indexed = indexPhrasing(normalised);
|
|
150
|
+
|
|
151
|
+
const spans: Span[] = [];
|
|
152
|
+
for (const phrase of allowed) {
|
|
153
|
+
for (const span of spansIn(indexed, phrase)) {
|
|
154
|
+
// The denylist reads through a break and the allowance does not, so both rules
|
|
155
|
+
// err toward reporting. A model that puts the negation in one block and the
|
|
156
|
+
// claim in another has written two things the shopper reads apart.
|
|
157
|
+
if (!normalised.slice(span.start, span.end + 1).includes('\n')) spans.push(span);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const findings: Finding[] = [];
|
|
162
|
+
for (const phrase of phrases) {
|
|
163
|
+
// One hit inside allowed wording is forgiven; the same phrase elsewhere is not.
|
|
164
|
+
let caught = false;
|
|
165
|
+
for (const hit of spansIn(indexed, phrase)) {
|
|
166
|
+
if (!standsBehind(spans, hit)) caught = true;
|
|
167
|
+
}
|
|
168
|
+
if (!caught) continue;
|
|
169
|
+
|
|
170
|
+
findings.push({
|
|
171
|
+
layer: 'wording',
|
|
172
|
+
token: phrase,
|
|
173
|
+
reason: `wording: "${phrase}" is a claim with no value to check, caught by a best-effort denylist`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
supported: findings.length === 0,
|
|
179
|
+
strength: 'best-effort',
|
|
180
|
+
checked: phrases.length,
|
|
181
|
+
findings,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function check(
|
|
186
|
+
text: string,
|
|
187
|
+
supported: Set<string>,
|
|
188
|
+
phrases: string[],
|
|
189
|
+
allowed: string[],
|
|
190
|
+
): VerifyResult {
|
|
191
|
+
const quantity = checkQuantities(text, supported);
|
|
192
|
+
const wording = checkWording(text, phrases, allowed);
|
|
193
|
+
return { supported: quantity.supported && wording.supported, quantity, wording };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether one piece of model-written text stands on the facts the host supplied. */
|
|
197
|
+
export function verify(text: string, facts: Facts): VerifyResult {
|
|
198
|
+
return check(text, valuesOf(facts), phrasesOf(facts), allowedOf(facts));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The same check over every field of one product, without repeating the facts. */
|
|
202
|
+
export function verifyFields(fields: Record<string, string>, facts: Facts): BatchResult {
|
|
203
|
+
const supported = valuesOf(facts);
|
|
204
|
+
const phrases = phrasesOf(facts);
|
|
205
|
+
const allowed = allowedOf(facts);
|
|
206
|
+
|
|
207
|
+
const results: FieldResult[] = [];
|
|
208
|
+
const written: string[] = [];
|
|
209
|
+
let allSupported = true;
|
|
210
|
+
for (const field of Object.keys(fields)) {
|
|
211
|
+
const text = fields[field] ?? '';
|
|
212
|
+
const result = check(text, supported, phrases, allowed);
|
|
213
|
+
if (!result.supported) allSupported = false;
|
|
214
|
+
results.push({ field, result });
|
|
215
|
+
written.push(text);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A card renders its fields next to each other, so `Free` and `delivery on every
|
|
219
|
+
// order` are one sentence to a shopper even though neither field carries one.
|
|
220
|
+
const acrossFields = checkWording(written.join(' '), phrases, allowed);
|
|
221
|
+
|
|
222
|
+
return { supported: allSupported && acrossFields.supported, fields: results, acrossFields };
|
|
223
|
+
}
|