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.
Files changed (97) hide show
  1. package/dist/ai-score/human-shares.js +59 -59
  2. package/dist/ai-score/human-shares.js.map +1 -1
  3. package/dist/capitals-with-small.d.ts +8 -0
  4. package/dist/capitals-with-small.d.ts.map +1 -0
  5. package/dist/capitals-with-small.js +20 -0
  6. package/dist/capitals-with-small.js.map +1 -0
  7. package/dist/derived/relative-weekdays.d.ts +16 -0
  8. package/dist/derived/relative-weekdays.d.ts.map +1 -0
  9. package/dist/derived/relative-weekdays.js +52 -0
  10. package/dist/derived/relative-weekdays.js.map +1 -0
  11. package/dist/detectors/change-rate.d.ts +3 -0
  12. package/dist/detectors/change-rate.d.ts.map +1 -0
  13. package/dist/detectors/change-rate.js +125 -0
  14. package/dist/detectors/change-rate.js.map +1 -0
  15. package/dist/detectors/derived-numbers.d.ts.map +1 -1
  16. package/dist/detectors/derived-numbers.js +3 -7
  17. package/dist/detectors/derived-numbers.js.map +1 -1
  18. package/dist/detectors/document-date.d.ts +4 -0
  19. package/dist/detectors/document-date.d.ts.map +1 -0
  20. package/dist/detectors/document-date.js +12 -0
  21. package/dist/detectors/document-date.js.map +1 -0
  22. package/dist/detectors/orthographic-variant.d.ts.map +1 -1
  23. package/dist/detectors/orthographic-variant.js +16 -11
  24. package/dist/detectors/orthographic-variant.js.map +1 -1
  25. package/dist/detectors/registry/change-rate.d.ts +3 -0
  26. package/dist/detectors/registry/change-rate.d.ts.map +1 -0
  27. package/dist/detectors/registry/change-rate.js +3 -0
  28. package/dist/detectors/registry/change-rate.js.map +1 -0
  29. package/dist/detectors/registry/version-mismatch.d.ts +3 -0
  30. package/dist/detectors/registry/version-mismatch.d.ts.map +1 -0
  31. package/dist/detectors/registry/version-mismatch.js +3 -0
  32. package/dist/detectors/registry/version-mismatch.js.map +1 -0
  33. package/dist/detectors/relative-dates.d.ts.map +1 -1
  34. package/dist/detectors/relative-dates.js +3 -1
  35. package/dist/detectors/relative-dates.js.map +1 -1
  36. package/dist/detectors/relative-day-words.d.ts +4 -0
  37. package/dist/detectors/relative-day-words.d.ts.map +1 -0
  38. package/dist/detectors/relative-day-words.js +105 -0
  39. package/dist/detectors/relative-day-words.js.map +1 -0
  40. package/dist/detectors/structure-tree.d.ts +4 -1
  41. package/dist/detectors/structure-tree.d.ts.map +1 -1
  42. package/dist/detectors/structure-tree.js +65 -3
  43. package/dist/detectors/structure-tree.js.map +1 -1
  44. package/dist/detectors/version-mismatch.d.ts +3 -0
  45. package/dist/detectors/version-mismatch.d.ts.map +1 -0
  46. package/dist/detectors/version-mismatch.js +19 -0
  47. package/dist/detectors/version-mismatch.js.map +1 -0
  48. package/dist/structure/change-rate.d.ts +51 -0
  49. package/dist/structure/change-rate.d.ts.map +1 -0
  50. package/dist/structure/change-rate.js +171 -0
  51. package/dist/structure/change-rate.js.map +1 -0
  52. package/dist/structure/list-numbering.d.ts +17 -0
  53. package/dist/structure/list-numbering.d.ts.map +1 -0
  54. package/dist/structure/list-numbering.js +27 -0
  55. package/dist/structure/list-numbering.js.map +1 -0
  56. package/dist/structure/percent-sum-prose.d.ts +7 -0
  57. package/dist/structure/percent-sum-prose.d.ts.map +1 -0
  58. package/dist/structure/percent-sum-prose.js +44 -0
  59. package/dist/structure/percent-sum-prose.js.map +1 -0
  60. package/dist/structure/version-mismatch.d.ts +23 -0
  61. package/dist/structure/version-mismatch.d.ts.map +1 -0
  62. package/dist/structure/version-mismatch.js +74 -0
  63. package/dist/structure/version-mismatch.js.map +1 -0
  64. package/dist/structure/weekday.d.ts +21 -2
  65. package/dist/structure/weekday.d.ts.map +1 -1
  66. package/dist/structure/weekday.js +70 -8
  67. package/dist/structure/weekday.js.map +1 -1
  68. package/package.json +3 -3
  69. package/rules/agreement-slip.yaml +2 -2
  70. package/rules/change-rate-mismatch.yaml +44 -0
  71. package/rules/date-weekday-mismatch.yaml +4 -4
  72. package/rules/max-sentence-length.yaml +1 -0
  73. package/rules/numbering-gap.yaml +4 -4
  74. package/rules/one-sentence-paragraph-run.yaml +1 -0
  75. package/rules/orthographic-variant.yaml +3 -3
  76. package/rules/percent-sum-mismatch.yaml +4 -4
  77. package/rules/relative-date-mismatch.yaml +4 -4
  78. package/rules/undefined-acronym.yaml +1 -0
  79. package/rules/version-mismatch.yaml +77 -0
  80. package/src/ai-score/human-shares.ts +59 -59
  81. package/src/capitals-with-small.ts +23 -0
  82. package/src/derived/relative-weekdays.ts +65 -0
  83. package/src/detectors/change-rate.ts +153 -0
  84. package/src/detectors/derived-numbers.ts +3 -11
  85. package/src/detectors/document-date.ts +18 -0
  86. package/src/detectors/orthographic-variant.ts +8 -4
  87. package/src/detectors/registry/change-rate.ts +4 -0
  88. package/src/detectors/registry/version-mismatch.ts +4 -0
  89. package/src/detectors/relative-dates.ts +3 -1
  90. package/src/detectors/relative-day-words.ts +126 -0
  91. package/src/detectors/structure-tree.ts +76 -4
  92. package/src/detectors/version-mismatch.ts +23 -0
  93. package/src/structure/change-rate.ts +223 -0
  94. package/src/structure/list-numbering.ts +34 -0
  95. package/src/structure/percent-sum-prose.ts +54 -0
  96. package/src/structure/version-mismatch.ts +103 -0
  97. package/src/structure/weekday.ts +83 -6
@@ -4,47 +4,47 @@ import type { HumanShares } from "./score.ts";
4
4
  /** On how many human documents of each genre group each AI-shape rule reported, at the genre's level. */
5
5
  export const HUMAN_SHARES: HumanShares = {
6
6
  "ai-tell": {
7
- academic: { documents: 6, fired: 0 },
7
+ academic: { documents: 10, fired: 0 },
8
8
  blog: { documents: 23, fired: 0 },
9
- business: { documents: 78, fired: 0 },
10
- docs: { documents: 20, fired: 0 },
9
+ business: { documents: 80, fired: 0 },
10
+ docs: { documents: 21, fired: 0 },
11
11
  legal: { documents: 35, fired: 0 },
12
12
  literature: { documents: 13, fired: 0 },
13
13
  speech: { documents: 7, fired: 0 },
14
14
  technical: { documents: 26, fired: 0 },
15
15
  },
16
16
  "announcing-opener": {
17
- academic: { documents: 6, fired: 0 },
17
+ academic: { documents: 10, fired: 0 },
18
18
  blog: { documents: 23, fired: 0 },
19
- business: { documents: 78, fired: 0 },
20
- docs: { documents: 20, fired: 0 },
19
+ business: { documents: 80, fired: 0 },
20
+ docs: { documents: 21, fired: 0 },
21
21
  legal: { documents: 35, fired: 0 },
22
22
  literature: { documents: 13, fired: 0 },
23
23
  speech: { documents: 7, fired: 0 },
24
24
  technical: { documents: 26, fired: 0 },
25
25
  },
26
26
  "assistant-residue": {
27
- academic: { documents: 6, fired: 0 },
27
+ academic: { documents: 10, fired: 0 },
28
28
  blog: { documents: 23, fired: 0 },
29
- business: { documents: 78, fired: 0 },
30
- docs: { documents: 20, fired: 0 },
29
+ business: { documents: 80, fired: 0 },
30
+ docs: { documents: 21, fired: 0 },
31
31
  legal: { documents: 35, fired: 0 },
32
32
  literature: { documents: 13, fired: 0 },
33
33
  speech: { documents: 7, fired: 0 },
34
- technical: { documents: 48, fired: 0 },
34
+ technical: { documents: 49, fired: 0 },
35
35
  },
36
36
  "bold-density": {
37
- academic: { documents: 6, fired: 0 },
37
+ academic: { documents: 10, fired: 0 },
38
38
  blog: { documents: 23, fired: 0 },
39
- business: { documents: 78, fired: 1 },
40
- docs: { documents: 20, fired: 0 },
39
+ business: { documents: 80, fired: 1 },
40
+ docs: { documents: 21, fired: 0 },
41
41
  legal: { documents: 35, fired: 0 },
42
42
  literature: { documents: 13, fired: 0 },
43
43
  speech: { documents: 7, fired: 0 },
44
- technical: { documents: 48, fired: 2 },
44
+ technical: { documents: 49, fired: 2 },
45
45
  },
46
46
  "bold-label-list": {
47
- academic: { documents: 1, fired: 0 },
47
+ academic: { documents: 5, fired: 0 },
48
48
  blog: { documents: 14, fired: 0 },
49
49
  business: { documents: 39, fired: 0 },
50
50
  docs: { documents: 10, fired: 0 },
@@ -54,25 +54,25 @@ export const HUMAN_SHARES: HumanShares = {
54
54
  technical: { documents: 13, fired: 1 },
55
55
  },
56
56
  "chat-citation-residue": {
57
- academic: { documents: 6, fired: 0 },
57
+ academic: { documents: 10, fired: 0 },
58
58
  blog: { documents: 23, fired: 0 },
59
- business: { documents: 78, fired: 0 },
60
- docs: { documents: 20, fired: 0 },
59
+ business: { documents: 80, fired: 0 },
60
+ docs: { documents: 21, fired: 0 },
61
61
  legal: { documents: 35, fired: 0 },
62
62
  literature: { documents: 13, fired: 0 },
63
63
  speech: { documents: 7, fired: 0 },
64
- technical: { documents: 48, fired: 0 },
64
+ technical: { documents: 49, fired: 0 },
65
65
  },
66
66
  "closing-cliche": {
67
- academic: { documents: 6, fired: 0 },
67
+ academic: { documents: 10, fired: 0 },
68
68
  blog: { documents: 23, fired: 0 },
69
- docs: { documents: 20, fired: 0 },
69
+ docs: { documents: 21, fired: 0 },
70
70
  legal: { documents: 35, fired: 1 },
71
71
  literature: { documents: 13, fired: 0 },
72
72
  speech: { documents: 7, fired: 0 },
73
73
  },
74
74
  "colon-lead-in": {
75
- academic: { documents: 1, fired: 0 },
75
+ academic: { documents: 5, fired: 0 },
76
76
  blog: { documents: 14, fired: 0 },
77
77
  business: { documents: 39, fired: 0 },
78
78
  docs: { documents: 10, fired: 0 },
@@ -82,110 +82,110 @@ export const HUMAN_SHARES: HumanShares = {
82
82
  technical: { documents: 13, fired: 1 },
83
83
  },
84
84
  "contrast-framing": {
85
- academic: { documents: 6, fired: 0 },
85
+ academic: { documents: 10, fired: 0 },
86
86
  blog: { documents: 23, fired: 0 },
87
- business: { documents: 78, fired: 0 },
88
- docs: { documents: 20, fired: 0 },
87
+ business: { documents: 80, fired: 0 },
88
+ docs: { documents: 21, fired: 0 },
89
89
  legal: { documents: 35, fired: 0 },
90
90
  literature: { documents: 13, fired: 0 },
91
91
  speech: { documents: 7, fired: 0 },
92
92
  technical: { documents: 26, fired: 0 },
93
93
  },
94
94
  "emoji-density": {
95
- academic: { documents: 6, fired: 0 },
95
+ academic: { documents: 10, fired: 0 },
96
96
  blog: { documents: 23, fired: 0 },
97
- business: { documents: 78, fired: 0 },
98
- docs: { documents: 20, fired: 0 },
97
+ business: { documents: 80, fired: 0 },
98
+ docs: { documents: 21, fired: 0 },
99
99
  legal: { documents: 35, fired: 0 },
100
100
  literature: { documents: 13, fired: 0 },
101
101
  speech: { documents: 7, fired: 0 },
102
- technical: { documents: 48, fired: 0 },
102
+ technical: { documents: 49, fired: 0 },
103
103
  },
104
104
  "emoji-heading": {
105
- academic: { documents: 6, fired: 0 },
105
+ academic: { documents: 10, fired: 0 },
106
106
  blog: { documents: 23, fired: 0 },
107
- business: { documents: 77, fired: 1 },
108
- docs: { documents: 20, fired: 0 },
107
+ business: { documents: 78, fired: 1 },
108
+ docs: { documents: 21, fired: 0 },
109
109
  legal: { documents: 28, fired: 0 },
110
110
  literature: { documents: 4, fired: 0 },
111
111
  speech: { documents: 7, fired: 0 },
112
112
  },
113
113
  "figurative-density": {
114
- academic: { documents: 6, fired: 0 },
114
+ academic: { documents: 10, fired: 0 },
115
115
  blog: { documents: 23, fired: 0 },
116
- business: { documents: 78, fired: 0 },
117
- docs: { documents: 20, fired: 0 },
116
+ business: { documents: 80, fired: 0 },
117
+ docs: { documents: 21, fired: 0 },
118
118
  legal: { documents: 35, fired: 0 },
119
119
  literature: { documents: 13, fired: 0 },
120
120
  speech: { documents: 7, fired: 0 },
121
- technical: { documents: 48, fired: 0 },
121
+ technical: { documents: 49, fired: 0 },
122
122
  },
123
123
  "no-em-dash": {
124
- academic: { documents: 6, fired: 0 },
124
+ academic: { documents: 10, fired: 0 },
125
125
  blog: { documents: 23, fired: 0 },
126
- business: { documents: 78, fired: 2 },
127
- docs: { documents: 20, fired: 0 },
126
+ business: { documents: 80, fired: 2 },
127
+ docs: { documents: 21, fired: 0 },
128
128
  legal: { documents: 35, fired: 2 },
129
129
  literature: { documents: 13, fired: 2 },
130
130
  speech: { documents: 7, fired: 0 },
131
- technical: { documents: 48, fired: 0 },
131
+ technical: { documents: 49, fired: 0 },
132
132
  },
133
133
  "padded-intro": {
134
- academic: { documents: 6, fired: 0 },
134
+ academic: { documents: 10, fired: 0 },
135
135
  blog: { documents: 23, fired: 0 },
136
- business: { documents: 46, fired: 1 },
137
- docs: { documents: 20, fired: 0 },
136
+ business: { documents: 47, fired: 1 },
137
+ docs: { documents: 21, fired: 0 },
138
138
  legal: { documents: 35, fired: 0 },
139
139
  literature: { documents: 13, fired: 0 },
140
140
  speech: { documents: 7, fired: 0 },
141
141
  },
142
142
  "paragraph-length-variance": {
143
- academic: { documents: 6, fired: 0 },
143
+ academic: { documents: 10, fired: 0 },
144
144
  blog: { documents: 23, fired: 0 },
145
- docs: { documents: 20, fired: 0 },
145
+ docs: { documents: 21, fired: 0 },
146
146
  legal: { documents: 35, fired: 0 },
147
147
  literature: { documents: 13, fired: 1 },
148
148
  speech: { documents: 7, fired: 0 },
149
149
  },
150
150
  "rule-of-three": {
151
- academic: { documents: 6, fired: 0 },
151
+ academic: { documents: 10, fired: 0 },
152
152
  blog: { documents: 23, fired: 0 },
153
- docs: { documents: 20, fired: 0 },
153
+ docs: { documents: 21, fired: 0 },
154
154
  legal: { documents: 35, fired: 0 },
155
155
  literature: { documents: 13, fired: 0 },
156
156
  speech: { documents: 7, fired: 0 },
157
157
  technical: { documents: 26, fired: 0 },
158
158
  },
159
159
  "section-length-uniformity": {
160
- academic: { documents: 6, fired: 0 },
160
+ academic: { documents: 10, fired: 0 },
161
161
  blog: { documents: 23, fired: 0 },
162
- docs: { documents: 20, fired: 0 },
162
+ docs: { documents: 21, fired: 0 },
163
163
  legal: { documents: 35, fired: 0 },
164
164
  literature: { documents: 13, fired: 1 },
165
165
  speech: { documents: 7, fired: 0 },
166
166
  technical: { documents: 26, fired: 0 },
167
167
  },
168
168
  "sentence-rhythm": {
169
- academic: { documents: 6, fired: 0 },
169
+ academic: { documents: 10, fired: 0 },
170
170
  blog: { documents: 23, fired: 0 },
171
- docs: { documents: 20, fired: 0 },
171
+ docs: { documents: 21, fired: 0 },
172
172
  legal: { documents: 35, fired: 0 },
173
173
  literature: { documents: 13, fired: 1 },
174
174
  speech: { documents: 7, fired: 0 },
175
175
  technical: { documents: 26, fired: 1 },
176
176
  },
177
177
  "stock-transition": {
178
- academic: { documents: 6, fired: 0 },
178
+ academic: { documents: 10, fired: 0 },
179
179
  blog: { documents: 23, fired: 0 },
180
- business: { documents: 78, fired: 0 },
181
- docs: { documents: 20, fired: 0 },
180
+ business: { documents: 80, fired: 0 },
181
+ docs: { documents: 21, fired: 0 },
182
182
  legal: { documents: 35, fired: 0 },
183
183
  literature: { documents: 13, fired: 0 },
184
184
  speech: { documents: 7, fired: 0 },
185
185
  technical: { documents: 26, fired: 0 },
186
186
  },
187
187
  "translationese-density": {
188
- academic: { documents: 1, fired: 0 },
188
+ academic: { documents: 5, fired: 0 },
189
189
  blog: { documents: 14, fired: 0 },
190
190
  business: { documents: 39, fired: 0 },
191
191
  docs: { documents: 10, fired: 0 },
@@ -195,13 +195,13 @@ export const HUMAN_SHARES: HumanShares = {
195
195
  technical: { documents: 22, fired: 0 },
196
196
  },
197
197
  "unfilled-placeholder": {
198
- academic: { documents: 6, fired: 0 },
198
+ academic: { documents: 10, fired: 0 },
199
199
  blog: { documents: 23, fired: 0 },
200
- business: { documents: 78, fired: 1 },
201
- docs: { documents: 20, fired: 0 },
200
+ business: { documents: 80, fired: 1 },
201
+ docs: { documents: 21, fired: 0 },
202
202
  legal: { documents: 35, fired: 0 },
203
203
  literature: { documents: 13, fired: 0 },
204
204
  speech: { documents: 7, fired: 0 },
205
- technical: { documents: 48, fired: 0 },
205
+ technical: { documents: 49, fired: 0 },
206
206
  },
207
207
  };
@@ -0,0 +1,23 @@
1
+ // Words that start with two capitals or more and go on in small letters, where the capitals are not a spelling of the
2
+ // plain word: a name in capitals with an English ending (SENDs, OPENed), or the letters of an acronym picked out in its
3
+ // expansion (CLImatology and PERsistence for CLIPER: part of a longer acronym). A name written that way (HBase, RSpec,
4
+ // SCIMple beside SCIM) is a spelling, and stays one.
5
+
6
+ const CAPITALS_THEN_SMALL = /^(\p{Lu}{2,})\p{Ll}+$/u;
7
+ const CAPITALS_WITH_ENDING = /^\p{Lu}{2,}(?:s|es|d|ed|ing)$/u;
8
+ const CAPITALS_ONLY = /^\p{Lu}[\p{Lu}\d]+$/u;
9
+ const LATIN_RUN = /[\p{L}\d]+/gu;
10
+
11
+ /** The words of a stretch of text written in capitals only (CLIPER, CAMEX), which a picked-out expansion spells. */
12
+ export const acronymsIn = (text: string): string[] => [...text.matchAll(LATIN_RUN)].map((match) => match[0]).filter((word) => CAPITALS_ONLY.test(word));
13
+
14
+ const isPickedOut = (word: string, acronyms: readonly string[]): boolean => {
15
+ const capitals = CAPITALS_THEN_SMALL.exec(word)?.[1];
16
+ return capitals !== undefined && acronyms.some((acronym) => acronym.length > capitals.length && acronym.includes(capitals));
17
+ };
18
+
19
+ /**
20
+ * Whether a word is capitals with an ending, or the letters of an acronym among acronyms. A hyphenated word is compared
21
+ * as written: leaving it out for one part would hide a variant in another (GitHub-SENDs, Github-SENDs).
22
+ */
23
+ export const isCapitalsNotSpelling = (word: string, acronyms: readonly string[]): boolean => CAPITALS_WITH_ENDING.test(word) || isPickedOut(word, acronyms);
@@ -0,0 +1,65 @@
1
+ // A date named by a weekday or a day relative to the document's date (「来週月曜(10月12日)」"next Monday (October 12)",
2
+ // 「明日(10月7日)」) that the absolute date beside it contradicts. Pure; the detector finds the words and the dates.
3
+ import type { Span } from "../plugin.ts";
4
+
5
+ /** 「来週月曜」"next Monday": weeks from the base's week (来週 is 1), and the weekday (Sunday is 0). */
6
+ export type WeekdayRelative = Span & { readonly kind: "weekday"; readonly weeks: number; readonly weekday: number };
7
+ /** 「明日」"tomorrow": days from the base. */
8
+ export type DayRelative = Span & { readonly kind: "day"; readonly days: number };
9
+ export type DayWord = WeekdayRelative | DayRelative;
10
+
11
+ const DAY_MS = 86_400_000;
12
+ const WEEK = 7;
13
+ const MONDAY = 1;
14
+ const FULL = /^(\d{4})-(\d{2})-(\d{2})$/u;
15
+ const MONTH_DAY = /^(\d{2})-(\d{2})$/u;
16
+
17
+ const timeOf = (value: string): number | undefined => {
18
+ const found = FULL.exec(value);
19
+ if (found === null) return undefined;
20
+ const [year, month, day] = [Number(found[1]), Number(found[2]), Number(found[3])];
21
+ const time = Date.UTC(year, month - 1, day);
22
+ const date = new Date(time);
23
+ return date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? time : undefined;
24
+ };
25
+
26
+ const valueOf = (time: number): string => new Date(time).toISOString().slice(0, "2026-10-06".length);
27
+
28
+ /**
29
+ * The days a weekday word can mean. Weeks are counted from Monday or from Sunday, and "next Monday" can also mean the
30
+ * coming Monday: every reading is allowed, so only a date that fits none of them is wrong.
31
+ */
32
+ const weekdayCandidates = (base: number, relative: WeekdayRelative): number[] => {
33
+ const baseDay = new Date(base).getUTCDay();
34
+ const fromMonday = base - ((baseDay - MONDAY + WEEK) % WEEK) * DAY_MS;
35
+ const fromSunday = base - baseDay * DAY_MS;
36
+ const inWeek = (weekStart: number, firstDay: number): number => weekStart + (relative.weeks * WEEK + ((relative.weekday - firstDay + WEEK) % WEEK)) * DAY_MS;
37
+ // "next Monday" and "this Monday" can mean the coming one; "last Monday" the one just past.
38
+ const coming = base + (((relative.weekday - baseDay + WEEK - 1) % WEEK) + 1) * DAY_MS;
39
+ const past = base - (((baseDay - relative.weekday + WEEK - 1) % WEEK) + 1) * DAY_MS;
40
+ const nearest = new Map([
41
+ [0, [coming]],
42
+ [1, [coming]],
43
+ [-1, [past]],
44
+ ]).get(relative.weeks);
45
+ return [inWeek(fromMonday, MONDAY), inWeek(fromSunday, 0), ...(nearest ?? [])];
46
+ };
47
+
48
+ const candidatesOf = (base: number, relative: DayWord): number[] =>
49
+ relative.kind === "day" ? [base + relative.days * DAY_MS] : weekdayCandidates(base, relative);
50
+
51
+ /** Whether the written date (2026-10-12, or 10-12 without its year) is one of the days the word can mean. */
52
+ const fits = (written: string, candidates: readonly number[]): boolean => {
53
+ const monthDay = MONTH_DAY.exec(written);
54
+ return candidates.some((time) => (monthDay === null ? valueOf(time) === written : valueOf(time).slice("2026-".length) === written));
55
+ };
56
+
57
+ /** The day the word means, as the message shows it: the first reading, written as the date was (with or without the year). */
58
+ export const relativeDayMismatch = (base: string, relative: DayWord, written: string): string | undefined => {
59
+ const baseTime = timeOf(base);
60
+ if (baseTime === undefined || (!FULL.test(written) && !MONTH_DAY.test(written))) return undefined;
61
+ const candidates = candidatesOf(baseTime, relative);
62
+ const [first] = candidates;
63
+ if (first === undefined || fits(written, candidates)) return undefined;
64
+ return MONTH_DAY.test(written) ? valueOf(first).slice("2026-".length) : valueOf(first);
65
+ };
@@ -0,0 +1,153 @@
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"), the calendar years, and the lexicons change-direction,
3
+ // change-base, change-target, change-break, percent-unit and amount-multiplier.
4
+ import type { Detector, Finding, LexiconEntry, ProseDocument, Span, StructureNode } from "../plugin.ts";
5
+ import { inDocumentOrder } from "../structure/issues.ts";
6
+ import { changeRateMismatches, type BaseMark, type Break, type Direction, type Figure, type Period, 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
+ /** Where the words of a lexicon stand, less those inside a phrase of its "-not" lexicon ("up to 25%" is a ceiling). */
86
+ const spansWithout = (doc: ProseDocument, entries: readonly LexiconEntry[], notLexicon: string): (Span & { readonly entry: LexiconEntry })[] => {
87
+ const not = patternsOf(doc, notLexicon).flatMap((phrase) => spansOf(doc.source, phrase));
88
+ return entries.flatMap((entry) =>
89
+ spansOf(doc.source, entry.pattern)
90
+ .filter((span) => !not.some((phrase) => phrase.start <= span.start && span.end <= phrase.end))
91
+ .map((span) => ({ ...span, entry })),
92
+ );
93
+ };
94
+
95
+ /** Lexicon change-direction, less the phrases of change-direction-not. */
96
+ const directionsOf = (doc: ProseDocument): Direction[] =>
97
+ spansWithout(doc, doc.lexicons["change-direction"] ?? [], "change-direction-not").flatMap(({ start, end, entry }) => {
98
+ const sign = SIGNS[entry.group ?? ""];
99
+ return sign === undefined ? [] : [{ start, end, sign }];
100
+ });
101
+
102
+ /** Lexicon change-break (「は」, "and"), less the phrases of change-break-not (「上がり」 holds が but starts no subject). */
103
+ const breaksOf = (doc: ProseDocument): Break[] =>
104
+ spansWithout(doc, doc.lexicons["change-break"] ?? [], "change-break-not").map(({ start, end, entry }) => ({
105
+ start,
106
+ end,
107
+ beforeRateOnly: entry.group === "rate",
108
+ }));
109
+
110
+ /** A calendar year written alone or with a unit of calendar year (2025, 2025年, 2025年度), and not part of an amount ($2025). */
111
+ const CALENDAR_YEAR = /(?<!\d|\d[.,])(?:1[89]|2[01])\d{2}(?!\d|[.,]\d)/gu;
112
+
113
+ const periodsOf = (doc: ProseDocument, figures: readonly Figure[]): Period[] => {
114
+ const yearUnits = new Set(patternsOf(doc, "calendar-year-unit").map((unit) => `${unit}|`));
115
+ const amounts = figures.filter((figure) => !yearUnits.has(figure.unit));
116
+ return [...doc.source.matchAll(CALENDAR_YEAR)].flatMap((match) => {
117
+ const span = { start: match.index, end: match.index + match[0].length };
118
+ return amounts.some((figure) => figure.start < span.end && span.start < figure.end) ? [] : [{ ...span, year: Number(match[0]) }];
119
+ });
120
+ };
121
+
122
+ const marksOf = (doc: ProseDocument, lexicon: string): BaseMark[] =>
123
+ (doc.lexicons[lexicon] ?? []).flatMap((entry) =>
124
+ entry.position === undefined ? [] : spansOf(doc.source, entry.pattern).map((span) => ({ ...span, position: entry.position ?? "before" })),
125
+ );
126
+
127
+ export const changeRate: Detector = (doc): Finding[] => {
128
+ if (doc.structure === undefined) return [];
129
+ const nodes = quantityNodes(doc.structure);
130
+ const units = patternsOf(doc, "percent-unit");
131
+ const multipliers = patternsOf(doc, "amount-multiplier").map((word) => word.toLowerCase());
132
+ const taken = nodes.map((node) => node.span);
133
+ const figures = [...treeFigures(doc, nodes, units, multipliers), ...countedFigures(doc, taken, multipliers)];
134
+ const text = {
135
+ sentences: doc.sentences.map((sentence) => sentence.span),
136
+ figures,
137
+ rates: ratesOf(doc, nodes, units),
138
+ directions: directionsOf(doc),
139
+ marks: marksOf(doc, "change-base"),
140
+ targets: marksOf(doc, "change-target"),
141
+ periods: periodsOf(doc, figures),
142
+ breaks: breaksOf(doc),
143
+ source: doc.source,
144
+ };
145
+ return changeRateMismatches(text).map((issue) => ({
146
+ rule: "change-rate-mismatch",
147
+ severity: "warning",
148
+ line: 0,
149
+ column: 0,
150
+ quote: quoteAt(doc.source, issue.offset),
151
+ values: { ...issue.values, offset: issue.offset },
152
+ }));
153
+ };
@@ -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 { dateStampIndexes, stampCandidates } from "../date-stamp.ts";
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 dates = datesOf(tree);
146
- const candidates = stampCandidates(
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
- [...sentence.text.matchAll(LATIN_WORD)].flatMap((match): Placed[] => {
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 =>
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { changeRate } from "../change-rate.ts";
3
+
4
+ export const detector: Detector = changeRate;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { versionMismatch } from "../version-mismatch.ts";
3
+
4
+ export const detector: Detector = versionMismatch;
@@ -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
- return relativeMismatches(input).map((mismatch) => ({
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
  };