chaffjs 0.26.0 → 0.27.1
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/dist/ai-score/human-shares.js +59 -59
- package/dist/ai-score/human-shares.js.map +1 -1
- package/dist/capitals-with-small.d.ts +8 -0
- package/dist/capitals-with-small.d.ts.map +1 -0
- package/dist/capitals-with-small.js +20 -0
- package/dist/capitals-with-small.js.map +1 -0
- package/dist/derived/relative-weekdays.d.ts +16 -0
- package/dist/derived/relative-weekdays.d.ts.map +1 -0
- package/dist/derived/relative-weekdays.js +52 -0
- package/dist/derived/relative-weekdays.js.map +1 -0
- package/dist/detectors/change-rate.d.ts +3 -0
- package/dist/detectors/change-rate.d.ts.map +1 -0
- package/dist/detectors/change-rate.js +125 -0
- package/dist/detectors/change-rate.js.map +1 -0
- package/dist/detectors/derived-numbers.d.ts.map +1 -1
- package/dist/detectors/derived-numbers.js +3 -7
- package/dist/detectors/derived-numbers.js.map +1 -1
- package/dist/detectors/document-date.d.ts +4 -0
- package/dist/detectors/document-date.d.ts.map +1 -0
- package/dist/detectors/document-date.js +12 -0
- package/dist/detectors/document-date.js.map +1 -0
- package/dist/detectors/orthographic-variant.d.ts.map +1 -1
- package/dist/detectors/orthographic-variant.js +16 -11
- package/dist/detectors/orthographic-variant.js.map +1 -1
- package/dist/detectors/registry/change-rate.d.ts +3 -0
- package/dist/detectors/registry/change-rate.d.ts.map +1 -0
- package/dist/detectors/registry/change-rate.js +3 -0
- package/dist/detectors/registry/change-rate.js.map +1 -0
- package/dist/detectors/registry/version-mismatch.d.ts +3 -0
- package/dist/detectors/registry/version-mismatch.d.ts.map +1 -0
- package/dist/detectors/registry/version-mismatch.js +3 -0
- package/dist/detectors/registry/version-mismatch.js.map +1 -0
- package/dist/detectors/relative-dates.d.ts.map +1 -1
- package/dist/detectors/relative-dates.js +3 -1
- package/dist/detectors/relative-dates.js.map +1 -1
- package/dist/detectors/relative-day-words.d.ts +4 -0
- package/dist/detectors/relative-day-words.d.ts.map +1 -0
- package/dist/detectors/relative-day-words.js +105 -0
- package/dist/detectors/relative-day-words.js.map +1 -0
- package/dist/detectors/structure-tree.d.ts +4 -1
- package/dist/detectors/structure-tree.d.ts.map +1 -1
- package/dist/detectors/structure-tree.js +65 -3
- package/dist/detectors/structure-tree.js.map +1 -1
- package/dist/detectors/version-mismatch.d.ts +3 -0
- package/dist/detectors/version-mismatch.d.ts.map +1 -0
- package/dist/detectors/version-mismatch.js +19 -0
- package/dist/detectors/version-mismatch.js.map +1 -0
- package/dist/structure/change-rate.d.ts +51 -0
- package/dist/structure/change-rate.d.ts.map +1 -0
- package/dist/structure/change-rate.js +171 -0
- package/dist/structure/change-rate.js.map +1 -0
- package/dist/structure/list-numbering.d.ts +17 -0
- package/dist/structure/list-numbering.d.ts.map +1 -0
- package/dist/structure/list-numbering.js +27 -0
- package/dist/structure/list-numbering.js.map +1 -0
- package/dist/structure/percent-sum-prose.d.ts +7 -0
- package/dist/structure/percent-sum-prose.d.ts.map +1 -0
- package/dist/structure/percent-sum-prose.js +44 -0
- package/dist/structure/percent-sum-prose.js.map +1 -0
- package/dist/structure/version-mismatch.d.ts +23 -0
- package/dist/structure/version-mismatch.d.ts.map +1 -0
- package/dist/structure/version-mismatch.js +74 -0
- package/dist/structure/version-mismatch.js.map +1 -0
- package/dist/structure/weekday.d.ts +21 -2
- package/dist/structure/weekday.d.ts.map +1 -1
- package/dist/structure/weekday.js +70 -8
- package/dist/structure/weekday.js.map +1 -1
- package/package.json +3 -3
- package/rules/agreement-slip.yaml +2 -2
- package/rules/change-rate-mismatch.yaml +44 -0
- package/rules/date-weekday-mismatch.yaml +4 -4
- package/rules/max-sentence-length.yaml +1 -0
- package/rules/numbering-gap.yaml +4 -4
- package/rules/one-sentence-paragraph-run.yaml +1 -0
- package/rules/orthographic-variant.yaml +3 -3
- package/rules/percent-sum-mismatch.yaml +4 -4
- package/rules/relative-date-mismatch.yaml +4 -4
- package/rules/undefined-acronym.yaml +1 -0
- package/rules/version-mismatch.yaml +77 -0
- package/src/ai-score/human-shares.ts +59 -59
- package/src/capitals-with-small.ts +23 -0
- package/src/derived/relative-weekdays.ts +65 -0
- package/src/detectors/change-rate.ts +153 -0
- package/src/detectors/derived-numbers.ts +3 -11
- package/src/detectors/document-date.ts +18 -0
- package/src/detectors/orthographic-variant.ts +8 -4
- package/src/detectors/registry/change-rate.ts +4 -0
- package/src/detectors/registry/version-mismatch.ts +4 -0
- package/src/detectors/relative-dates.ts +3 -1
- package/src/detectors/relative-day-words.ts +126 -0
- package/src/detectors/structure-tree.ts +76 -4
- package/src/detectors/version-mismatch.ts +23 -0
- package/src/structure/change-rate.ts +223 -0
- package/src/structure/list-numbering.ts +34 -0
- package/src/structure/percent-sum-prose.ts +54 -0
- package/src/structure/version-mismatch.ts +103 -0
- package/src/structure/weekday.ts +83 -6
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// relative-date-mismatch, the day words: 「来週月曜(10月12日)」「明日(10月7日)」"next Monday (October 12)" against the
|
|
2
|
+
// date field of an email or minutes. The words are the lexicons relative-week, relative-day, weekday and message-date-field;
|
|
3
|
+
// the reckoning is derived/relative-weekdays.ts.
|
|
4
|
+
import type { Finding, ProseDocument, Span } from "../plugin.ts";
|
|
5
|
+
import type { DatedValue } from "../derived/relative-dates.ts";
|
|
6
|
+
import { relativeDayMismatch, type DayWord } from "../derived/relative-weekdays.ts";
|
|
7
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
8
|
+
import { quoteAt } from "./structure-tree.ts";
|
|
9
|
+
|
|
10
|
+
const FULL = /^\d{4}-\d{2}-\d{2}$/u;
|
|
11
|
+
const LATIN = /^[A-Za-z]/u;
|
|
12
|
+
/** 文書の書き出し: 最初の見出しより前と、最初の見出しの節。メールや議事録の頭の日付(日付:、Date:)が書かれる所。 */
|
|
13
|
+
const OPENING_SECTIONS = 1;
|
|
14
|
+
/** The separators a date may follow its day word with: a space, a bracket, a comma (「来週月曜(10月12日)」). */
|
|
15
|
+
const SEPARATORS = /[\s,、,((::]/gu;
|
|
16
|
+
|
|
17
|
+
const wordPattern = (word: string): RegExp => new RegExp(LATIN.test(word) ? `\\b${escapeRegExp(word)}\\b` : escapeRegExp(word), "giu");
|
|
18
|
+
|
|
19
|
+
const spansOf = (source: string, word: string): Span[] =>
|
|
20
|
+
[...source.matchAll(wordPattern(word))].map((match) => ({ start: match.index, end: match.index + match[0].length }));
|
|
21
|
+
|
|
22
|
+
/** The weekday written right after a week word (Sunday is 0), and where it ends: the name, or the name without its last 日 (月曜). */
|
|
23
|
+
const weekdayAt = (source: string, at: number, names: readonly string[]): { readonly weekday: number; readonly end: number } | undefined => {
|
|
24
|
+
const gap = source.slice(at).length - source.slice(at).trimStart().length;
|
|
25
|
+
const rest = source.slice(at + gap).toLowerCase();
|
|
26
|
+
const found = names
|
|
27
|
+
.map((name, weekday) => {
|
|
28
|
+
const lower = name.toLowerCase();
|
|
29
|
+
const short = LATIN.test(name) ? lower : lower.slice(0, -1);
|
|
30
|
+
const written = [lower, short].find((form) => form.length >= 2 && rest.startsWith(form));
|
|
31
|
+
return written === undefined ? undefined : { weekday, end: at + gap + written.length };
|
|
32
|
+
})
|
|
33
|
+
.find((entry) => entry !== undefined);
|
|
34
|
+
return gap > 1 ? undefined : found;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const dayWordsOf = (doc: ProseDocument): DayWord[] => {
|
|
38
|
+
const names = (doc.lexicons["weekday"] ?? []).map((entry) => entry.pattern);
|
|
39
|
+
const weeks = (doc.lexicons["relative-week"] ?? []).flatMap((entry): DayWord[] =>
|
|
40
|
+
spansOf(doc.source, entry.pattern).flatMap((span) => {
|
|
41
|
+
const day = weekdayAt(doc.source, span.end, names);
|
|
42
|
+
return day === undefined ? [] : [{ start: span.start, end: day.end, kind: "weekday", weeks: Number(entry.group), weekday: day.weekday }];
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
const days = (doc.lexicons["relative-day"] ?? []).flatMap((entry): DayWord[] =>
|
|
46
|
+
spansOf(doc.source, entry.pattern).map((span) => ({ ...span, kind: "day", days: Number(entry.group) })),
|
|
47
|
+
);
|
|
48
|
+
// 「再来週」の中の「来週」、「明後日」の中の「明日」は読まない: 長い語が覆う語を落とす。
|
|
49
|
+
const all = [...weeks, ...days];
|
|
50
|
+
return all.filter(
|
|
51
|
+
(word) => !all.some((other) => other !== word && other.start <= word.start && word.end <= other.end && other.end - other.start > word.end - word.start),
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The date written right after the day word: only separators and a link word (の, on) between. */
|
|
56
|
+
const targetOf = (doc: ProseDocument, dates: readonly DatedValue[], word: DayWord): DatedValue | undefined => {
|
|
57
|
+
const next = dates.find((date) => date.start >= word.end);
|
|
58
|
+
if (next === undefined) return undefined;
|
|
59
|
+
const between = doc.source.slice(word.end, next.start).replace(SEPARATORS, "").toLowerCase();
|
|
60
|
+
const links = (doc.lexicons["relative-link"] ?? []).map((entry) => entry.pattern.replace(SEPARATORS, "").toLowerCase());
|
|
61
|
+
return between === "" || links.includes(between) ? next : undefined;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const lineStartAt = (source: string, offset: number): number => source.lastIndexOf("\n", offset - 1) + 1;
|
|
65
|
+
|
|
66
|
+
const lineAt = (source: string, offset: number): string => {
|
|
67
|
+
const start = lineStartAt(source, offset);
|
|
68
|
+
const end = source.indexOf("\n", offset);
|
|
69
|
+
return source.slice(start, end === -1 ? source.length : end);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const FIELD_MARK = /^\s*(?:[-*+]\s+)?(?<label>[^::]{1,12})[::]/u;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The date the day words count from: the first full date on a date field of the opening (「日付:」「日時:」"Date:", lexicon
|
|
76
|
+
* message-date-field), before the first heading or in the first heading's section. A document without one (a press release
|
|
77
|
+
* whose only date is an update stamp) has no date to count from.
|
|
78
|
+
*/
|
|
79
|
+
const baseOf = (doc: ProseDocument, dates: readonly DatedValue[], starts: readonly number[]): DatedValue | undefined => {
|
|
80
|
+
const labels = (doc.lexicons["message-date-field"] ?? []).map((entry) => entry.pattern.toLowerCase());
|
|
81
|
+
return dates.find((date) => {
|
|
82
|
+
const label = FIELD_MARK.exec(lineAt(doc.source, date.start))?.groups?.["label"]?.trim().toLowerCase() ?? "";
|
|
83
|
+
return FULL.test(date.value) && labels.includes(label) && starts.filter((start) => start <= date.start).length <= OPENING_SECTIONS;
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const QUOTED_LINE = /^\s*>/u;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Day words the document's date does not count: in a quoted reply (「> 明日(10月2日)」 counts from the quoted mail's date),
|
|
91
|
+
* or in a section whose heading names a date (「## 10月1日の会」: its 「本日」 is that day).
|
|
92
|
+
*/
|
|
93
|
+
const countedFromBase = (doc: ProseDocument, word: DayWord, datedHeadings: readonly number[], starts: readonly number[]): boolean => {
|
|
94
|
+
const section = starts.filter((start) => start <= word.start).at(-1);
|
|
95
|
+
return !QUOTED_LINE.test(lineAt(doc.source, word.start)) && (section === undefined || !datedHeadings.includes(section));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const dayWordFindings = (doc: ProseDocument, dates: readonly DatedValue[]): Finding[] => {
|
|
99
|
+
const starts = doc.sections.filter((section) => section.depth > 0).map((section) => section.span.start);
|
|
100
|
+
const base = baseOf(doc, dates, starts);
|
|
101
|
+
if (base === undefined) return [];
|
|
102
|
+
const datedHeadings = starts.filter((start) => dates.some((date) => date.start >= start && date.start < start + lineAt(doc.source, start).length));
|
|
103
|
+
return dayWordsOf(doc)
|
|
104
|
+
.filter((word) => countedFromBase(doc, word, datedHeadings, starts))
|
|
105
|
+
.flatMap((word): Finding[] => {
|
|
106
|
+
const target = targetOf(doc, dates, word);
|
|
107
|
+
const expected = target === undefined || target === base ? undefined : relativeDayMismatch(base.value, word, target.value);
|
|
108
|
+
if (target === undefined || expected === undefined) return [];
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
rule: "relative-date-mismatch",
|
|
112
|
+
severity: "warning",
|
|
113
|
+
line: 0,
|
|
114
|
+
column: 0,
|
|
115
|
+
quote: quoteAt(doc.source, target.start),
|
|
116
|
+
values: {
|
|
117
|
+
base: doc.source.slice(base.start, base.end),
|
|
118
|
+
relative: doc.source.slice(word.start, word.end),
|
|
119
|
+
target: doc.source.slice(target.start, target.end),
|
|
120
|
+
expected,
|
|
121
|
+
offset: target.start,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
});
|
|
126
|
+
};
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import type { Detector, Finding, ProseDocument } from "../plugin.ts";
|
|
1
|
+
import type { Detector, Finding, ProseDocument, StructureNode } from "../plugin.ts";
|
|
2
2
|
import { danglingReferences, duplicateDefinitions, inDocumentOrder, type StructureIssue } from "../structure/issues.ts";
|
|
3
3
|
import { numberingBreaks } from "../structure/numbering.ts";
|
|
4
|
+
import { listNumberBreaks, writtenNumbers } from "../structure/list-numbering.ts";
|
|
4
5
|
import { weekdayMismatches } from "../structure/weekday.ts";
|
|
6
|
+
import { documentDateOf } from "./document-date.ts";
|
|
5
7
|
import { dateOrderBreaks } from "../structure/date-order.ts";
|
|
6
8
|
import { totalMismatches, type Amount } from "../structure/total.ts";
|
|
7
9
|
import { proseTotalMismatches } from "../structure/prose-total.ts";
|
|
8
10
|
import { rangeFrameOf, reversedRanges, type DatedSpan, type RangeWords } from "../structure/date-range.ts";
|
|
9
11
|
import { percentSumMismatches, type ShareWords } from "../structure/percent-sum.ts";
|
|
12
|
+
import { proseShareMismatches } from "../structure/percent-sum-prose.ts";
|
|
10
13
|
import { isQuotedAlone } from "../quoted-span.ts";
|
|
11
14
|
|
|
12
15
|
const QUOTE_LENGTH = 80;
|
|
@@ -35,7 +38,39 @@ const findingsOf =
|
|
|
35
38
|
}));
|
|
36
39
|
|
|
37
40
|
export const danglingReference: Detector = findingsOf("dangling-reference", danglingReferences);
|
|
38
|
-
|
|
41
|
+
const structureNumberingGap: Detector = findingsOf("numbering-gap", numberingBreaks);
|
|
42
|
+
|
|
43
|
+
const lineStartAt = (source: string, offset: number): number => source.lastIndexOf("\n", offset - 1) + 1;
|
|
44
|
+
|
|
45
|
+
/** The written numbers of the document's Markdown ordered lists that skip or repeat (structure/list-numbering.ts). */
|
|
46
|
+
const QUOTED_LINE = /^[ \t]*>/u;
|
|
47
|
+
|
|
48
|
+
/** 引用の中の箇条書きは、ほかの文書の例で、この文書の番号ではない。 */
|
|
49
|
+
const isQuotedList = (source: string, start: number): boolean => QUOTED_LINE.test(source.slice(lineStartAt(source, start), start));
|
|
50
|
+
|
|
51
|
+
const listNumberingGaps = (doc: ProseDocument): Finding[] =>
|
|
52
|
+
doc.lists
|
|
53
|
+
.filter((list) => !isQuotedList(doc.source, list.span.start))
|
|
54
|
+
.flatMap((list) => listNumberBreaks(writtenNumbers(doc.source, list.itemSpans) ?? []))
|
|
55
|
+
.map((issue) => ({
|
|
56
|
+
rule: "numbering-gap",
|
|
57
|
+
severity: "error",
|
|
58
|
+
line: 0,
|
|
59
|
+
column: 0,
|
|
60
|
+
quote: quoteAt(doc.source, issue.offset),
|
|
61
|
+
values: { ...issue.values, offset: issue.offset },
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 番号の抜けと重なり: 番地の木の並び(条、項、号)と、Markdown の番号付きの箇条書きに書いた番号。条の中の「1.」「2.」は
|
|
66
|
+
* 両方に読まれるので、木が指した行の箇条書きは重ねて言わない。
|
|
67
|
+
*/
|
|
68
|
+
export const numberingGap: Detector = (doc, options): Finding[] => {
|
|
69
|
+
const fromTree = structureNumberingGap(doc, options);
|
|
70
|
+
const reported = new Set(fromTree.map((finding) => lineStartAt(doc.source, Number(finding.values["offset"]))));
|
|
71
|
+
const fromLists = listNumberingGaps(doc).filter((finding) => !reported.has(lineStartAt(doc.source, Number(finding.values["offset"]))));
|
|
72
|
+
return [...fromTree, ...fromLists].toSorted((left, right) => Number(left.values["offset"]) - Number(right.values["offset"]));
|
|
73
|
+
};
|
|
39
74
|
export const duplicateDefinition: Detector = findingsOf("duplicate-definition", duplicateDefinitions);
|
|
40
75
|
|
|
41
76
|
/** 曜日の名前は言語パッケージの語彙表(weekday、日曜日から順)から取る。無ければ番号のまま。 */
|
|
@@ -59,10 +94,33 @@ const quotedDates = (doc: ProseDocument, from: DatedSpan | undefined, until: Dat
|
|
|
59
94
|
|
|
60
95
|
const dateAt = (dates: readonly DatedSpan[], offset: number): number => dates.findIndex((date) => date.offset === offset);
|
|
61
96
|
|
|
97
|
+
/** A year written as figures in a heading: 2024年度, FY2024, "2024 events". */
|
|
98
|
+
const YEAR_FIGURES = /(?<![0-90-9])(?:1[89]|20)\d{2}(?![0-90-9])/u;
|
|
99
|
+
const ERA_YEAR_NUMBER = /^[ \t]?(?:[0-90-9]{1,2}|元)/u;
|
|
100
|
+
|
|
101
|
+
/** 見出しが年を名指すか: 数字の年(2024年度、FY2024)か、暦の元号の年(語彙表 calendar-era、令和6年度)。 */
|
|
102
|
+
const namesYear = (heading: string, eras: readonly string[]): boolean =>
|
|
103
|
+
YEAR_FIGURES.test(heading) ||
|
|
104
|
+
eras.some((era) => {
|
|
105
|
+
const at = heading.indexOf(era);
|
|
106
|
+
return at !== -1 && ERA_YEAR_NUMBER.test(heading.slice(at + era.length));
|
|
107
|
+
});
|
|
108
|
+
|
|
62
109
|
export const dateWeekdayMismatch: Detector = (doc): Finding[] => {
|
|
63
110
|
if (doc.structure === undefined) return [];
|
|
64
111
|
const dates = datedSpans(doc.structure);
|
|
65
|
-
|
|
112
|
+
const sections = doc.sections.filter((section) => section.depth > 0);
|
|
113
|
+
const eras = (doc.lexicons["calendar-era"] ?? []).map((entry) => entry.pattern);
|
|
114
|
+
const context = {
|
|
115
|
+
stamp: documentDateOf(doc, doc.structure),
|
|
116
|
+
sectionStarts: sections.map((section) => section.span.start),
|
|
117
|
+
yearHeadings: sections.filter((section) => namesYear(section.heading, eras)).map((section) => section.span.start),
|
|
118
|
+
anchorable: (node: StructureNode): boolean => {
|
|
119
|
+
const date = dates[dateAt(dates, node.span.start)];
|
|
120
|
+
return !quotedDates(doc, date, date, WEEKDAY_WORDS);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
return weekdayMismatches(doc.structure, context)
|
|
66
124
|
.filter((issue) => {
|
|
67
125
|
const date = dates[dateAt(dates, issue.offset)];
|
|
68
126
|
return !quotedDates(doc, date, date, WEEKDAY_WORDS);
|
|
@@ -178,10 +236,24 @@ const shareWordsOf = (doc: ProseDocument): ShareWords => ({
|
|
|
178
236
|
});
|
|
179
237
|
|
|
180
238
|
/** 構成比や内訳の百分率の和が 100% にならない。割合の語と百分率の単位は言語パッケージの語彙表から取る。 */
|
|
239
|
+
/**
|
|
240
|
+
* 表や箇条書きの内訳と、一つの文に並べた内訳(「構成比は、Aが50%、Bが30%、Cが20%」)。箇条書きの項目の文は両方に読まれうるので、
|
|
241
|
+
* 表や箇条書きが指した行の文は重ねて言わない。
|
|
242
|
+
*/
|
|
243
|
+
const percentSumIssues = (doc: ProseDocument, tree: NonNullable<ProseDocument["structure"]>): StructureIssue[] => {
|
|
244
|
+
const amounts = amountsOf(tree);
|
|
245
|
+
const words = shareWordsOf(doc);
|
|
246
|
+
const runs = percentSumMismatches(doc.source, amounts, words);
|
|
247
|
+
const reported = new Set(runs.map((issue) => lineStartAt(doc.source, issue.offset)));
|
|
248
|
+
const sentences = doc.sentences.map((sentence) => sentence.span);
|
|
249
|
+
const prose = proseShareMismatches(doc.source, sentences, amounts, words).filter((issue) => !reported.has(lineStartAt(doc.source, issue.offset)));
|
|
250
|
+
return [...runs, ...prose].toSorted((left, right) => left.offset - right.offset);
|
|
251
|
+
};
|
|
252
|
+
|
|
181
253
|
export const percentSumMismatch: Detector = (doc): Finding[] =>
|
|
182
254
|
doc.structure === undefined
|
|
183
255
|
? []
|
|
184
|
-
:
|
|
256
|
+
: percentSumIssues(doc, doc.structure).map((issue) => ({
|
|
185
257
|
rule: "percent-sum-mismatch",
|
|
186
258
|
severity: "warning",
|
|
187
259
|
line: 0,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// version-mismatch: the version a code block's lead-in names against the one the block shows (structure/version-mismatch.ts).
|
|
2
|
+
// The words that make a version a bound (「から」「以上」, "from", "or later") are the lexicon version-range; the words that
|
|
3
|
+
// lead to the version installed (version, install) are version-lead.
|
|
4
|
+
import type { Detector, Finding } from "../plugin.ts";
|
|
5
|
+
import { versionMismatches, type RangeWord, type VersionWords } from "../structure/version-mismatch.ts";
|
|
6
|
+
import { codeFences } from "./code-fences.ts";
|
|
7
|
+
import { quoteAt } from "./structure-tree.ts";
|
|
8
|
+
|
|
9
|
+
export const versionMismatch: Detector = (doc): Finding[] => {
|
|
10
|
+
const words: VersionWords = {
|
|
11
|
+
ranges: (doc.lexicons["version-range"] ?? []).map((entry): RangeWord => ({ word: entry.pattern, position: entry.position ?? "before" })),
|
|
12
|
+
versionWords: (doc.lexicons["version-lead"] ?? []).map((entry) => entry.pattern),
|
|
13
|
+
};
|
|
14
|
+
const blocks = codeFences(doc.source).map((fence) => ({ start: fence.start, code: fence.lines.join("\n") }));
|
|
15
|
+
return versionMismatches(doc.source, doc.prose ?? doc.source, blocks, words).map((issue) => ({
|
|
16
|
+
rule: "version-mismatch",
|
|
17
|
+
severity: "warning",
|
|
18
|
+
line: 0,
|
|
19
|
+
column: 0,
|
|
20
|
+
quote: quoteAt(doc.source, issue.offset),
|
|
21
|
+
values: { ...issue.values, offset: issue.offset },
|
|
22
|
+
}));
|
|
23
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// A rate of change written beside the two values it is computed from (「前年同月の1,000社から20%増えました」 with 1,200社,
|
|
2
|
+
// "up 20% from 1,000 companies" with 1,200 companies) that the two values do not give. Pure; the detector reads the
|
|
3
|
+
// figures, the rate and the words from the tree and the language's lexicons.
|
|
4
|
+
import type { Span } from "../plugin.ts";
|
|
5
|
+
import type { StructureIssue } from "./issues.ts";
|
|
6
|
+
|
|
7
|
+
/** A value with its unit as the sentence compares it (円, 社, $, companies) and the step it is written to (1 for 1,200; 1億 for 12億). */
|
|
8
|
+
export type Figure = Span & { readonly value: number; readonly unit: string; readonly step: number };
|
|
9
|
+
/** A percentage, with the number of decimals written. */
|
|
10
|
+
export type Rate = Span & { readonly value: number; readonly decimals: number };
|
|
11
|
+
/** A word that says which way the value moved (増加, up: 1; 減少, down: -1). */
|
|
12
|
+
export type Direction = Span & { readonly sign: 1 | -1 };
|
|
13
|
+
/** A word that marks a value: the earlier one after it (「1,000社から」) or before it ("from 1,000"), the later one (「1,200社に」, "to 1,200"). */
|
|
14
|
+
export type BaseMark = Span & { readonly position: "before" | "after" };
|
|
15
|
+
/** A calendar year written in the sentence (「2025年」, "in 2025"). */
|
|
16
|
+
export type Period = Span & { readonly year: number };
|
|
17
|
+
/** A word that starts another subject or clause; one that joins two values ("… in 2025 and …") is read only before the rate. */
|
|
18
|
+
export type Break = Span & { readonly beforeRateOnly: boolean };
|
|
19
|
+
|
|
20
|
+
export type ChangeText = {
|
|
21
|
+
readonly sentences: readonly Span[];
|
|
22
|
+
readonly figures: readonly Figure[];
|
|
23
|
+
readonly rates: readonly Rate[];
|
|
24
|
+
readonly directions: readonly Direction[];
|
|
25
|
+
readonly marks: readonly BaseMark[];
|
|
26
|
+
/** Words that mark the later value ("to", 「に」). */
|
|
27
|
+
readonly targets: readonly BaseMark[];
|
|
28
|
+
readonly periods: readonly Period[];
|
|
29
|
+
/** Words that start another subject or clause (「利益は」, "and", ";"): a rate after one is not about the values before it. */
|
|
30
|
+
readonly breaks: readonly Break[];
|
|
31
|
+
/** The document's text, which the gaps of a pair are measured in. */
|
|
32
|
+
readonly source: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** How far a direction word may stand from the rate (「20%の増加」「20%増えました」, "an increase of 20%", "up 20%"). */
|
|
36
|
+
const DIRECTION_REACH = 8;
|
|
37
|
+
/** How far a base mark may stand from its value ("from $10" has the currency sign between). */
|
|
38
|
+
const MARK_GAP = 3;
|
|
39
|
+
/**
|
|
40
|
+
* How far the earlier value may stand from the rate (「1,000社から20%」「10億円に比べて20%」"20% from $10 million"). Further
|
|
41
|
+
* away, the rate is of something else in the sentence (「100店から120店に増え、売上は15%増えた」).
|
|
42
|
+
*/
|
|
43
|
+
const BASE_REACH = 12;
|
|
44
|
+
/**
|
|
45
|
+
* The two values written together, the earlier then the later or the other way round (「昨年の100万円から今年は150万円」
|
|
46
|
+
* "from $1.0 million last year to $1.5 million"): how far apart they may stand, and how far after them the rate may
|
|
47
|
+
* (「150万円になり、前年比20%」", an increase of 20%"). Measured with a Latin word counting as one character.
|
|
48
|
+
*/
|
|
49
|
+
const PAIR_GAP = 16;
|
|
50
|
+
const PAIR_REACH = 16;
|
|
51
|
+
/** How far a year may stand from the value it dates (「2025年の100万円」"$1.0 million in 2025"), a word counting as one. */
|
|
52
|
+
const PERIOD_GAP = 8;
|
|
53
|
+
const LATIN_WORD = /[A-Za-z]+/gu;
|
|
54
|
+
const PERCENT = 100;
|
|
55
|
+
const HALF = 0.5;
|
|
56
|
+
const DECIMAL_BASE = 10;
|
|
57
|
+
|
|
58
|
+
const within = (outer: Span, inner: Span): boolean => inner.start >= outer.start && inner.end <= outer.end;
|
|
59
|
+
|
|
60
|
+
const distance = (left: Span, right: Span): number => Math.max(left.start - right.end, right.start - left.end, 0);
|
|
61
|
+
|
|
62
|
+
const gapBetween = (left: Span, right: Span): Span => ({ start: left.end, end: right.start });
|
|
63
|
+
|
|
64
|
+
/** The length of a gap, a Latin word counting as one character: "an increase of" is as short as 「前年比」. */
|
|
65
|
+
export const gapLength = (source: string, gap: Span): number => source.slice(gap.start, gap.end).replaceAll(LATIN_WORD, "w").length;
|
|
66
|
+
|
|
67
|
+
const markOf = (figure: Figure, marks: readonly BaseMark[]): BaseMark | undefined =>
|
|
68
|
+
marks.find((mark) => {
|
|
69
|
+
const gap = mark.position === "after" ? mark.start - figure.end : figure.start - mark.end;
|
|
70
|
+
return gap >= 0 && gap <= MARK_GAP;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** The earlier value: marked, with the value or its mark beside the rate ("up 25% compared with $1,000"). */
|
|
74
|
+
const isBaseFor = (figure: Figure, rate: Rate, marks: readonly BaseMark[]): boolean => {
|
|
75
|
+
const mark = markOf(figure, marks);
|
|
76
|
+
return mark !== undefined && Math.min(distance(figure, rate), distance(mark, rate)) <= BASE_REACH;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The later value is written before the rate (「1,200社となり、…20%増えました」"1,200 companies, up 20%") or marked as the
|
|
81
|
+
* value reached (「1,200社に」"to 1,200"). Another amount after the rate is about something else ("…, and revenue was $1,300").
|
|
82
|
+
*/
|
|
83
|
+
const isCurrentFor = (figure: Figure, rate: Rate, targets: readonly BaseMark[]): boolean => figure.end <= rate.start || markOf(figure, targets) !== undefined;
|
|
84
|
+
|
|
85
|
+
/** The one sign the direction words beside the rate agree on. */
|
|
86
|
+
const signNear = (rate: Rate, directions: readonly Direction[]): 1 | -1 | undefined => {
|
|
87
|
+
const signs = new Set(directions.filter((direction) => distance(direction, rate) <= DIRECTION_REACH).map((direction) => direction.sign));
|
|
88
|
+
const [sign] = signs;
|
|
89
|
+
return signs.size === 1 ? sign : undefined;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The rates of change the two values allow. Each value may be off by half the step it is written to: 12億 is anything from
|
|
94
|
+
* 11.5億 to 12.5億, so 12億 from 10億 is a rise of 9.5% to 31.6%, and only a rate outside that is a mistake.
|
|
95
|
+
*/
|
|
96
|
+
const rateRange = (base: Figure, current: Figure): readonly [number, number] | undefined => {
|
|
97
|
+
const [baseLow, baseHigh] = [base.value - base.step * HALF, base.value + base.step * HALF];
|
|
98
|
+
const [currentLow, currentHigh] = [current.value - current.step * HALF, current.value + current.step * HALF];
|
|
99
|
+
if (baseLow <= 0) return undefined;
|
|
100
|
+
return [(currentLow / baseHigh - 1) * PERCENT, (currentHigh / baseLow - 1) * PERCENT];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
type Pair = { readonly base: Figure; readonly current: Figure };
|
|
104
|
+
/** One sentence's parts, with the rate: the one percentage that has a word of direction beside it. */
|
|
105
|
+
type Reading = ChangeText & { readonly rate: Rate; readonly sign: 1 | -1 };
|
|
106
|
+
|
|
107
|
+
/** A word between the two that starts another subject or clause (「100万円から150万円に増え、利益は20%」). */
|
|
108
|
+
const brokenBetween = (text: Reading, one: Span, other: Span): boolean => {
|
|
109
|
+
const gap = one.end <= other.start ? gapBetween(one, other) : gapBetween(other, one);
|
|
110
|
+
return text.breaks.some((word) => within(gap, word));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** The earlier value marked beside the rate, and the one value in its unit before the rate or marked as reached. */
|
|
114
|
+
const besideRate = (text: Reading): Pair | undefined => {
|
|
115
|
+
const bases = text.figures.filter((figure) => isBaseFor(figure, text.rate, text.marks) && !brokenBetween(text, figure, text.rate));
|
|
116
|
+
const [base] = bases;
|
|
117
|
+
if (bases.length !== 1 || base === undefined) return undefined;
|
|
118
|
+
const currents = text.figures.filter((figure) => figure !== base && figure.unit === base.unit && isCurrentFor(figure, text.rate, text.targets));
|
|
119
|
+
const [current] = currents;
|
|
120
|
+
return currents.length === 1 && current !== undefined ? { base, current } : undefined;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const yearsBeside = (text: Reading, figure: Figure, side: "before" | "after", bound: Span): Period[] =>
|
|
124
|
+
text.periods.filter((period) => {
|
|
125
|
+
const [left, right] = side === "before" ? [period, figure] : [figure, period];
|
|
126
|
+
return left.end <= right.start && within(bound, period) && gapLength(text.source, gapBetween(left, right)) <= PERIOD_GAP;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Of two values each dated by a year, the earlier year's. The years stand on the same side of both values: before them
|
|
131
|
+
* (「2025年は100万円、2026年は150万円」) or after them ("$1.0 million in 2025 and $1.5 million in 2026").
|
|
132
|
+
*/
|
|
133
|
+
const datedEarlier = (text: Reading, first: Figure, second: Figure): Figure | undefined => {
|
|
134
|
+
const middle = gapBetween(first, second);
|
|
135
|
+
const sides = [
|
|
136
|
+
[yearsBeside(text, first, "before", { start: 0, end: first.start }), yearsBeside(text, second, "before", middle)],
|
|
137
|
+
[yearsBeside(text, first, "after", middle), yearsBeside(text, second, "after", { start: second.end, end: text.source.length })],
|
|
138
|
+
].filter(([early, late]) => early?.length === 1 && late?.length === 1);
|
|
139
|
+
const [[early, late] = []] = sides;
|
|
140
|
+
const [firstYear, secondYear] = [early?.[0]?.year, late?.[0]?.year];
|
|
141
|
+
if (sides.length !== 1 || firstYear === undefined || secondYear === undefined || firstYear === secondYear) return undefined;
|
|
142
|
+
return firstYear < secondYear ? first : second;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Which of two values written together is the earlier: the one marked (「から」, "from", 「前年の」), else the one dated earlier. */
|
|
146
|
+
const pairOf = (text: Reading, first: Figure, second: Figure): Pair | undefined => {
|
|
147
|
+
const marked = [first, second].filter((figure) => markOf(figure, text.marks) !== undefined);
|
|
148
|
+
if (marked.length > 1) return undefined;
|
|
149
|
+
const base = marked[0] ?? datedEarlier(text, first, second);
|
|
150
|
+
if (base === undefined) return undefined;
|
|
151
|
+
return { base, current: base === first ? second : first };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Nothing between the later value and the rate that makes the rate another one's: a value, another subject, another base. */
|
|
155
|
+
const leadsTo = (text: Reading, second: Figure): boolean => {
|
|
156
|
+
const gap = gapBetween(second, text.rate);
|
|
157
|
+
if (gap.start > gap.end || gapLength(text.source, gap) > PAIR_REACH) return false;
|
|
158
|
+
return ![...text.figures, ...text.breaks, ...text.marks].some((span) => within(gap, span));
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Two values of one subject written close, with no rate between them (「売上は100万円、費用は150万円」 is two subjects). */
|
|
162
|
+
const standTogether = (text: Reading, first: Figure, second: Figure): boolean => {
|
|
163
|
+
const gap = gapBetween(first, second);
|
|
164
|
+
const inGap = (span: Span): boolean => within(gap, span);
|
|
165
|
+
if (gapLength(text.source, gap) > PAIR_GAP || text.rates.some(inGap)) return false;
|
|
166
|
+
return !text.breaks.some((word) => !word.beforeRateOnly && inGap(word));
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** The two values written together right before the rate (「昨年の100万円から今年は150万円になり、前年比20%の増加」). */
|
|
170
|
+
const beforeRate = (text: Reading): Pair | undefined => {
|
|
171
|
+
const pairs = text.figures.flatMap((first, index) => {
|
|
172
|
+
const second = text.figures.slice(index + 1).find((figure) => figure.unit === first.unit);
|
|
173
|
+
const pair = second !== undefined && standTogether(text, first, second) && leadsTo(text, second) ? pairOf(text, first, second) : undefined;
|
|
174
|
+
return pair === undefined ? [] : [pair];
|
|
175
|
+
});
|
|
176
|
+
const [pair] = pairs;
|
|
177
|
+
return pairs.length === 1 ? pair : undefined;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The sentence's one rate with a word of direction beside it, a word counting for the rate it stands nearest. A percentage
|
|
182
|
+
* without one (「20%の増加、利益率は10%」) is about something else.
|
|
183
|
+
*/
|
|
184
|
+
const readingIn = (text: ChangeText, sentence: Span): Reading | undefined => {
|
|
185
|
+
const inSentence = (span: Span): boolean => within(sentence, span);
|
|
186
|
+
const directions = text.directions.filter(inSentence);
|
|
187
|
+
const rates = text.rates.filter(inSentence);
|
|
188
|
+
const nearestTo = (rate: Rate, direction: Direction): boolean => rates.every((other) => distance(direction, rate) <= distance(direction, other));
|
|
189
|
+
const signed = rates.flatMap((rate) => {
|
|
190
|
+
const sign = signNear(
|
|
191
|
+
rate,
|
|
192
|
+
directions.filter((direction) => nearestTo(rate, direction)),
|
|
193
|
+
);
|
|
194
|
+
return sign === undefined ? [] : [{ rate, sign }];
|
|
195
|
+
});
|
|
196
|
+
const [only] = signed;
|
|
197
|
+
if (signed.length !== 1 || only === undefined) return undefined;
|
|
198
|
+
const parts = {
|
|
199
|
+
figures: text.figures.filter(inSentence).toSorted((left, right) => left.start - right.start),
|
|
200
|
+
periods: text.periods.filter(inSentence),
|
|
201
|
+
breaks: text.breaks.filter(inSentence),
|
|
202
|
+
};
|
|
203
|
+
return { ...text, ...parts, ...only, rates };
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const issueIn = (text: ChangeText, sentence: Span): StructureIssue[] => {
|
|
207
|
+
const reading = readingIn(text, sentence);
|
|
208
|
+
const pair = reading === undefined ? undefined : (besideRate(reading) ?? beforeRate(reading));
|
|
209
|
+
const range = pair === undefined ? undefined : rateRange(pair.base, pair.current);
|
|
210
|
+
if (reading === undefined || pair === undefined || range === undefined) return [];
|
|
211
|
+
const { rate, sign } = reading;
|
|
212
|
+
const written = sign * rate.value;
|
|
213
|
+
const slack = HALF * DECIMAL_BASE ** -rate.decimals;
|
|
214
|
+
if (written >= range[0] - slack && written <= range[1] + slack) return [];
|
|
215
|
+
const computed = Math.abs((pair.current.value / pair.base.value - 1) * PERCENT).toFixed(rate.decimals);
|
|
216
|
+
return [{ offset: rate.start, values: { rate: String(rate.value), computed } }];
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Each sentence with one rate of change and its direction, and the two values it is computed from, that disagree: the
|
|
221
|
+
* earlier value marked beside the rate, or the two values written together right before it.
|
|
222
|
+
*/
|
|
223
|
+
export const changeRateMismatches = (text: ChangeText): StructureIssue[] => text.sentences.flatMap((sentence) => issueIn(text, sentence));
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The numbers written on the items of a Markdown ordered list (「1.」「2.」「4.」) that skip or repeat. Markdown renders an
|
|
2
|
+
// ordered list from its first number on, but the source shows the numbers as written, and a "see step 4" points at those.
|
|
3
|
+
import type { Span } from "../plugin.ts";
|
|
4
|
+
import type { StructureIssue } from "./issues.ts";
|
|
5
|
+
|
|
6
|
+
const ITEM_NUMBER = /^(?<number>\d{1,9})(?<delimiter>[.)])[ \t]/u;
|
|
7
|
+
|
|
8
|
+
/** One item's written number: where the item starts, the number and the number as written (「4.」). */
|
|
9
|
+
export type WrittenNumber = { readonly offset: number; readonly number: number; readonly label: string };
|
|
10
|
+
|
|
11
|
+
/** The number written at the start of each item, or undefined when an item has none (a bulleted list). */
|
|
12
|
+
export const writtenNumbers = (source: string, itemSpans: readonly Span[]): WrittenNumber[] | undefined => {
|
|
13
|
+
const numbers = itemSpans.map((span) => {
|
|
14
|
+
const found = ITEM_NUMBER.exec(source.slice(span.start, span.end))?.groups;
|
|
15
|
+
return found === undefined
|
|
16
|
+
? undefined
|
|
17
|
+
: { offset: span.start, number: Number(found["number"]), label: `${found["number"] ?? ""}${found["delimiter"] ?? ""}` };
|
|
18
|
+
});
|
|
19
|
+
return numbers.every((entry) => entry !== undefined) ? numbers : undefined;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Each item whose number is not one more than the item before it. A list that writes one number on every item (「1.」 on
|
|
24
|
+
* each, which Markdown counts up) is numbered by the renderer, not by the writer, and is not read. An item numbered 1
|
|
25
|
+
* starts a new count, as the structure rule reads a 1 that follows other items.
|
|
26
|
+
*/
|
|
27
|
+
export const listNumberBreaks = (items: readonly WrittenNumber[]): StructureIssue[] => {
|
|
28
|
+
if (new Set(items.map((item) => item.number)).size < 2) return [];
|
|
29
|
+
return items.flatMap((item, index) => {
|
|
30
|
+
const previous = items[index - 1];
|
|
31
|
+
if (previous === undefined || item.number === previous.number + 1 || item.number === 1) return [];
|
|
32
|
+
return [{ offset: item.offset, values: { previous: previous.label, label: item.label, expected: previous.number + 1, found: item.number } }];
|
|
33
|
+
});
|
|
34
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A breakdown written in one sentence (「構成比は、Aが50%、Bが30%、Cが20%」, "By product, A made up 50% of sales, B 30% and
|
|
2
|
+
// C 20%") whose shares do not add up to 100%. The table and list form is structure/percent-sum.ts; the words are the same.
|
|
3
|
+
import type { Span } from "../plugin.ts";
|
|
4
|
+
import type { StructureIssue } from "./issues.ts";
|
|
5
|
+
import type { Amount } from "./total.ts";
|
|
6
|
+
import type { ShareWords } from "./percent-sum.ts";
|
|
7
|
+
|
|
8
|
+
const WHOLE = 100;
|
|
9
|
+
/** A sentence names two shares in passing (「AとBで50%」); a breakdown in prose names at least three parts. */
|
|
10
|
+
const MIN_PARTS = 3;
|
|
11
|
+
/**
|
|
12
|
+
* How far from 100% a sentence may add up and still read as one whole broken down. Far below it, the sentence names only the
|
|
13
|
+
* larger parts (「Aが50%、Bが20%、Cが10%など」); far above it, the percentages are of different wholes ("55% of directors,
|
|
14
|
+
* 48% of managers") or of two periods ("45% (40% last year)"), or the total is among them (「合計100%」).
|
|
15
|
+
*/
|
|
16
|
+
const BAND = 10;
|
|
17
|
+
const HALF = 0.5;
|
|
18
|
+
const DECIMAL_BASE = 10;
|
|
19
|
+
const DECIMALS = /\.(\d+)/u;
|
|
20
|
+
/** A sign before the number (+3%, ▲2%): a change, not a part. */
|
|
21
|
+
const SIGNS = "-+−▲△";
|
|
22
|
+
|
|
23
|
+
const includesAny = (text: string, words: readonly string[]): boolean => {
|
|
24
|
+
const lowered = text.toLowerCase();
|
|
25
|
+
return words.some((word) => lowered.includes(word.toLowerCase()));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const decimalsOf = (source: string, amount: Amount): number => DECIMALS.exec(source.slice(amount.offset, amount.end))?.[1]?.length ?? 0;
|
|
29
|
+
|
|
30
|
+
const isSigned = (source: string, amount: Amount): boolean => SIGNS.includes(source.slice(0, amount.offset).trimEnd().at(-1) ?? " ");
|
|
31
|
+
|
|
32
|
+
/** The rounding the parts allow: half the last digit each, at least one point. */
|
|
33
|
+
const toleranceOf = (source: string, shares: readonly Amount[]): number =>
|
|
34
|
+
Math.max(1, shares.length * HALF * DECIMAL_BASE ** -Math.max(0, ...shares.map((share) => decimalsOf(source, share))));
|
|
35
|
+
|
|
36
|
+
const isBroken = (sum: number, tolerance: number): boolean => Math.abs(sum - WHOLE) > tolerance && Math.abs(sum - WHOLE) <= BAND;
|
|
37
|
+
|
|
38
|
+
const issueIn = (source: string, sentence: Span, percents: readonly Amount[], words: ShareWords): StructureIssue[] => {
|
|
39
|
+
const text = source.slice(sentence.start, sentence.end);
|
|
40
|
+
if (!includesAny(text, words.labels) || includesAny(text, words.exceptions)) return [];
|
|
41
|
+
const shares = percents.filter((amount) => amount.offset >= sentence.start && amount.end <= sentence.end);
|
|
42
|
+
if (shares.length < MIN_PARTS || shares.some((share) => isSigned(source, share))) return [];
|
|
43
|
+
const sum = shares.reduce((total, share) => total + share.value, 0);
|
|
44
|
+
const decimals = Math.max(0, ...shares.map((share) => decimalsOf(source, share)));
|
|
45
|
+
if (!isBroken(sum, toleranceOf(source, shares))) return [];
|
|
46
|
+
return [{ offset: shares[0]?.offset ?? sentence.start, values: { sum: `${sum.toFixed(decimals)}${shares[0]?.unit ?? ""}` } }];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Each sentence that names a share word and three or more unsigned percentages whose sum misses 100% by more than rounding, and by no more than BAND. */
|
|
50
|
+
export const proseShareMismatches = (source: string, sentences: readonly Span[], amounts: readonly Amount[], words: ShareWords): StructureIssue[] => {
|
|
51
|
+
if (words.labels.length === 0) return [];
|
|
52
|
+
const percents = amounts.filter((amount) => words.units.includes(amount.unit));
|
|
53
|
+
return sentences.flatMap((sentence) => issueIn(source, sentence, percents, words));
|
|
54
|
+
};
|