chaffjs 0.26.0 → 0.27.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/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 +102 -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 +33 -0
- package/dist/structure/change-rate.d.ts.map +1 -0
- package/dist/structure/change-rate.js +71 -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 +122 -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 +103 -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,122 @@
|
|
|
1
|
+
// change-rate-mismatch: the reading half. Builds the figures, the rates and the words of structure/change-rate.ts from the
|
|
2
|
+
// tree's quantities, the numbers counted with a word ("1,200 companies"), and the lexicons change-direction, change-base,
|
|
3
|
+
// percent-unit and amount-multiplier.
|
|
4
|
+
import type { Detector, Finding, ProseDocument, Span, StructureNode } from "../plugin.ts";
|
|
5
|
+
import { inDocumentOrder } from "../structure/issues.ts";
|
|
6
|
+
import { changeRateMismatches, type BaseMark, type Direction, type Figure, type Rate } from "../structure/change-rate.ts";
|
|
7
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
8
|
+
import { quoteAt } from "./structure-tree.ts";
|
|
9
|
+
|
|
10
|
+
const patternsOf = (doc: ProseDocument, id: string): string[] => (doc.lexicons[id] ?? []).map((entry) => entry.pattern);
|
|
11
|
+
|
|
12
|
+
const DIGITS = /\d[\d,]*(?:\.(\d+))?/u;
|
|
13
|
+
const DECIMAL_BASE = 10;
|
|
14
|
+
const LATIN = /^[A-Za-z]/u;
|
|
15
|
+
|
|
16
|
+
/** The step a number is written to: its last digit, times the magnitude the tree folded into the value (12億 is 12 × 1億). */
|
|
17
|
+
const stepOf = (written: string, value: number): number | undefined => {
|
|
18
|
+
const found = DIGITS.exec(written.normalize("NFKC"));
|
|
19
|
+
const shown = found === null ? Number.NaN : Number(found[0].replaceAll(",", ""));
|
|
20
|
+
if (!Number.isFinite(shown) || shown === 0) return undefined;
|
|
21
|
+
return (value / shown) * DECIMAL_BASE ** -(found?.[1]?.length ?? 0);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A word of magnitude right after the number ("$12 million"): the values compared must be written to the same one. */
|
|
25
|
+
const WORD_AFTER = /^\s+([A-Za-z]+)/u;
|
|
26
|
+
const WORDS_AFTER = /^\s+([A-Za-z]+)(?:\s+([A-Za-z]+))?/u;
|
|
27
|
+
|
|
28
|
+
const multiplierAfter = (source: string, end: number, multipliers: readonly string[]): string => {
|
|
29
|
+
const after = WORD_AFTER.exec(source.slice(end))?.[1]?.toLowerCase() ?? "";
|
|
30
|
+
return multipliers.includes(after) ? after : "";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const quantityNodes = (tree: StructureNode): StructureNode[] => inDocumentOrder(tree).filter((node) => node.kind === "quantity" || node.kind === "date");
|
|
34
|
+
|
|
35
|
+
const ratesOf = (doc: ProseDocument, nodes: readonly StructureNode[], units: readonly string[]): Rate[] =>
|
|
36
|
+
nodes.flatMap((node) => {
|
|
37
|
+
const unit = String(node.attrs["unit"] ?? "");
|
|
38
|
+
if (node.kind !== "quantity" || !units.includes(unit)) return [];
|
|
39
|
+
const decimals = DIGITS.exec(doc.source.slice(node.span.start, node.span.end).normalize("NFKC"))?.[1]?.length ?? 0;
|
|
40
|
+
return [{ start: node.span.start, end: node.span.end, value: Number(node.attrs["value"]), decimals }];
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const treeFigures = (doc: ProseDocument, nodes: readonly StructureNode[], units: readonly string[], multipliers: readonly string[]): Figure[] =>
|
|
44
|
+
nodes.flatMap((node) => {
|
|
45
|
+
const unit = String(node.attrs["unit"] ?? "");
|
|
46
|
+
const value = Number(node.attrs["value"]);
|
|
47
|
+
const step = stepOf(doc.source.slice(node.span.start, node.span.end), value);
|
|
48
|
+
if (node.kind !== "quantity" || units.includes(unit) || step === undefined) return [];
|
|
49
|
+
return [{ start: node.span.start, end: node.span.end, value, step, unit: `${unit}|${multiplierAfter(doc.source, node.span.end, multipliers)}` }];
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** A number with no unit the tree reads, counted with the word after it ("1,200 companies", "2.4 million users"). */
|
|
53
|
+
const COUNTED = /(?<![\w.,$€£¥])(\d{1,3}(?:,\d{3})+|\d+)(?:\.(\d+))?(?=\s+[A-Za-z])/gu;
|
|
54
|
+
/** A year written alone ("2024 revenue"): a point in time, not a value. */
|
|
55
|
+
const YEAR = /^(?:1[89]|2[01])\d{2}$/u;
|
|
56
|
+
|
|
57
|
+
/** The unit a counted number is compared in: the word after it, with a word of magnitude before that word ("million users"). */
|
|
58
|
+
const countedUnit = (source: string, end: number, multipliers: readonly string[]): string | undefined => {
|
|
59
|
+
const words = WORDS_AFTER.exec(source.slice(end));
|
|
60
|
+
const first = words?.[1]?.toLowerCase() ?? "";
|
|
61
|
+
const multiplier = multipliers.includes(first) ? first : "";
|
|
62
|
+
const noun = multiplier === "" ? first : (words?.[2]?.toLowerCase() ?? "");
|
|
63
|
+
return LATIN.test(noun) ? `${noun}|${multiplier}` : undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const countedFigures = (doc: ProseDocument, taken: readonly Span[], multipliers: readonly string[]): Figure[] =>
|
|
67
|
+
[...doc.source.matchAll(COUNTED)].flatMap((match) => {
|
|
68
|
+
const start = match.index;
|
|
69
|
+
const end = start + match[0].length;
|
|
70
|
+
const unit = countedUnit(doc.source, end, multipliers);
|
|
71
|
+
if (unit === undefined || taken.some((span) => span.start < end && start < span.end) || YEAR.test(match[0])) return [];
|
|
72
|
+
const decimals = match[2] ?? "";
|
|
73
|
+
const value = Number([(match[1] ?? "").replaceAll(",", ""), decimals].filter((part) => part !== "").join("."));
|
|
74
|
+
return [{ start, end, value, step: DECIMAL_BASE ** -decimals.length, unit }];
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Where a word of the lexicon stands in the source: a Latin word as a whole word, anything else as written. */
|
|
78
|
+
const spansOf = (source: string, word: string): Span[] => {
|
|
79
|
+
const pattern = LATIN.test(word) ? new RegExp(`\\b${escapeRegExp(word)}\\b`, "giu") : new RegExp(escapeRegExp(word), "gu");
|
|
80
|
+
return [...source.matchAll(pattern)].map((match) => ({ start: match.index, end: match.index + match[0].length }));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const SIGNS: Readonly<Record<string, 1 | -1>> = { rise: 1, fall: -1 };
|
|
84
|
+
|
|
85
|
+
/** Phrases that hold a direction word but give no direction ("up to 25%" is a ceiling): lexicon change-direction-not. */
|
|
86
|
+
const directionsOf = (doc: ProseDocument): Direction[] => {
|
|
87
|
+
const not = patternsOf(doc, "change-direction-not").flatMap((phrase) => spansOf(doc.source, phrase));
|
|
88
|
+
return (doc.lexicons["change-direction"] ?? []).flatMap((entry) => {
|
|
89
|
+
const sign = SIGNS[entry.group ?? ""];
|
|
90
|
+
const spans = spansOf(doc.source, entry.pattern).filter((span) => !not.some((phrase) => phrase.start <= span.start && span.end <= phrase.end));
|
|
91
|
+
return sign === undefined ? [] : spans.map((span) => ({ ...span, sign }));
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const marksOf = (doc: ProseDocument, lexicon: string): BaseMark[] =>
|
|
96
|
+
(doc.lexicons[lexicon] ?? []).flatMap((entry) =>
|
|
97
|
+
entry.position === undefined ? [] : spansOf(doc.source, entry.pattern).map((span) => ({ ...span, position: entry.position ?? "before" })),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
export const changeRate: Detector = (doc): Finding[] => {
|
|
101
|
+
if (doc.structure === undefined) return [];
|
|
102
|
+
const nodes = quantityNodes(doc.structure);
|
|
103
|
+
const units = patternsOf(doc, "percent-unit");
|
|
104
|
+
const multipliers = patternsOf(doc, "amount-multiplier").map((word) => word.toLowerCase());
|
|
105
|
+
const taken = nodes.map((node) => node.span);
|
|
106
|
+
const text = {
|
|
107
|
+
sentences: doc.sentences.map((sentence) => sentence.span),
|
|
108
|
+
figures: [...treeFigures(doc, nodes, units, multipliers), ...countedFigures(doc, taken, multipliers)],
|
|
109
|
+
rates: ratesOf(doc, nodes, units),
|
|
110
|
+
directions: directionsOf(doc),
|
|
111
|
+
marks: marksOf(doc, "change-base"),
|
|
112
|
+
targets: marksOf(doc, "change-target"),
|
|
113
|
+
};
|
|
114
|
+
return changeRateMismatches(text).map((issue) => ({
|
|
115
|
+
rule: "change-rate-mismatch",
|
|
116
|
+
severity: "warning",
|
|
117
|
+
line: 0,
|
|
118
|
+
column: 0,
|
|
119
|
+
quote: quoteAt(doc.source, issue.offset),
|
|
120
|
+
values: { ...issue.values, offset: issue.offset },
|
|
121
|
+
}));
|
|
122
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Detector, Finding, ProseDocument, Span, StructureNode } from "../plugin.ts";
|
|
2
2
|
import { inDocumentOrder } from "../structure/issues.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { documentDateOf } from "./document-date.ts";
|
|
4
4
|
import { overlapsAny, spanIndex } from "../compare/spans.ts";
|
|
5
5
|
import { yearOf, type DurationUnit } from "../derived/date-arithmetic.ts";
|
|
6
6
|
import { durationMismatches, type DatedValue, type Duration } from "../derived/durations.ts";
|
|
@@ -142,16 +142,8 @@ const markedAges = (doc: ProseDocument): Elapsed[] =>
|
|
|
142
142
|
|
|
143
143
|
/** 文書の日付の年: 日付だけの段落か「更新日:」の段落(date-stamp)の、最初の日付。 */
|
|
144
144
|
const referenceYear = (doc: ProseDocument, tree: StructureNode): number | undefined => {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
doc.source,
|
|
148
|
-
doc.paragraphs.map((paragraph) => paragraph.span),
|
|
149
|
-
dates,
|
|
150
|
-
);
|
|
151
|
-
const stamps = dateStampIndexes(candidates, patternsOf(doc, "date-stamp-label"));
|
|
152
|
-
const paragraph = doc.paragraphs.find((_, index) => stamps.has(index));
|
|
153
|
-
const date = paragraph === undefined ? undefined : dates.find((candidate) => candidate.start >= paragraph.span.start && candidate.end <= paragraph.span.end);
|
|
154
|
-
return date === undefined ? undefined : yearOf(date.value);
|
|
145
|
+
const date = documentDateOf(doc, tree);
|
|
146
|
+
return date === undefined ? undefined : yearOf(date);
|
|
155
147
|
};
|
|
156
148
|
|
|
157
149
|
/** 起点の年から数えた年数が、文書の日付と合わない。 */
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The document's own date: the first date of its date-stamp paragraph (a paragraph that is only a date, or 「更新日:」).
|
|
2
|
+
import type { ProseDocument, StructureNode } from "../plugin.ts";
|
|
3
|
+
import { inDocumentOrder } from "../structure/issues.ts";
|
|
4
|
+
import { dateStampIndexes, stampCandidates } from "../date-stamp.ts";
|
|
5
|
+
|
|
6
|
+
/** The date of the document's date stamp as the tree reads it ("2026-10-06", or "2026" for a year alone), when it has one. */
|
|
7
|
+
export const documentDateOf = (doc: ProseDocument, tree: StructureNode): string | undefined => {
|
|
8
|
+
const dates = inDocumentOrder(tree).flatMap((node) => (node.kind === "date" ? [{ ...node.span, value: String(node.attrs["value"]) }] : []));
|
|
9
|
+
const candidates = stampCandidates(
|
|
10
|
+
doc.source,
|
|
11
|
+
doc.paragraphs.map((paragraph) => paragraph.span),
|
|
12
|
+
dates,
|
|
13
|
+
);
|
|
14
|
+
const labels = (doc.lexicons["date-stamp-label"] ?? []).map((entry) => entry.pattern);
|
|
15
|
+
const stamps = dateStampIndexes(candidates, labels);
|
|
16
|
+
const paragraph = doc.paragraphs.find((_, index) => stamps.has(index));
|
|
17
|
+
return paragraph === undefined ? undefined : dates.find((candidate) => candidate.start >= paragraph.span.start && candidate.end <= paragraph.span.end)?.value;
|
|
18
|
+
};
|
|
@@ -5,6 +5,7 @@ import { isKatakanaWord, stemOf } from "../long-vowel.ts";
|
|
|
5
5
|
import { QUOTATION_MARKS, isWithinAny, quotedSpans } from "../quoted-span.ts";
|
|
6
6
|
import { nameSpans, touchesAny } from "../team-names.ts";
|
|
7
7
|
import { furiganaSpans } from "../furigana.ts";
|
|
8
|
+
import { acronymsIn, isCapitalsNotSpelling } from "../capitals-with-small.ts";
|
|
8
9
|
|
|
9
10
|
/** A word found in the document: where it is, which sentence it is in, its key and spelling, and how it is written when that differs. */
|
|
10
11
|
type Placed = KeyedWord & { readonly sentence: Sentence; readonly offset: number; readonly shown?: string };
|
|
@@ -150,17 +151,20 @@ const isPartOfAddress = (text: string, start: number, end: number): boolean =>
|
|
|
150
151
|
|
|
151
152
|
/**
|
|
152
153
|
* Latin words keyed without hyphens and case (e-mail and email, GitHub and Github). A word in capitals only (TEAMS, MAY) is
|
|
153
|
-
* emphasis or an acronym, not a spelling of the word, and is left out
|
|
154
|
+
* emphasis or an acronym, not a spelling of the word, and is left out; so are a name in capitals with an ending (SENDs) and
|
|
155
|
+
* the letters of an acronym of the sentence picked out (CLImatology for CLIPER).
|
|
154
156
|
*/
|
|
155
|
-
const latinWords = (sentence: Sentence, isOpen: IsOpen, skip: ReadonlySet<string>): Placed[] =>
|
|
156
|
-
|
|
157
|
+
const latinWords = (sentence: Sentence, isOpen: IsOpen, skip: ReadonlySet<string>): Placed[] => {
|
|
158
|
+
const acronyms = acronymsIn(sentence.text);
|
|
159
|
+
return [...sentence.text.matchAll(LATIN_WORD)].flatMap((match): Placed[] => {
|
|
157
160
|
const word = match[0];
|
|
158
161
|
const plain = word.toLowerCase().replaceAll("-", "");
|
|
159
162
|
const end = match.index + word.length;
|
|
160
|
-
if (plain.length < MIN_LATIN_KEY || !LOWER.test(word) || skip.has(word.toLowerCase())) return [];
|
|
163
|
+
if (plain.length < MIN_LATIN_KEY || !LOWER.test(word) || isCapitalsNotSpelling(word, acronyms) || skip.has(word.toLowerCase())) return [];
|
|
161
164
|
if (isPartOfAddress(sentence.text, match.index, end) || !isOpen(match.index, end)) return [];
|
|
162
165
|
return [{ key: `latin|${plain}`, spelling: latinSpelling(word), shown: word, sentence, offset: sentence.span.start + match.index }];
|
|
163
166
|
});
|
|
167
|
+
};
|
|
164
168
|
|
|
165
169
|
/** How the usual spelling is written where it first appears (gitHub is shown GitHub). */
|
|
166
170
|
const shownUsual = (words: readonly Placed[], { word, usual }: OddSpelling<Placed>): string =>
|
|
@@ -4,6 +4,7 @@ import { overlapsAny, spanIndex } from "../compare/spans.ts";
|
|
|
4
4
|
import type { DurationUnit } from "../derived/date-arithmetic.ts";
|
|
5
5
|
import { relativeMismatches, type DatedValue, type Relative } from "../derived/relative-dates.ts";
|
|
6
6
|
import { quoteAt } from "./structure-tree.ts";
|
|
7
|
+
import { dayWordFindings } from "./relative-day-words.ts";
|
|
7
8
|
|
|
8
9
|
/** 期間の単位の語彙表。relative-year-unit は、向きの語が付くときだけ期間になる単位(「1年前」の年)。 */
|
|
9
10
|
const DURATION_LEXICONS: readonly (readonly [string, DurationUnit])[] = [
|
|
@@ -96,7 +97,7 @@ export const relativeDateMismatch: Detector = (doc): Finding[] => {
|
|
|
96
97
|
relatives: relativesOf(doc, amounts),
|
|
97
98
|
links: patternsOf(doc, "relative-link"),
|
|
98
99
|
};
|
|
99
|
-
|
|
100
|
+
const counted = relativeMismatches(input).map((mismatch): Finding => ({
|
|
100
101
|
rule: "relative-date-mismatch",
|
|
101
102
|
severity: "warning",
|
|
102
103
|
line: 0,
|
|
@@ -110,4 +111,5 @@ export const relativeDateMismatch: Detector = (doc): Finding[] => {
|
|
|
110
111
|
offset: mismatch.target.start,
|
|
111
112
|
},
|
|
112
113
|
}));
|
|
114
|
+
return [...counted, ...dayWordFindings(doc, dates)];
|
|
113
115
|
};
|
|
@@ -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,103 @@
|
|
|
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
|
+
|
|
16
|
+
export type ChangeText = {
|
|
17
|
+
readonly sentences: readonly Span[];
|
|
18
|
+
readonly figures: readonly Figure[];
|
|
19
|
+
readonly rates: readonly Rate[];
|
|
20
|
+
readonly directions: readonly Direction[];
|
|
21
|
+
readonly marks: readonly BaseMark[];
|
|
22
|
+
/** Words that mark the later value ("to", 「に」). */
|
|
23
|
+
readonly targets: readonly BaseMark[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** How far a direction word may stand from the rate (「20%の増加」「20%増えました」, "an increase of 20%", "up 20%"). */
|
|
27
|
+
const DIRECTION_REACH = 8;
|
|
28
|
+
/** How far a base mark may stand from its value ("from $10" has the currency sign between). */
|
|
29
|
+
const MARK_GAP = 3;
|
|
30
|
+
/**
|
|
31
|
+
* How far the earlier value may stand from the rate (「1,000社から20%」「10億円に比べて20%」"20% from $10 million"). Further
|
|
32
|
+
* away, the rate is of something else in the sentence (「100店から120店に増え、売上は15%増えた」).
|
|
33
|
+
*/
|
|
34
|
+
const BASE_REACH = 12;
|
|
35
|
+
const PERCENT = 100;
|
|
36
|
+
const HALF = 0.5;
|
|
37
|
+
const DECIMAL_BASE = 10;
|
|
38
|
+
|
|
39
|
+
const within = (outer: Span, inner: Span): boolean => inner.start >= outer.start && inner.end <= outer.end;
|
|
40
|
+
|
|
41
|
+
const distance = (left: Span, right: Span): number => Math.max(left.start - right.end, right.start - left.end, 0);
|
|
42
|
+
|
|
43
|
+
const markOf = (figure: Figure, marks: readonly BaseMark[]): BaseMark | undefined =>
|
|
44
|
+
marks.find((mark) => {
|
|
45
|
+
const gap = mark.position === "after" ? mark.start - figure.end : figure.start - mark.end;
|
|
46
|
+
return gap >= 0 && gap <= MARK_GAP;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** The earlier value: marked, with the value or its mark beside the rate ("up 25% compared with $1,000"). */
|
|
50
|
+
const isBaseFor = (figure: Figure, rate: Rate, marks: readonly BaseMark[]): boolean => {
|
|
51
|
+
const mark = markOf(figure, marks);
|
|
52
|
+
return mark !== undefined && Math.min(distance(figure, rate), distance(mark, rate)) <= BASE_REACH;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The later value is written before the rate (「1,200社となり、…20%増えました」"1,200 companies, up 20%") or marked as the
|
|
57
|
+
* value reached (「1,200社に」"to 1,200"). Another amount after the rate is about something else ("…, and revenue was $1,300").
|
|
58
|
+
*/
|
|
59
|
+
const isCurrentFor = (figure: Figure, rate: Rate, targets: readonly BaseMark[]): boolean => figure.end <= rate.start || markOf(figure, targets) !== undefined;
|
|
60
|
+
|
|
61
|
+
/** The one sign the direction words beside the rate agree on. */
|
|
62
|
+
const signNear = (rate: Rate, directions: readonly Direction[]): 1 | -1 | undefined => {
|
|
63
|
+
const signs = new Set(directions.filter((direction) => distance(direction, rate) <= DIRECTION_REACH).map((direction) => direction.sign));
|
|
64
|
+
const [sign] = signs;
|
|
65
|
+
return signs.size === 1 ? sign : undefined;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 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
|
|
70
|
+
* 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.
|
|
71
|
+
*/
|
|
72
|
+
const rateRange = (base: Figure, current: Figure): readonly [number, number] | undefined => {
|
|
73
|
+
const [baseLow, baseHigh] = [base.value - base.step * HALF, base.value + base.step * HALF];
|
|
74
|
+
const [currentLow, currentHigh] = [current.value - current.step * HALF, current.value + current.step * HALF];
|
|
75
|
+
if (baseLow <= 0) return undefined;
|
|
76
|
+
return [(currentLow / baseHigh - 1) * PERCENT, (currentHigh / baseLow - 1) * PERCENT];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const issueIn = (text: ChangeText, sentence: Span): StructureIssue[] => {
|
|
80
|
+
const rates = text.rates.filter((rate) => within(sentence, rate));
|
|
81
|
+
const figures = text.figures.filter((figure) => within(sentence, figure));
|
|
82
|
+
const [rate] = rates;
|
|
83
|
+
if (rates.length !== 1 || rate === undefined) return [];
|
|
84
|
+
const sign = signNear(
|
|
85
|
+
rate,
|
|
86
|
+
text.directions.filter((direction) => within(sentence, direction)),
|
|
87
|
+
);
|
|
88
|
+
const bases = figures.filter((figure) => isBaseFor(figure, rate, text.marks));
|
|
89
|
+
const [base] = bases;
|
|
90
|
+
if (sign === undefined || bases.length !== 1 || base === undefined) return [];
|
|
91
|
+
const currents = figures.filter((figure) => figure !== base && figure.unit === base.unit && isCurrentFor(figure, rate, text.targets));
|
|
92
|
+
const [current] = currents;
|
|
93
|
+
const range = currents.length === 1 && current !== undefined ? rateRange(base, current) : undefined;
|
|
94
|
+
if (range === undefined || current === undefined) return [];
|
|
95
|
+
const written = sign * rate.value;
|
|
96
|
+
const slack = HALF * DECIMAL_BASE ** -rate.decimals;
|
|
97
|
+
if (written >= range[0] - slack && written <= range[1] + slack) return [];
|
|
98
|
+
const computed = Math.abs((current.value / base.value - 1) * PERCENT).toFixed(rate.decimals);
|
|
99
|
+
return [{ offset: rate.start, values: { rate: String(rate.value), computed } }];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Each sentence with one rate of change, one direction, one marked earlier value and one other value in its unit, that disagree. */
|
|
103
|
+
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
|
+
};
|