chaffjs 0.16.0 → 0.17.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.
Files changed (187) hide show
  1. package/dist/calendar-number.d.ts +7 -2
  2. package/dist/calendar-number.d.ts.map +1 -1
  3. package/dist/calendar-number.js +10 -3
  4. package/dist/calendar-number.js.map +1 -1
  5. package/dist/closing-numbers.d.ts +20 -0
  6. package/dist/closing-numbers.d.ts.map +1 -0
  7. package/dist/closing-numbers.js +56 -0
  8. package/dist/closing-numbers.js.map +1 -0
  9. package/dist/commands/test.d.ts.map +1 -1
  10. package/dist/commands/test.js +11 -9
  11. package/dist/commands/test.js.map +1 -1
  12. package/dist/detectors/acronym-context.d.ts +2 -0
  13. package/dist/detectors/acronym-context.d.ts.map +1 -1
  14. package/dist/detectors/acronym-context.js +13 -0
  15. package/dist/detectors/acronym-context.js.map +1 -1
  16. package/dist/detectors/acronym-expansion.d.ts +2 -0
  17. package/dist/detectors/acronym-expansion.d.ts.map +1 -1
  18. package/dist/detectors/acronym-expansion.js +104 -1
  19. package/dist/detectors/acronym-expansion.js.map +1 -1
  20. package/dist/detectors/agreement-slip.d.ts +32 -0
  21. package/dist/detectors/agreement-slip.d.ts.map +1 -0
  22. package/dist/detectors/agreement-slip.js +137 -0
  23. package/dist/detectors/agreement-slip.js.map +1 -0
  24. package/dist/detectors/content-morphemes.d.ts +4 -0
  25. package/dist/detectors/content-morphemes.d.ts.map +1 -0
  26. package/dist/detectors/content-morphemes.js +12 -0
  27. package/dist/detectors/content-morphemes.js.map +1 -0
  28. package/dist/detectors/en-shape.d.ts +0 -1
  29. package/dist/detectors/en-shape.d.ts.map +1 -1
  30. package/dist/detectors/en-shape.js +0 -235
  31. package/dist/detectors/en-shape.js.map +1 -1
  32. package/dist/detectors/heading-echo.d.ts.map +1 -1
  33. package/dist/detectors/heading-echo.js +12 -18
  34. package/dist/detectors/heading-echo.js.map +1 -1
  35. package/dist/detectors/heading-overlap.d.ts +13 -0
  36. package/dist/detectors/heading-overlap.d.ts.map +1 -0
  37. package/dist/detectors/heading-overlap.js +52 -0
  38. package/dist/detectors/heading-overlap.js.map +1 -0
  39. package/dist/detectors/index.d.ts.map +1 -1
  40. package/dist/detectors/index.js +6 -1
  41. package/dist/detectors/index.js.map +1 -1
  42. package/dist/detectors/letter-joined-name.d.ts +13 -0
  43. package/dist/detectors/letter-joined-name.d.ts.map +1 -0
  44. package/dist/detectors/letter-joined-name.js +11 -0
  45. package/dist/detectors/letter-joined-name.js.map +1 -0
  46. package/dist/detectors/lexicon.d.ts.map +1 -1
  47. package/dist/detectors/lexicon.js +5 -2
  48. package/dist/detectors/lexicon.js.map +1 -1
  49. package/dist/detectors/orthography.js +2 -2
  50. package/dist/detectors/orthography.js.map +1 -1
  51. package/dist/detectors/oxford-comma.d.ts +3 -0
  52. package/dist/detectors/oxford-comma.d.ts.map +1 -0
  53. package/dist/detectors/oxford-comma.js +340 -0
  54. package/dist/detectors/oxford-comma.js.map +1 -0
  55. package/dist/detectors/signals.d.ts.map +1 -1
  56. package/dist/detectors/signals.js +29 -10
  57. package/dist/detectors/signals.js.map +1 -1
  58. package/dist/detectors/stacked-hedge.d.ts +0 -7
  59. package/dist/detectors/stacked-hedge.d.ts.map +1 -1
  60. package/dist/detectors/stacked-hedge.js +6 -3
  61. package/dist/detectors/stacked-hedge.js.map +1 -1
  62. package/dist/detectors/stray-space.d.ts +7 -0
  63. package/dist/detectors/stray-space.d.ts.map +1 -0
  64. package/dist/detectors/stray-space.js +34 -0
  65. package/dist/detectors/stray-space.js.map +1 -0
  66. package/dist/detectors/structure.d.ts +3 -1
  67. package/dist/detectors/structure.d.ts.map +1 -1
  68. package/dist/detectors/structure.js +4 -2
  69. package/dist/detectors/structure.js.map +1 -1
  70. package/dist/document.d.ts.map +1 -1
  71. package/dist/document.js +15 -3
  72. package/dist/document.js.map +1 -1
  73. package/dist/genre-parse.d.ts +5 -1
  74. package/dist/genre-parse.d.ts.map +1 -1
  75. package/dist/genre-parse.js +16 -3
  76. package/dist/genre-parse.js.map +1 -1
  77. package/dist/genre-suggest.d.ts.map +1 -1
  78. package/dist/genre-suggest.js +15 -1
  79. package/dist/genre-suggest.js.map +1 -1
  80. package/dist/line-paragraphs.d.ts.map +1 -1
  81. package/dist/line-paragraphs.js +3 -1
  82. package/dist/line-paragraphs.js.map +1 -1
  83. package/dist/mask.d.ts.map +1 -1
  84. package/dist/mask.js +2 -11
  85. package/dist/mask.js.map +1 -1
  86. package/dist/number-name.js +1 -1
  87. package/dist/number-name.js.map +1 -1
  88. package/dist/opening-line.d.ts +3 -0
  89. package/dist/opening-line.d.ts.map +1 -0
  90. package/dist/opening-line.js +14 -0
  91. package/dist/opening-line.js.map +1 -0
  92. package/dist/orthography.d.ts +5 -1
  93. package/dist/orthography.d.ts.map +1 -1
  94. package/dist/orthography.js +7 -1
  95. package/dist/orthography.js.map +1 -1
  96. package/dist/paragraph-length.d.ts +7 -0
  97. package/dist/paragraph-length.d.ts.map +1 -0
  98. package/dist/paragraph-length.js +10 -0
  99. package/dist/paragraph-length.js.map +1 -0
  100. package/dist/plugin.d.ts +6 -0
  101. package/dist/plugin.d.ts.map +1 -1
  102. package/dist/quoted-span.d.ts +0 -2
  103. package/dist/quoted-span.d.ts.map +1 -1
  104. package/dist/quoted-span.js +1 -1
  105. package/dist/quoted-span.js.map +1 -1
  106. package/dist/rule-load.d.ts.map +1 -1
  107. package/dist/rule-load.js +8 -0
  108. package/dist/rule-load.js.map +1 -1
  109. package/dist/run-semantic.d.ts +3 -1
  110. package/dist/run-semantic.d.ts.map +1 -1
  111. package/dist/run-semantic.js +8 -3
  112. package/dist/run-semantic.js.map +1 -1
  113. package/dist/run.d.ts +2 -0
  114. package/dist/run.d.ts.map +1 -1
  115. package/dist/run.js +4 -1
  116. package/dist/run.js.map +1 -1
  117. package/dist/semantic.d.ts +1 -1
  118. package/dist/semantic.d.ts.map +1 -1
  119. package/dist/semantic.js +10 -3
  120. package/dist/semantic.js.map +1 -1
  121. package/dist/span-merge.d.ts +4 -0
  122. package/dist/span-merge.d.ts.map +1 -0
  123. package/dist/span-merge.js +12 -0
  124. package/dist/span-merge.js.map +1 -0
  125. package/dist/speaker-labels.d.ts +4 -0
  126. package/dist/speaker-labels.d.ts.map +1 -0
  127. package/dist/speaker-labels.js +106 -0
  128. package/dist/speaker-labels.js.map +1 -0
  129. package/dist/stray-space.d.ts +17 -0
  130. package/dist/stray-space.d.ts.map +1 -0
  131. package/dist/stray-space.js +66 -0
  132. package/dist/stray-space.js.map +1 -0
  133. package/dist/structure/relative-resolve.d.ts.map +1 -1
  134. package/dist/structure/relative-resolve.js +26 -20
  135. package/dist/structure/relative-resolve.js.map +1 -1
  136. package/dist/team-names.d.ts.map +1 -1
  137. package/dist/team-names.js +11 -14
  138. package/dist/team-names.js.map +1 -1
  139. package/genres.yaml +34 -2
  140. package/package.json +3 -3
  141. package/rules/agreement-slip.yaml +36 -0
  142. package/rules/empty-conclusion.yaml +1 -0
  143. package/rules/heading-echo.yaml +1 -0
  144. package/rules/max-paragraph-length.yaml +9 -2
  145. package/rules/max-sentence-length.yaml +4 -0
  146. package/rules/oxford-comma-consistency.yaml +1 -1
  147. package/rules/stray-space.yaml +38 -0
  148. package/rules/undefined-acronym.yaml +1 -0
  149. package/src/calendar-number.ts +16 -7
  150. package/src/closing-numbers.ts +72 -0
  151. package/src/commands/test.ts +19 -14
  152. package/src/detectors/acronym-context.ts +16 -0
  153. package/src/detectors/acronym-expansion.ts +132 -1
  154. package/src/detectors/agreement-slip.ts +191 -0
  155. package/src/detectors/content-morphemes.ts +19 -0
  156. package/src/detectors/en-shape.ts +0 -269
  157. package/src/detectors/heading-echo.ts +12 -20
  158. package/src/detectors/heading-overlap.ts +65 -0
  159. package/src/detectors/index.ts +6 -1
  160. package/src/detectors/letter-joined-name.ts +15 -0
  161. package/src/detectors/lexicon.ts +6 -2
  162. package/src/detectors/orthography.ts +2 -2
  163. package/src/detectors/oxford-comma.ts +402 -0
  164. package/src/detectors/signals.ts +32 -9
  165. package/src/detectors/stacked-hedge.ts +6 -3
  166. package/src/detectors/stray-space.ts +40 -0
  167. package/src/detectors/structure.ts +10 -2
  168. package/src/document.ts +25 -4
  169. package/src/genre-parse.ts +30 -5
  170. package/src/genre-suggest.ts +15 -2
  171. package/src/line-paragraphs.ts +4 -1
  172. package/src/mask.ts +2 -11
  173. package/src/number-name.ts +1 -1
  174. package/src/opening-line.ts +17 -0
  175. package/src/orthography.ts +10 -2
  176. package/src/paragraph-length.ts +9 -0
  177. package/src/plugin.ts +6 -0
  178. package/src/quoted-span.ts +1 -1
  179. package/src/rule-load.ts +8 -0
  180. package/src/run-semantic.ts +28 -20
  181. package/src/run.ts +5 -1
  182. package/src/semantic.ts +11 -3
  183. package/src/span-merge.ts +13 -0
  184. package/src/speaker-labels.ts +129 -0
  185. package/src/stray-space.ts +81 -0
  186. package/src/structure/relative-resolve.ts +34 -19
  187. package/src/team-names.ts +13 -13
@@ -0,0 +1,191 @@
1
+ import type { Detector, Finding, Lexicon, Token } from "../plugin.ts";
2
+ import { isPartOfLongerWord } from "./doubled-word.ts";
3
+
4
+ /**
5
+ * 語の形が合っていない(a significant changes / one of the most important feature / Your can check)。
6
+ * 書き換えで片方の語だけ直した跡。解析器の読みが一通りに決まる形だけを数える。語はすべてアダプタの語彙表から来る。
7
+ */
8
+
9
+ export type AgreementLists = {
10
+ /** 一つを数える限定詞(a / each)。 */
11
+ readonly singular: Lexicon;
12
+ /** 複数を数える限定詞と句(these / one of the)。 */
13
+ readonly plural: Lexicon;
14
+ /** 単数と複数が同じ綴りの名詞(series / data)。 */
15
+ readonly invariant: ReadonlySet<string>;
16
+ /** 単数の限定詞で複数を数える形容詞(a few days)。 */
17
+ readonly count: ReadonlySet<string>;
18
+ /** 名詞の前にしか立たない所有の語(your / their)。 */
19
+ readonly possessive: ReadonlySet<string>;
20
+ /** 名詞にならない be / have の形(is / has)。 */
21
+ readonly finite: ReadonlySet<string>;
22
+ /** 単数で書く所を複数で書いた句(ones of the most)。 */
23
+ readonly misnumbered: Lexicon;
24
+ };
25
+
26
+ export type Slip = { readonly variant: "number" | "possessive"; readonly first: Token; readonly last: Token; readonly word: string };
27
+
28
+ const SPACE_ONLY = /^\s+$/u;
29
+ const UPPER_START = /^\p{Lu}/u;
30
+ const LETTER = /\p{L}/u;
31
+ const LOWER = /\p{Ll}/u;
32
+ /** 名詞句を閉じる記号。読点・括弧の始まり・ハイフンは、名詞を重ねた語(a sales, marketing and support team)の途中にも立つ。 */
33
+ const CLOSING_MARK = new Set([".", ";", ":", "?", "!", ")"]);
34
+ /**
35
+ * 名詞の後ろに来れば、名詞句はそこで終わっている品詞。名詞・形容詞・接続詞・所有の印は、名詞を重ねた語の途中。
36
+ * 動詞は、名詞にならない be / have の形(is / has)だけ。解析器は複数の名詞の後ろの名詞を動詞と読む(a sales team の team が VBP)。
37
+ */
38
+ const PHRASE_END = new Set(["AUX", "ADP", "SCONJ", "PRON", "DET", "ADV"]);
39
+ const PREDICATE_TAIL = new Set(["DET", "ADJ", "PRON", "NUM"]);
40
+
41
+ const lower = (token: Token): string => token.surface.toLowerCase();
42
+
43
+ const isParticiple = (token: Token): boolean => token.features?.["VerbForm"] === "Part" || token.features?.["VerbForm"] === "Ger";
44
+
45
+ const isArticle = (token: Token): boolean => token.features?.["PronType"] === "Art";
46
+
47
+ const isPlural = (token: Token): boolean => token.features?.["Number"] === "Plur";
48
+
49
+ /** 名詞の後ろの語で、名詞句が終わるか。分詞(a data driven approach)も名詞を重ねた語の途中。 */
50
+ export const endsNounPhrase = (next: Token | undefined, finite: ReadonlySet<string>): boolean => {
51
+ if (next === undefined || finite.has(lower(next))) return true;
52
+ return next.pos === "PUNCT" ? CLOSING_MARK.has(next.surface) : PHRASE_END.has(next.pos);
53
+ };
54
+
55
+ const wordsOf = (entry: Lexicon[number]): readonly string[] =>
56
+ entry.tokens === undefined ? entry.pattern.toLowerCase().split(/\s+/u) : entry.tokens.map((token) => token.surface.toLowerCase());
57
+
58
+ /** tokens の at 語目から始まる語彙表の句の長さ。無ければ 0。 */
59
+ const phraseAt = (tokens: readonly Token[], at: number, phrases: Lexicon): number => {
60
+ const matched = phrases.map(wordsOf).find((words) => words.every((word, k) => tokens[at + k]?.surface.toLowerCase() === word));
61
+ return matched?.length ?? 0;
62
+ };
63
+
64
+ /** first から last まで、語のあいだが空白だけ。コードの印やリンクを挟めば別々の語。 */
65
+ const joinedBySpace = (source: string, tokens: readonly Token[]): boolean =>
66
+ tokens.slice(1).every((token, k) => SPACE_ONLY.test(source.slice(tokens[k]?.span.end ?? token.span.start, token.span.start)));
67
+
68
+ const opensSentence = (tokens: readonly Token[], at: number): boolean => !tokens.slice(0, at).some((token) => LETTER.test(token.surface));
69
+
70
+ /** 文の途中の大文字の限定詞は、名前の一部(Plan A users)。 */
71
+ const isNamePart = (tokens: readonly Token[], at: number): boolean => UPPER_START.test(tokens[at]?.surface ?? "") && !opensSentence(tokens, at);
72
+
73
+ const isModifier = (token: Token): boolean => token.pos === "ADJ" || token.pos === "ADV" || (token.pos === "VERB" && isParticiple(token));
74
+
75
+ /** 限定詞の後ろの修飾語。数の形容詞(a few days)が挟まれば、単数の限定詞でも複数を数えるので undefined。 */
76
+ const modifiersAfter = (tokens: readonly Token[], from: number, count: ReadonlySet<string>): readonly Token[] | undefined => {
77
+ const end = tokens.findIndex((token, index) => index >= from && !isModifier(token));
78
+ const modifiers = tokens.slice(from, end === -1 ? tokens.length : end);
79
+ return modifiers.some((token) => count.has(lower(token))) ? undefined : modifiers;
80
+ };
81
+
82
+ const isGuessed = (token: Token): boolean => token.features?.["Guess"] === "Yes";
83
+
84
+ /** 単数と複数が同じ綴りの語、複数と付いても原形が同じ語(lens)、解析器が形から当てた語(stimuli)は、どちらの数とも決まらない。 */
85
+ const hasOneNumber = (noun: Token, invariant: ReadonlySet<string>): boolean =>
86
+ !invariant.has(lower(noun)) && !isGuessed(noun) && !(isPlural(noun) && noun.lemma?.toLowerCase() === lower(noun));
87
+
88
+ const isCommonNoun = (token: Token | undefined): token is Token => token?.pos === "NOUN" && !UPPER_START.test(token.surface);
89
+
90
+ type Determiner = { readonly head: Token; readonly length: number; readonly plural: boolean };
91
+
92
+ const determinerAt = (tokens: readonly Token[], at: number, lists: AgreementLists): Determiner | undefined => {
93
+ const head = tokens[at];
94
+ const plural = phraseAt(tokens, at, lists.plural);
95
+ const singular = phraseAt(tokens, at, lists.singular);
96
+ if (head === undefined || plural + singular === 0) return undefined;
97
+ return plural > 0 ? { head, length: plural, plural: true } : { head, length: singular, plural: false };
98
+ };
99
+
100
+ /**
101
+ * 冠詞と句でない限定詞(this / these)は代名詞にもなる。This results in a loss の results は動詞なので、
102
+ * 形容詞を挟んだ名詞(this new rules)だけを数える。
103
+ */
104
+ const standsAlone = (determiner: Determiner): boolean => determiner.length === 1 && !isArticle(determiner.head);
105
+
106
+ const readsAsDeterminer = (determiner: Determiner, modifiers: readonly Token[]): boolean =>
107
+ !standsAlone(determiner) || modifiers.some((token) => token.pos === "ADJ");
108
+
109
+ const mayBeNoun = (token: Token): boolean => token.features?.["AlsoNoun"] === "Yes" || isGuessed(token);
110
+
111
+ /**
112
+ * 名詞が動詞にも読め(works)、その前に名詞句の頭になれる語があれば、限定詞と名詞ではなく主語と動詞かもしれない。
113
+ * an individual works(individual は名詞にもなる)/ Those responsible report to the board(those は代名詞にもなる)。
114
+ * 後ろが is / has なら、名詞は動詞ではない(these key result is)。
115
+ */
116
+ const mayBeVerb = (determiner: Determiner, modifiers: readonly Token[], noun: Token, next: Token | undefined, finite: ReadonlySet<string>): boolean =>
117
+ noun.features?.["AlsoVerb"] === "Yes" && !(next !== undefined && finite.has(lower(next))) && (standsAlone(determiner) || modifiers.some(mayBeNoun));
118
+
119
+ const numberSlipAt = (source: string, tokens: readonly Token[], at: number, lists: AgreementLists): Slip | undefined => {
120
+ const determiner = determinerAt(tokens, at, lists);
121
+ if (determiner === undefined || isNamePart(tokens, at)) return undefined;
122
+ const modifiers = modifiersAfter(tokens, at + determiner.length, lists.count);
123
+ if (modifiers === undefined || !readsAsDeterminer(determiner, modifiers)) return undefined;
124
+ const nounAt = at + determiner.length + modifiers.length;
125
+ const noun = tokens[nounAt];
126
+ if (!isCommonNoun(noun) || !hasOneNumber(noun, lists.invariant) || isPlural(noun) === determiner.plural) return undefined;
127
+ if (mayBeVerb(determiner, modifiers, noun, tokens[nounAt + 1], lists.finite)) return undefined;
128
+ const span = tokens.slice(at, nounAt + 1);
129
+ if (!endsNounPhrase(tokens[nounAt + 1], lists.finite) || !joinedBySpace(source, span) || isPartOfLongerWord(source, determiner.head, noun)) return undefined;
130
+ return slipOf("number", source, determiner.head, noun);
131
+ };
132
+
133
+ const slipOf = (variant: Slip["variant"], source: string, first: Token, last: Token): Slip => ({
134
+ variant,
135
+ first,
136
+ last,
137
+ word: source.slice(first.span.start, last.span.end).replace(/\s+/gu, " "),
138
+ });
139
+
140
+ const isBaseVerb = (token: Token | undefined): boolean => token?.pos === "VERB" && token.features?.["VerbForm"] === undefined;
141
+
142
+ /** 所有の語の直後の be(their is)か、助動詞 + 動詞の原形(Your can check)。名詞にもなる助動詞(your will)は動詞が続くときだけ。 */
143
+ const possessiveSlipAt = (source: string, tokens: readonly Token[], at: number, lists: AgreementLists): Slip | undefined => {
144
+ const [possessive, verb] = [tokens[at], tokens[at + 1]];
145
+ if (possessive === undefined || verb === undefined || !lists.possessive.has(lower(possessive))) return undefined;
146
+ // 大文字だけの語は略語(Your AM = account manager)。
147
+ const verbal = LOWER.test(verb.surface) && (lists.finite.has(lower(verb)) || (verb.pos === "AUX" && isBaseVerb(tokens[at + 2])));
148
+ if (!verbal || !joinedBySpace(source, [possessive, verb]) || isPartOfLongerWord(source, possessive, verb)) return undefined;
149
+ return slipOf("possessive", source, possessive, verb);
150
+ };
151
+
152
+ /** ones of the most。前に限定詞・形容詞・代名詞があれば、ones は代名詞(the ones of the most use)。 */
153
+ const misnumberedAt = (source: string, tokens: readonly Token[], at: number, lists: AgreementLists): Slip | undefined => {
154
+ const span = tokens.slice(at, at + phraseAt(tokens, at, lists.misnumbered));
155
+ const [first, last, previous] = [span[0], span.at(-1), tokens[at - 1]];
156
+ if (first === undefined || last === undefined || (previous !== undefined && PREDICATE_TAIL.has(previous.pos))) return undefined;
157
+ return joinedBySpace(source, span) ? slipOf("number", source, first, last) : undefined;
158
+ };
159
+
160
+ export const agreementIn = (source: string, tokens: readonly Token[], lists: AgreementLists): Slip[] =>
161
+ tokens.flatMap((_, at) => {
162
+ const slip = numberSlipAt(source, tokens, at, lists) ?? possessiveSlipAt(source, tokens, at, lists) ?? misnumberedAt(source, tokens, at, lists);
163
+ return slip === undefined ? [] : [slip];
164
+ });
165
+
166
+ const setOf = (lexicon: Lexicon | undefined): ReadonlySet<string> => new Set((lexicon ?? []).map((entry) => entry.pattern.toLowerCase()));
167
+
168
+ const listsOf = (lexicons: Readonly<Record<string, Lexicon>>): AgreementLists => ({
169
+ singular: lexicons["singular-determiner"] ?? [],
170
+ plural: lexicons["plural-determiner"] ?? [],
171
+ invariant: setOf(lexicons["invariant-noun"]),
172
+ count: setOf(lexicons["count-adjective"]),
173
+ possessive: setOf(lexicons["dependent-possessive"]),
174
+ finite: setOf(lexicons["finite-auxiliary"]),
175
+ misnumbered: lexicons["misnumbered-phrase"] ?? [],
176
+ });
177
+
178
+ export const agreementSlip: Detector = (doc): Finding[] => {
179
+ const lists = listsOf(doc.lexicons);
180
+ return doc.sentences.flatMap((sentence) =>
181
+ agreementIn(doc.source, sentence.tokens ?? [], lists).map((slip): Finding => ({
182
+ rule: "agreement-slip",
183
+ severity: "warning",
184
+ line: 0,
185
+ column: 0,
186
+ quote: sentence.text.trim(),
187
+ values: { word: slip.word, offset: slip.first.span.start },
188
+ variant: slip.variant,
189
+ })),
190
+ );
191
+ };
@@ -0,0 +1,19 @@
1
+ // What a sentence adds to a heading, counted in content morphemes: words that stand by themselves and name something.
2
+ // Pure; heading-echo measures a language written without spaces with it, where a character count says nothing
3
+ // (「会社に勤めています」 carries two new words, 「について説明します」 one, in the same nine characters).
4
+ import type { Token } from "../plugin.ts";
5
+
6
+ /** 代名詞・連体詞・接続詞は、すでに書いたものを指すかつなぐだけで、中身を足さない。 */
7
+ const CONTENT_POS = new Set(["NOUN", "PROPN", "NUM", "VERB", "ADJ", "ADV"]);
8
+
9
+ /** 接頭詞・接尾・非自立の語(Bound)と、動詞にするだけの「する」(VerbType=Light)は、付いた語が中身を持つ。 */
10
+ const isContent = (token: Token): boolean => CONTENT_POS.has(token.pos) && token.features?.["Bound"] !== "Yes" && token.features?.["VerbType"] !== "Light";
11
+
12
+ /** 原形で照らす(変えました → 変える)。原形の無い語は書いた形で。 */
13
+ const baseForm = (token: Token): string => (token.lemma ?? token.surface).toLowerCase();
14
+
15
+ /** The sentence's content morphemes whose base form is not one of the heading's content morphemes, every occurrence counted. */
16
+ export const newContentMorphemes = (heading: readonly Token[], sentence: readonly Token[]): number => {
17
+ const said = new Set(heading.filter(isContent).map(baseForm));
18
+ return sentence.filter((token) => isContent(token) && !said.has(baseForm(token))).length;
19
+ };
@@ -134,272 +134,3 @@ export const titleCaseMix: Detector = (doc, options): Finding[] => {
134
134
  values: { count: few.length, limit: options.limit, offset: section.span.start },
135
135
  }));
136
136
  };
137
-
138
- const LIST_CONJUNCTION = new Set(["and", "or"]);
139
-
140
- const isComma = (token: Token): boolean => token.surface === ",";
141
-
142
- const commaBefore = (tokens: readonly Token[], at: number): boolean => tokens[at - 1]?.surface === ",";
143
-
144
- /** 並列はこれをまたがない。セミコロンの前後は別の節。 */
145
- const CLAUSE_BREAK = new Set([";", ":", "—"]);
146
-
147
- /** 各 token の前で閉じていない括弧の数。括弧の中の読点(external users (e.g., guests), and ...)は外の並列を切らない。 */
148
- const PAREN_STEP: Readonly<Record<string, number>> = { "(": 1, ")": -1 };
149
-
150
- const depthsOf = (tokens: readonly Token[]): number[] =>
151
- tokens.reduce<{ depths: number[]; open: number }>(
152
- (acc, token) => ({ depths: [...acc.depths, acc.open], open: Math.max(0, acc.open + (PAREN_STEP[token.surface] ?? 0)) }),
153
- { depths: [], open: 0 },
154
- ).depths;
155
-
156
- /** participleWords は、解析器が分詞と読まないが読点のあとで分詞の句を始める語(meaning)。 */
157
- type Clause = { readonly tokens: readonly Token[]; readonly depths: readonly number[]; readonly participleWords: ReadonlySet<string> };
158
-
159
- /** 冠詞や引用符を飛ばした、項目の頭の品詞。the parser と an exporter と samples を同じ形と見る。 */
160
- const NOMINAL = new Set(["NOUN", "PROPN", "PRON", "NUM", "ADJ"]);
161
-
162
- const isContent = (token: Token): boolean => token.pos !== "DET" && token.pos !== "PUNCT" && token.pos !== "X";
163
-
164
- const shapeOf = (item: readonly Token[]): string | undefined => {
165
- const head = item.find(isContent);
166
- if (head === undefined) return undefined;
167
- return NOMINAL.has(head.pos) ? "NOMINAL" : head.pos;
168
- };
169
-
170
- const VERBAL = new Set(["VERB", "AUX"]);
171
-
172
- /** 主語と述語のある項目。the team fixed the bug / these are crucial。gets us more は述語だけ。 */
173
- const isClause = (item: readonly Token[]): boolean => {
174
- const first = item.find((token) => token.pos !== "PUNCT" && token.pos !== "X");
175
- if (first === undefined || !(first.pos === "DET" || NOMINAL.has(first.pos))) return false;
176
- return item.some((token) => token !== first && VERBAL.has(token.pos));
177
- };
178
-
179
- /** 節の頭から and / or の手前までを、同じ深さの読点で項目に切る。Oxford comma の読点のあとは空なので項目にならない。 */
180
- const itemsBefore = (clause: Clause, at: number): Token[][] => {
181
- const level = clause.depths[at] ?? 0;
182
- const start = clause.tokens.slice(0, at).findLastIndex((token) => CLAUSE_BREAK.has(token.surface)) + 1;
183
- return clause.tokens
184
- .slice(start, at)
185
- .reduce<Token[][]>(
186
- (items, token, offset) => {
187
- if (isComma(token) && clause.depths[start + offset] === level) return [...items, []];
188
- return [...items.slice(0, -1), [...(items.at(-1) ?? []), token]];
189
- },
190
- [[]],
191
- )
192
- .filter((item) => item.length > 0);
193
- };
194
-
195
- /**
196
- * and / or で始まる項目で、前の並びは閉じている(offering, giving, or receiving, directly or indirectly)。
197
- * 次の並びはその項目から始まる。項目が動詞で始まれば、その目的語から(Develop, maintain, and track courses, materials and events)。
198
- */
199
- const afterClosedList = (items: readonly Token[][]): Token[][] => {
200
- const closed = items.findLastIndex((item) => LIST_CONJUNCTION.has(openingOf(item)?.surface.toLowerCase() ?? ""));
201
- const closing = items[closed];
202
- if (closing === undefined) return [...items];
203
- const body = closing.slice(closing.findIndex((token) => LIST_CONJUNCTION.has(token.surface.toLowerCase())) + 1);
204
- const head = body.findIndex(isContent);
205
- const rest = VERBAL.has(body[head]?.pos ?? "") ? body.slice(head + 1) : body;
206
- // 項目が残らなければ、後ろは新しい句の頭。導入の句(directly or indirectly,)を外す。
207
- return rest.some(isContent) ? [rest, ...items.slice(closed + 1)] : [...withoutLead(items.slice(closed + 1))];
208
- };
209
-
210
- /** and / or の後ろの項目。次の読点か節の切れ目まで。 */
211
- const itemAfter = (tokens: readonly Token[], at: number): Token[] => {
212
- const end = tokens.findIndex((token, index) => index > at && (isComma(token) || CLAUSE_BREAK.has(token.surface)));
213
- return tokens.slice(at + 1, end === -1 ? undefined : end);
214
- };
215
-
216
- /**
217
- * 導入の句(After the review, / If it fails, / Finally, / To test it, / Based on the review,)は並列の項目ではない。
218
- * 節の最初の項目が前置詞・接続詞・副詞・to・過去分詞で始まり、次の項目と頭の形が違えば外す。
219
- * Quickly, quietly and carefully は残る。For managers, engineers and designers, も外れるが、前置詞のあとの名詞を
220
- * 項目と読むと、Over this period, the subcommittees and the full committee ... を並びと誤る。こちらのほうが多い。
221
- */
222
- const LEAD_POS = new Set(["ADP", "SCONJ", "ADV", "PART"]);
223
-
224
- const isParticiple = (token: Token): boolean => token.features?.["VerbForm"] === "Part";
225
-
226
- const openingOf = (item: readonly Token[]): Token | undefined => item.find((token) => token.pos !== "PUNCT" && token.pos !== "X");
227
-
228
- const openingKind = (item: readonly Token[]): string | undefined => {
229
- const opening = openingOf(item);
230
- return opening !== undefined && isParticiple(opening) ? "PARTICIPLE" : shapeOf(item);
231
- };
232
-
233
- const withoutLead = (items: readonly Token[][]): readonly Token[][] => {
234
- const [first, second] = items;
235
- const opening = first === undefined ? undefined : openingOf(first);
236
- if (first === undefined || second === undefined || opening === undefined) return items;
237
- if (!LEAD_POS.has(opening.pos) && !isParticiple(opening)) return items;
238
- return openingKind(first) === openingKind(second) ? items : items.slice(1);
239
- };
240
-
241
- /**
242
- * 形容詞のあとの読点は、名詞の前で形容詞を重ねているだけのことが多い(the long, winding bridge)。
243
- * 次の項目が名詞で終わるならつなげ直す。形容詞そのものの並び(quick, cheap and reliable)は切ったまま。
244
- */
245
- const lastContent = (item: readonly Token[]): Token | undefined => item.findLast(isContent);
246
-
247
- const joinAdjectives = (items: readonly Token[][]): Token[][] =>
248
- items.reduce<Token[][]>((joined, item) => {
249
- const previous = joined.at(-1);
250
- const stacked = previous !== undefined && lastContent(previous)?.pos === "ADJ" && lastContent(item)?.pos !== "ADJ";
251
- if (stacked) joined[joined.length - 1] = [...previous, ...item];
252
- else joined.push(item);
253
- return joined;
254
- }, []);
255
-
256
- /**
257
- * 節を並べるなら、どの項目も節。Additionally, others can learn, and the mistake is rarer. の
258
- * Additionally は項目ではなく、and の前の読点は節をつなぐ読点。名詞の並びは最初の項目に前置き
259
- * (We shipped the parser)を抱えるので、この確かめは節の並びだけにする。
260
- * and の後ろだけが節なのは主語の並び(The parser, the renderer and the exporter shipped.)なので外さない。
261
- */
262
- const clausesAgree = (items: readonly Token[][], after: readonly Token[]): boolean => {
263
- const last = items.at(-1);
264
- if (last === undefined || !isClause(last)) return true;
265
- return isClause(after) && items.every(isClause);
266
- };
267
-
268
- /**
269
- * 動詞で始まる項目を並べるなら、どの項目にも動詞がある。The scope, in contrast, is larger, and covers ... の
270
- * The scope は項目ではなく、後ろの述語の主語。
271
- */
272
- const hasVerb = (item: readonly Token[]): boolean => item.some((token) => VERBAL.has(token.pos));
273
-
274
- const predicatesAgree = (items: readonly Token[][], shape: string): boolean => !VERBAL.has(shape) || items.every(hasVerb);
275
-
276
- /** -ing 形(VerbForm=Ger)と過去分詞(Part)。filling the gaps と sentenced to prison は並べても並列にならない。 */
277
- const VERB_FORMS = new Set(["Ger", "Part"]);
278
-
279
- const verbFormOf = (token: Token): string | undefined => {
280
- const form = token.features?.["VerbForm"];
281
- return form !== undefined && VERB_FORMS.has(form) ? form : undefined;
282
- };
283
-
284
- /** 項目の頭の分詞とその形。副詞は飛ばす(originally written)。引用符の中の語(‘modelling’)は語の例なので分詞と読まない。 */
285
- const participleOpening = (item: readonly Token[], words: ReadonlySet<string>): { readonly at: number; readonly form: string } | undefined => {
286
- const at = item.findIndex((token) => token.pos !== "PUNCT" && token.pos !== "ADV");
287
- const opening = item[at];
288
- if (opening === undefined) return undefined;
289
- const form = words.has(opening.surface.toLowerCase()) ? "Ger" : verbFormOf(opening);
290
- return form === undefined ? undefined : { at, form };
291
- };
292
-
293
- const hasParticiple = (item: readonly Token[], form: string): boolean => item.some((token) => verbFormOf(token) === form);
294
-
295
- /**
296
- * 最初より後ろの項目が分詞で始まるとき。最初の項目にも同じ形の分詞があれば分詞の並び
297
- * (are hiring engineers, buying laptops and renting desks / Tested, reviewed and approved)。後ろの項目が動詞で始まれば述語の並びで、
298
- * 分詞は解析器の読み違い(the jury, cross-examined the witnesses, presented witnesses, and made)。そうでなければ分詞の句
299
- * (from Latin, meaning ship or boat / the judge, sitting without a jury, and sentenced)で、並びがあるならその句の中から始まる。
300
- * 分詞より前の項目と分詞そのものは項目ではない(programs, including the office, the desk and the team)。句の中の項目の形が揃わなければ
301
- * 句は挿入で、並びは句の後ろだけ(relief, including an injunction, in any court ... and without / including before the date, to a
302
- * Recipient ... “confidential”, “proprietary”, or the like)。
303
- */
304
- /** 解析器は過去形と過去分詞(fixed)を見分けられない。動詞のある項目のあとの「過去分詞」の並びは、述語の並びと読む。 */
305
- const pastPredicates = (first: readonly Token[], form: string, after: readonly Token[], words: ReadonlySet<string>): boolean =>
306
- form === "Part" && participleOpening(after, words)?.form === "Part" && hasVerb(first);
307
-
308
- const fromParticiplePhrase = (items: readonly Token[][], after: readonly Token[], words: ReadonlySet<string>): readonly Token[][] => {
309
- const at = items.findIndex((item, index) => index > 0 && participleOpening(item, words) !== undefined);
310
- const phrase = items[at];
311
- const first = items[0];
312
- const opening = phrase === undefined ? undefined : participleOpening(phrase, words);
313
- if (first === undefined || phrase === undefined || opening === undefined) return items;
314
- const rest = items.slice(at + 1);
315
- const predicates = rest.some((item) => VERBAL.has(shapeOf(item) ?? "")) || pastPredicates(first, opening.form, after, words);
316
- if (hasParticiple(first, opening.form) || predicates) return items;
317
- const inPhrase = withoutLead([phrase.slice(opening.at + 1), ...rest].filter((item) => item.some(isContent)));
318
- return new Set(inPhrase.map(shapeOf)).size > 1 ? rest : inPhrase;
319
- };
320
-
321
- const APPOSITIVE_ANCHOR = new Set(["NOUN", "PROPN"]);
322
-
323
- /**
324
- * 名詞のすぐ後ろの、読点で挟まれた X and Y は同格(two HPV types, HPV16 and HPV18, that account / Governments, both state and
325
- * federal, spend)。項目が 2 つに見え、and の前に読点が無く、and の後ろの項目が読点で閉じ、後ろの 2 つに動詞が無いときに同格と読む。
326
- * 2 つを結ぶ同格に Oxford comma は打たないので、and の前に読点があれば 3 つの並び(the subcommittee, full committee, and
327
- * chamber levels, as well as)。読点の無い本当の並び(apples, pears and plums, then ...)はこの形と見分けられず、判定から外れる。
328
- */
329
- const isAppositive = (clause: Clause, at: number, items: readonly Token[][], after: readonly Token[]): boolean => {
330
- const [anchor, head] = items;
331
- if (items.length !== 2 || anchor === undefined || head === undefined || commaBefore(clause.tokens, at)) return false;
332
- const end = at + 1 + after.length;
333
- // 閉じた読点のあとに and / or が続くなら、並びのあとに節をつないでいる(apples, pears and plums, and went home)。
334
- const joinsClause = LIST_CONJUNCTION.has(clause.tokens[end + 1]?.surface.toLowerCase() ?? "");
335
- const closed = clause.tokens[end]?.surface === "," && clause.depths[end] === clause.depths[at] && !joinsClause;
336
- return closed && APPOSITIVE_ANCHOR.has(lastContent(anchor)?.pos ?? "") && !hasVerb(head) && !hasVerb(after);
337
- };
338
-
339
- /** 副詞を飛ばした頭の形。explain and justify と ultimately ensure は同じ動詞の項目。 */
340
- const leadShape = (item: readonly Token[]): string | undefined => shapeOf(item.filter((token) => token.pos !== "ADV"));
341
-
342
- /**
343
- * and の後ろの項目に読点と、同じ形の項目を連れた and / or が続くなら、この and は項目の中にある(searches and seizures, and
344
- * the Eighth's ban)。頭の形か、節かどうかが違えば別の節をつなぐ and で、この and までが並び(apples, pears and plums, and went home / and figs fell)。
345
- */
346
- const listContinues = (clause: Clause, at: number, after: readonly Token[]): boolean => {
347
- const next = at + 1 + after.length;
348
- if (clause.tokens[next]?.surface !== "," || !LIST_CONJUNCTION.has(clause.tokens[next + 1]?.surface.toLowerCase() ?? "")) return false;
349
- const following = itemAfter(clause.tokens, next + 1);
350
- const shape = leadShape(following);
351
- return shape !== undefined && shape === leadShape(after) && isClause(following) === isClause(after);
352
- };
353
-
354
- /**
355
- * 読点で区切った項目が and / or の前に 2 つ以上あり、最後の項目と and の後ろが同じ形のときだけ並列。
356
- * 導入の読点(After the review, the team fixed the bug and shipped it.)や、節をつなぐ読点
357
- * (We tested it, and the team shipped it.)は、Oxford comma を打つかどうかの選択を見せない。
358
- */
359
- const listAt = (clause: Clause, at: number): boolean | undefined => {
360
- const after = itemAfter(clause.tokens, at);
361
- const items = fromParticiplePhrase(afterClosedList(withoutLead(joinAdjectives(itemsBefore(clause, at)))), after, clause.participleWords);
362
- if (listContinues(clause, at, after) || isAppositive(clause, at, items, after)) return undefined;
363
- const last = items.at(-1);
364
- const shape = last === undefined ? undefined : shapeOf(last);
365
- if (items.length < 2 || shape === undefined || shape !== shapeOf(after)) return undefined;
366
- if (!clausesAgree(items, after) || !predicatesAgree(items, shape)) return undefined;
367
- return commaBefore(clause.tokens, at);
368
- };
369
-
370
- /**
371
- * 3 つ以上の並列の最後の and / or の前に読点を打つか。Oxford comma。
372
- *
373
- * どちらが正しいかは決めない。スタイルガイドで割れる論点に立場を取ると rule ごと無視される。
374
- * 見るのは 1 つの文書で揃っているかだけ。spec §12.3。
375
- */
376
- const oxfordIn = (tokens: readonly Token[], participleWords: ReadonlySet<string>): boolean | undefined => {
377
- const clause = { tokens, depths: depthsOf(tokens), participleWords };
378
- return tokens.reduce<boolean | undefined>(
379
- (found, token, at) => found ?? (at > 0 && LIST_CONJUNCTION.has(token.surface.toLowerCase()) ? listAt(clause, at) : undefined),
380
- undefined,
381
- );
382
- };
383
-
384
- export const oxfordComma: Detector = (doc, options): Finding[] => {
385
- const participleWords = new Set((doc.lexicons["participle-word"] ?? []).map((entry) => entry.pattern.toLowerCase()));
386
- const judged = doc.sentences.flatMap((sentence) => {
387
- const oxford = oxfordIn(sentence.tokens ?? [], participleWords);
388
- return oxford === undefined ? [] : [{ sentence, oxford }];
389
- });
390
- const withComma = judged.filter((entry) => entry.oxford).length;
391
- const minorityUsesComma = withComma <= judged.length - withComma;
392
- const few = minorityUsesComma ? withComma : judged.length - withComma;
393
- // 同数なら少数派は無い。
394
- if (few === 0 || few * 2 === judged.length || few > options.limit) return [];
395
- return judged
396
- .filter((entry) => entry.oxford === minorityUsesComma)
397
- .map(({ sentence }) => ({
398
- rule: "oxford-comma-consistency",
399
- severity: "info",
400
- line: 0,
401
- column: 0,
402
- quote: sentence.text.trim(),
403
- values: { count: few, limit: options.limit, offset: sentence.span.start },
404
- }));
405
- };
@@ -1,4 +1,6 @@
1
- import { lengthOf } from "../measure.ts";
1
+ import { lengthOf, proseText } from "../measure.ts";
2
+ import { newContentMorphemes } from "./content-morphemes.ts";
3
+ import { echoedHeadingUnits, trigrams } from "./heading-overlap.ts";
2
4
  import { handsOver } from "./lead-in.ts";
3
5
  import { withoutQuotedVariants } from "./quoted-variant.ts";
4
6
  import type { Detector, Finding, LengthUnit, ProseDocument, Section } from "../plugin.ts";
@@ -6,17 +8,6 @@ import type { Detector, Finding, LengthUnit, ProseDocument, Section } from "../p
6
8
  /** 見出しが短すぎると、偶然の一致で 100% になる。これ未満の見出しは見ない。 */
7
9
  const MIN_GRAMS = 4;
8
10
 
9
- /**
10
- * 文字 3-gram で測る。
11
- * 語 n-gram にすると wordSplit capability が要り、L1(言語を問わず動く)から外れる。
12
- */
13
- const trigrams = (text: string): Set<string> => {
14
- // 大文字小文字を畳む。英語では見出しが Title Case、本文が小文字になり、
15
- // 同じ語でも一致しなくなる(Generating Output → generated output)。
16
- const clean = [...text.toLowerCase().replace(/\s+/gu, "")];
17
- return new Set(clean.slice(0, Math.max(0, clean.length - 2)).map((__char, index) => clean.slice(index, index + 3).join("")));
18
- };
19
-
20
11
  /**
21
12
  * Jaccard ではなく包含率を使う。
22
13
  *
@@ -39,18 +30,19 @@ const containment = (heading: Set<string>, sentence: Set<string>): number => {
39
30
  */
40
31
  const NEW_MATERIAL = { word: 6, char: 20 };
41
32
 
42
- const headingUnits = (heading: string, unit: LengthUnit): number =>
43
- unit === "word"
44
- ? heading
45
- .trim()
46
- .split(/\s+/u)
47
- .filter((word) => word.length > 0).length
48
- : heading.replace(/\s+/gu, "").length;
33
+ /**
34
+ * 空白で語を分けない言語は、品詞が読めれば文字ではなく見出しに無い内容語で測る。文字数は中身の量を言わない:
35
+ * 「会社に勤めています」は 9 文字で 会社・勤める を足し、「について説明します」は同じ 9 文字で 説明 しか足さない。
36
+ * 内容語 2 つで足りる(こころさんのお父さんは、会社に勤めています。)。日本語の corpus で測った(yarn corpus)。
37
+ */
38
+ const NEW_CONTENT_MORPHEMES = 1;
49
39
 
50
40
  const addsLittle = (section: Section, unit: LengthUnit): boolean => {
51
41
  const first = section.firstSentence;
52
42
  if (first === undefined) return false;
53
- return lengthOf(first, unit) - headingUnits(section.heading, unit) <= NEW_MATERIAL[unit];
43
+ if (unit === "char" && first.tokens !== undefined && section.headingTokens !== undefined)
44
+ return newContentMorphemes(section.headingTokens, first.tokens) <= NEW_CONTENT_MORPHEMES;
45
+ return lengthOf(first, unit) - echoedHeadingUnits(section.heading, proseText(first), unit) <= NEW_MATERIAL[unit];
54
46
  };
55
47
 
56
48
  /** 最初の文が、同じ節の後ろ(箇条書き・表・コード)へ読者を渡している。 */
@@ -0,0 +1,65 @@
1
+ // How much of a section's heading its first sentence takes up: the heading's character trigrams, and the part of the
2
+ // heading the sentence repeats, in the document's length unit. Pure; heading-echo decides with these.
3
+ import type { LengthUnit } from "../plugin.ts";
4
+
5
+ const foldedChars = (text: string): string[] => [...text.toLowerCase().replace(/\s+/gu, "")];
6
+
7
+ const trigramAt = (chars: readonly string[], index: number): string => chars.slice(index, index + 3).join("");
8
+
9
+ /**
10
+ * 文字 3-gram で測る。
11
+ * 語 n-gram にすると wordSplit capability が要り、L1(言語を問わず動く)から外れる。
12
+ */
13
+ export const trigrams = (text: string): Set<string> => {
14
+ // 大文字小文字を畳む。英語では見出しが Title Case、本文が小文字になり、
15
+ // 同じ語でも一致しなくなる(Generating Output → generated output)。
16
+ const clean = foldedChars(text);
17
+ return new Set(clean.slice(0, Math.max(0, clean.length - 2)).map((__char, index) => trigramAt(clean, index)));
18
+ };
19
+
20
+ /** A word as compared across heading and sentence: case folded, the punctuation around it dropped ("Spacewalks?" is spacewalks). */
21
+ const foldedWord = (word: string): string => {
22
+ const chars = [...word.toLowerCase()];
23
+ const isWordChar = (char: string): boolean => /[\p{L}\p{N}]/u.test(char);
24
+ return chars.slice(chars.findIndex(isWordChar), chars.findLastIndex(isWordChar) + 1).join("");
25
+ };
26
+
27
+ const headingWords = (heading: string): string[] =>
28
+ heading
29
+ .trim()
30
+ .split(/\s+/u)
31
+ .filter((word) => word.length > 0);
32
+
33
+ /** Below this length a word repeats another only as itself: "on" is not repeated by "one", nor "go" by "going". */
34
+ const MIN_STEM = 4;
35
+
36
+ /** The same word, or one of them the other with an ending added (level → levels, reference → references). */
37
+ const isSameWord = (headingWord: string, sentenceWord: string): boolean => {
38
+ if (headingWord === sentenceWord) return true;
39
+ const [shorter, longer] = headingWord.length < sentenceWord.length ? [headingWord, sentenceWord] : [sentenceWord, headingWord];
40
+ return shorter.length >= MIN_STEM && longer.startsWith(shorter);
41
+ };
42
+
43
+ /** The heading's words that the sentence repeats; a word of punctuation alone is never repeated. */
44
+ const echoedWords = (heading: string, sentence: string): number => {
45
+ const said = sentence.split(/\s+/u).map(foldedWord);
46
+ return headingWords(heading)
47
+ .map(foldedWord)
48
+ .filter((word) => word !== "" && said.some((other) => isSameWord(word, other))).length;
49
+ };
50
+
51
+ /** The heading's characters (spaces aside) that a trigram the sentence also has covers. */
52
+ const echoedChars = (heading: string, sentence: string): number => {
53
+ const said = trigrams(sentence);
54
+ const clean = foldedChars(heading);
55
+ const covered = new Set(clean.flatMap((__char, index) => (said.has(trigramAt(clean, index)) ? [index, index + 1, index + 2] : [])));
56
+ return covered.size;
57
+ };
58
+
59
+ /**
60
+ * How much of the heading the sentence repeats, in the document's length unit. A sentence that repeats only part of it
61
+ * ("How Do Astronauts Go on Spacewalks?" → "When astronauts go on spacewalks, they wear spacesuits…") keeps the rest of
62
+ * its length as its own material.
63
+ */
64
+ export const echoedHeadingUnits = (heading: string, sentence: string, unit: LengthUnit): number =>
65
+ unit === "word" ? echoedWords(heading, sentence) : echoedChars(heading, sentence);
@@ -7,15 +7,18 @@ import { sentenceRhythm } from "./sentence-rhythm.ts";
7
7
  import { phraseCount, phraseMatch } from "./phrase-match.ts";
8
8
  import { agentlessPassive } from "./agentless-passive.ts";
9
9
  import { doubledWord } from "./doubled-word.ts";
10
+ import { agreementSlip } from "./agreement-slip.ts";
10
11
  import { sentenceEnding } from "./sentence-ending.ts";
11
12
  import { doubledParticle, nounEnding } from "./token-shape.ts";
12
13
  import { kanjiRun, middleDot } from "./char-shape.ts";
13
- import { adverbDensity, conjunctionRun, expletive, oxfordComma, titleCaseMix } from "./en-shape.ts";
14
+ import { adverbDensity, conjunctionRun, expletive, titleCaseMix } from "./en-shape.ts";
15
+ import { oxfordComma } from "./oxford-comma.ts";
14
16
  import { paragraphLength, paragraphVariance, preambleLength, ruleOfThree, sectionUniformity } from "./structure.ts";
15
17
  import { concreteEvidence, dashDensity, emojiDensity, ngramRepetition, undefinedAcronym } from "./signals.ts";
16
18
  import { aiTell, contractionMix, cushionDensity, hedging, repeatedConjunction, unqualifiedSuperlative } from "./lexicon.ts";
17
19
  import { internalJargon, properNounDensity, requiredSections } from "./team.ts";
18
20
  import { latinSpacing, preferredTerm } from "./orthography.ts";
21
+ import { straySpace } from "./stray-space.ts";
19
22
  import { danglingReference, dateOrder, dateWeekdayMismatch, duplicateDefinition, numberingGap, totalMismatch } from "./structure-tree.ts";
20
23
 
21
24
  /** rule 定義の how_to_find がここを引く。rule 側は実装を知らない。 */
@@ -32,6 +35,7 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
32
35
  "noun-ending": nounEnding,
33
36
  "doubled-particle": doubledParticle,
34
37
  "doubled-word": doubledWord,
38
+ "agreement-slip": agreementSlip,
35
39
  "kanji-run": kanjiRun,
36
40
  "middle-dot": middleDot,
37
41
  "adverb-density": adverbDensity,
@@ -60,6 +64,7 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
60
64
  "proper-noun-density": properNounDensity,
61
65
  "preferred-term": preferredTerm,
62
66
  "latin-spacing": latinSpacing,
67
+ "stray-space": straySpace,
63
68
  "dangling-reference": danglingReference,
64
69
  "date-weekday-mismatch": dateWeekdayMismatch,
65
70
  "date-order": dateOrder,