@rudra-js/attested 0.0.0 → 0.4.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,231 @@
1
+ // Everything here is string work. No float ever holds a value, so a twenty-digit
2
+ // order number compares exactly.
3
+
4
+ import { stripInvisible, stripMarks } from './hidden.js';
5
+
6
+ /** A dot or a comma. Either mark is a decimal point in one locale and grouping in another. */
7
+ const AMBIGUOUS = '.,';
8
+ /** The Arabic decimal separator, which is never grouping. */
9
+ const DECIMAL_ONLY = '\u066b';
10
+ /** Marks that are only ever grouping: Arabic thousands, the space family, the apostrophe. */
11
+ const GROUPING_ONLY = '\u066c\u0020\u00a0\u2007\u2008\u2009\u202f\u0027\u2019';
12
+
13
+ const SEPARATORS = AMBIGUOUS + DECIMAL_ONLY + GROUPING_ONLY;
14
+
15
+ const RUN = `\\p{Nd}+(?:[${SEPARATORS}]\\p{Nd}+)*`;
16
+ /** Marks that multiply the digits in front of them: 万 億 兆, and the k/M/B of English copy. */
17
+ const MAGNITUDE = '(?:[十百千万萬億亿兆]|[kKMB](?!\\p{L}))';
18
+ /** Numeric characters that are not decimal digits: ½ ² ② Ⅲ. */
19
+ const OTHER_NUMERAL = '[\\p{No}\\p{Nl}]+';
20
+
21
+ const SCAN = new RegExp(`${RUN}${MAGNITUDE}|${RUN}|${OTHER_NUMERAL}`, 'gu');
22
+ const LOOSE = new RegExp(`[${GROUPING_ONLY}]`, 'u');
23
+
24
+ const IS_DIGIT = /^\p{Nd}$/u;
25
+ const STARTS_DIGIT = /^\p{Nd}/u;
26
+ const ENDS_DIGIT = /\p{Nd}$/u;
27
+
28
+ export interface Numeral {
29
+ /** The run exactly as it appears in the text. */
30
+ token: string;
31
+ /** Every reading of that run, canonical. Empty when the layer cannot read the run. */
32
+ forms: string[];
33
+ /** `digits` is a run this layer reads. The other two it cannot read at all. */
34
+ kind: 'digits' | 'magnitude' | 'other-numeral';
35
+ }
36
+
37
+ const zeros = new Map<number, number>();
38
+
39
+ /**
40
+ * Unicode writes decimal digits in complete sets of ten, and adjacent sets abut, so
41
+ * the start of a maximal run of digit code points is a zero and the offset into that
42
+ * run, modulo ten, is the value. Walking back without that modulo reads every digit
43
+ * of the second and later sets in the maths block as a nine.
44
+ */
45
+ function digitValue(char: string): number {
46
+ const code = char.codePointAt(0) ?? 0;
47
+
48
+ let zero = zeros.get(code);
49
+ if (zero === undefined) {
50
+ zero = code;
51
+ while (zero > 0 && IS_DIGIT.test(String.fromCodePoint(zero - 1))) zero -= 1;
52
+ zeros.set(code, zero);
53
+ }
54
+
55
+ return (code - zero) % 10;
56
+ }
57
+
58
+ function toAscii(token: string): string {
59
+ let out = '';
60
+ for (const char of token) {
61
+ out += SEPARATORS.includes(char) ? char : String(digitValue(char));
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Strips the zeros that carry no value, so 39.00 and 39 are one string. */
67
+ function canonical(whole: string, fraction: string): string {
68
+ let start = 0;
69
+ while (start < whole.length - 1 && whole[start] === '0') start += 1;
70
+
71
+ let end = fraction.length;
72
+ while (end > 0 && fraction[end - 1] === '0') end -= 1;
73
+
74
+ const head = whole.slice(start);
75
+ const tail = fraction.slice(0, end);
76
+ return tail.length === 0 ? head : `${head}.${tail}`;
77
+ }
78
+
79
+ /** Whether these runs read as one grouped whole number: 12,345,678 or 1,29,999. */
80
+ function isGrouping(groups: string[]): boolean {
81
+ if (groups.length === 1) return true;
82
+ if ((groups[groups.length - 1] ?? '').length !== 3) return false;
83
+
84
+ let three = true;
85
+ let two = true;
86
+ for (let index = 1; index < groups.length - 1; index += 1) {
87
+ const size = (groups[index] ?? '').length;
88
+ if (size !== 3) three = false;
89
+ if (size !== 2) two = false;
90
+ }
91
+
92
+ const lead = (groups[0] ?? '').length;
93
+ if (three) return lead >= 1 && lead <= 3;
94
+ // 1,29,999 — the Indian grouping, two digits at a time above the last three.
95
+ if (two) return lead >= 1 && lead <= 2;
96
+ return false;
97
+ }
98
+
99
+ /** Whether every mark is the same one, and every one of them can be grouping. */
100
+ function allGrouping(separators: string[]): boolean {
101
+ for (const separator of separators) {
102
+ if (separator !== separators[0]) return false;
103
+ if (DECIMAL_ONLY.includes(separator)) return false;
104
+ }
105
+ return true;
106
+ }
107
+
108
+ /** `1,299` is 1299 in London and in Berlin. `1234,567` is a decimal in both. */
109
+ function formsOf(token: string): string[] {
110
+ const ascii = toAscii(token);
111
+
112
+ const groups: string[] = [];
113
+ const separators: string[] = [];
114
+ let current = '';
115
+ for (const char of ascii) {
116
+ if (SEPARATORS.includes(char)) {
117
+ groups.push(current);
118
+ separators.push(char);
119
+ current = '';
120
+ } else {
121
+ current += char;
122
+ }
123
+ }
124
+ groups.push(current);
125
+
126
+ if (separators.length === 0) return [canonical(groups[0] ?? '', '')];
127
+
128
+ const forms: string[] = [];
129
+ if (allGrouping(separators) && isGrouping(groups)) forms.push(canonical(groups.join(''), ''));
130
+
131
+ const whole = groups.slice(0, -1);
132
+ const leading = separators.slice(0, -1);
133
+ const point = separators[separators.length - 1] ?? '';
134
+ const fraction = groups[groups.length - 1] ?? '';
135
+
136
+ // One dot or comma with exactly three digits behind it is grouping wherever it is
137
+ // written, so `4.800` is four thousand eight hundred and never the rating 4.8.
138
+ const groupingWins = forms.length > 0 && leading.length === 0 && fraction.length === 3;
139
+
140
+ if (
141
+ !groupingWins &&
142
+ !GROUPING_ONLY.includes(point) &&
143
+ allGrouping(leading) &&
144
+ leading[0] !== point &&
145
+ isGrouping(whole)
146
+ ) {
147
+ forms.push(canonical(whole.join(''), fraction));
148
+ }
149
+
150
+ return forms;
151
+ }
152
+
153
+ function collect(found: Numeral[], token: string): void {
154
+ if (!STARTS_DIGIT.test(token)) {
155
+ found.push({ token, forms: [], kind: 'other-numeral' });
156
+ return;
157
+ }
158
+ if (!ENDS_DIGIT.test(token)) {
159
+ found.push({ token, forms: [], kind: 'magnitude' });
160
+ return;
161
+ }
162
+
163
+ const forms = formsOf(token);
164
+ if (forms.length > 0) {
165
+ found.push({ token, forms, kind: 'digits' });
166
+ return;
167
+ }
168
+
169
+ // `8 10 12` is three numbers, not one badly grouped one.
170
+ if (LOOSE.test(token)) {
171
+ for (const part of token.split(LOOSE)) {
172
+ if (part.length > 0) collect(found, part);
173
+ }
174
+ return;
175
+ }
176
+
177
+ // No locale reads `24.12.2026` as a number, so only a fact written the same way
178
+ // stands behind it. The raw run carries a mark, and a reading never does, so it
179
+ // can never be mistaken for one.
180
+ found.push({ token, forms: [toAscii(token)], kind: 'digits' });
181
+ }
182
+
183
+ export function numeralsIn(text: string): Numeral[] {
184
+ const found: Numeral[] = [];
185
+ for (const match of stripMarks(stripInvisible(text)).matchAll(SCAN)) collect(found, match[0]);
186
+ return found;
187
+ }
188
+
189
+ /** A whole fact in exponent notation and nothing else, so expanding it swallows no prose. */
190
+ const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE][+-]?\d+$/;
191
+
192
+ /** `String(1e21)` is `1e+21`, which reads as the two numerals 1 and 21. Lay the same digits out in place. */
193
+ function positional(written: string): string {
194
+ const marker = written.search(/[eE]/);
195
+ if (marker < 0) return written;
196
+
197
+ const power = Number(written.slice(marker + 1));
198
+ // The sign never reaches a numeral, so dropping it here mints no digit the host lacks.
199
+ const body = written.slice(0, marker).replace(/^[+-]/, '');
200
+ const point = body.indexOf('.');
201
+ const digits = point < 0 ? body : body.slice(0, point) + body.slice(point + 1);
202
+ const place = (point < 0 ? body.length : point) + power;
203
+
204
+ if (place <= 0) return `0.${'0'.repeat(-place)}${digits}`;
205
+ if (place >= digits.length) return `${digits}${'0'.repeat(place - digits.length)}`;
206
+ return `${digits.slice(0, place)}.${digits.slice(place)}`;
207
+ }
208
+
209
+ /**
210
+ * A string fact is read as the host typed it, except when the whole of it is a number
211
+ * in exponent notation: `String`, `JSON.stringify`, a CSV export and an API that writes
212
+ * 64-bit values as strings all produce that, and none of them is the host choosing digits.
213
+ */
214
+ function digitsOf(value: string | number | bigint): string {
215
+ if (typeof value === 'number') return positional(String(value));
216
+ if (typeof value !== 'string') return String(value);
217
+
218
+ const trimmed = value.trim();
219
+ return BARE_EXPONENT.test(trimmed) ? positional(trimmed) : value;
220
+ }
221
+
222
+ /** Every reading of every numeral the host stands behind. */
223
+ export function supportedValues(values: readonly (string | number | bigint)[]): Set<string> {
224
+ const supported = new Set<string>();
225
+ for (const value of values) {
226
+ for (const numeral of numeralsIn(digitsOf(value))) {
227
+ for (const form of numeral.forms) supported.add(form);
228
+ }
229
+ }
230
+ return supported;
231
+ }
package/src/phrases.ts ADDED
@@ -0,0 +1,204 @@
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
+ /** Both arguments must already be normalised. Spans index the text passed in. */
159
+ // The word-gap rule only guards an ASCII edge, so `cheap` misses `cheapskate`
160
+ // while a Japanese phrase — which has no word gaps to find — still matches.
161
+ export function phraseSpans(text: string, phrase: string): Span[] {
162
+ // Matching runs with every space dropped, because 送料 無料 is 送料無料 to anyone
163
+ // and a space is free. `spots` carries each character back to where it really sat,
164
+ // so the word-gap guard still reads the neighbours the shopper sees.
165
+ const tight: string[] = [];
166
+ const spots: number[] = [];
167
+ for (let index = 0; index < text.length; index += 1) {
168
+ const char = text[index] ?? '';
169
+ // A line break reads like a space here, so a claim split over two lines is caught.
170
+ if (char === ' ' || char === '\n') continue;
171
+ tight.push(char);
172
+ spots.push(index);
173
+ }
174
+
175
+ const spans: Span[] = [];
176
+ const needle = phrase.replaceAll(' ', '').replaceAll('\n', '');
177
+ if (needle.length === 0) return spans;
178
+
179
+ const guardStart = ASCII_WORD.test(needle[0] ?? '');
180
+ const guardEnd = ASCII_WORD.test(needle[needle.length - 1] ?? '');
181
+ const haystack = tight.join('');
182
+
183
+ let from = 0;
184
+ for (;;) {
185
+ const at = haystack.indexOf(needle, from);
186
+ if (at === -1) return spans;
187
+
188
+ const start = spots[at] ?? 0;
189
+ const end = spots[at + needle.length - 1] ?? 0;
190
+ const next = text[end + 1];
191
+ // A trailing s is a plural, not a different word: `discount` catches `discounts`.
192
+ const plural = next === 's' && !isGlued(text[end + 2]);
193
+
194
+ const before = guardStart && isGlued(text[start - 1]);
195
+ const after = guardEnd && isGlued(next) && !plural;
196
+ if (!before && !after) spans.push({ start, end });
197
+
198
+ from = at + 1;
199
+ }
200
+ }
201
+
202
+ export function phraseIn(text: string, phrase: string): boolean {
203
+ return phraseSpans(text, phrase).length > 0;
204
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,193 @@
1
+ import { numeralsIn, supportedValues, type Numeral } from './numerals.js';
2
+ import { BANNED_PHRASES, normalisePhrasing, phraseSpans, 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
+ function phrasesOf(facts: Facts): string[] {
59
+ const phrases = new Set<string>(BANNED_PHRASES);
60
+
61
+ for (const phrase of facts.bannedPhrases ?? []) {
62
+ const normalised = normalisePhrasing(phrase);
63
+ if (normalised.length > 0) phrases.add(normalised);
64
+ }
65
+ return [...phrases];
66
+ }
67
+
68
+ function allowedOf(facts: Facts): string[] {
69
+ const allowed: string[] = [];
70
+ for (const phrase of facts.allowedPhrases ?? []) {
71
+ const normalised = normalisePhrasing(phrase);
72
+ if (normalised.length > 0) allowed.push(normalised);
73
+ }
74
+ return allowed;
75
+ }
76
+
77
+ function reasonFor(numeral: Numeral): string {
78
+ if (numeral.kind === 'other-numeral') {
79
+ return `quantity: "${numeral.token}" is a numeric character this layer cannot read, so no fact can back it`;
80
+ }
81
+ if (numeral.kind === 'magnitude') {
82
+ return `quantity: "${numeral.token}" ends in a magnitude mark this layer cannot read, so the value it shows is unchecked`;
83
+ }
84
+ return `quantity: "${numeral.token}" is not a value the supplied facts carry`;
85
+ }
86
+
87
+ function checkQuantities(text: string, supported: Set<string>): LayerReport {
88
+ const numerals = numeralsIn(text);
89
+ const findings: Finding[] = [];
90
+ const reported = new Set<string>();
91
+
92
+ for (const numeral of numerals) {
93
+ let backed = false;
94
+ for (const form of numeral.forms) {
95
+ if (supported.has(form)) backed = true;
96
+ }
97
+ if (backed || reported.has(numeral.token)) continue;
98
+
99
+ reported.add(numeral.token);
100
+ findings.push({ layer: 'quantity', token: numeral.token, reason: reasonFor(numeral) });
101
+ }
102
+
103
+ return {
104
+ supported: findings.length === 0,
105
+ strength: 'proof',
106
+ checked: numerals.length,
107
+ findings,
108
+ };
109
+ }
110
+
111
+ function standsBehind(allowed: Span[], hit: Span): boolean {
112
+ for (const span of allowed) {
113
+ if (span.start <= hit.start && hit.end <= span.end) return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ function checkWording(text: string, phrases: string[], allowed: string[]): LayerReport {
119
+ const normalised = normalisePhrasing(text);
120
+
121
+ const spans: Span[] = [];
122
+ for (const phrase of allowed) {
123
+ for (const span of phraseSpans(normalised, phrase)) {
124
+ // The denylist reads through a break and the allowance does not, so both rules
125
+ // err toward reporting. A model that puts the negation in one block and the
126
+ // claim in another has written two things the shopper reads apart.
127
+ if (!normalised.slice(span.start, span.end + 1).includes('\n')) spans.push(span);
128
+ }
129
+ }
130
+
131
+ const findings: Finding[] = [];
132
+ for (const phrase of phrases) {
133
+ // One hit inside allowed wording is forgiven; the same phrase elsewhere is not.
134
+ let caught = false;
135
+ for (const hit of phraseSpans(normalised, phrase)) {
136
+ if (!standsBehind(spans, hit)) caught = true;
137
+ }
138
+ if (!caught) continue;
139
+
140
+ findings.push({
141
+ layer: 'wording',
142
+ token: phrase,
143
+ reason: `wording: "${phrase}" is a claim with no value to check, caught by a best-effort denylist`,
144
+ });
145
+ }
146
+
147
+ return {
148
+ supported: findings.length === 0,
149
+ strength: 'best-effort',
150
+ checked: phrases.length,
151
+ findings,
152
+ };
153
+ }
154
+
155
+ function check(
156
+ text: string,
157
+ supported: Set<string>,
158
+ phrases: string[],
159
+ allowed: string[],
160
+ ): VerifyResult {
161
+ const quantity = checkQuantities(text, supported);
162
+ const wording = checkWording(text, phrases, allowed);
163
+ return { supported: quantity.supported && wording.supported, quantity, wording };
164
+ }
165
+
166
+ /** Whether one piece of model-written text stands on the facts the host supplied. */
167
+ export function verify(text: string, facts: Facts): VerifyResult {
168
+ return check(text, supportedValues(facts.values), phrasesOf(facts), allowedOf(facts));
169
+ }
170
+
171
+ /** The same check over every field of one product, without repeating the facts. */
172
+ export function verifyFields(fields: Record<string, string>, facts: Facts): BatchResult {
173
+ const supported = supportedValues(facts.values);
174
+ const phrases = phrasesOf(facts);
175
+ const allowed = allowedOf(facts);
176
+
177
+ const results: FieldResult[] = [];
178
+ const written: string[] = [];
179
+ let allSupported = true;
180
+ for (const field of Object.keys(fields)) {
181
+ const text = fields[field] ?? '';
182
+ const result = check(text, supported, phrases, allowed);
183
+ if (!result.supported) allSupported = false;
184
+ results.push({ field, result });
185
+ written.push(text);
186
+ }
187
+
188
+ // A card renders its fields next to each other, so `Free` and `delivery on every
189
+ // order` are one sentence to a shopper even though neither field carries one.
190
+ const acrossFields = checkWording(written.join(' '), phrases, allowed);
191
+
192
+ return { supported: allSupported && acrossFields.supported, fields: results, acrossFields };
193
+ }