chaffjs 0.29.0 → 0.30.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 (198) hide show
  1. package/dist/derived/session-hours.d.ts +26 -0
  2. package/dist/derived/session-hours.d.ts.map +1 -0
  3. package/dist/derived/session-hours.js +29 -0
  4. package/dist/derived/session-hours.js.map +1 -0
  5. package/dist/derived/time-lengths.d.ts +37 -0
  6. package/dist/derived/time-lengths.d.ts.map +1 -0
  7. package/dist/derived/time-lengths.js +168 -0
  8. package/dist/derived/time-lengths.js.map +1 -0
  9. package/dist/derived/unit-counts.d.ts +7 -0
  10. package/dist/derived/unit-counts.d.ts.map +1 -0
  11. package/dist/derived/unit-counts.js +13 -0
  12. package/dist/derived/unit-counts.js.map +1 -0
  13. package/dist/derived/working-hours.d.ts +36 -0
  14. package/dist/derived/working-hours.d.ts.map +1 -0
  15. package/dist/derived/working-hours.js +55 -0
  16. package/dist/derived/working-hours.js.map +1 -0
  17. package/dist/detectors/acronym-expansion.d.ts +1 -1
  18. package/dist/detectors/acronym-expansion.d.ts.map +1 -1
  19. package/dist/detectors/acronym-expansion.js +46 -2
  20. package/dist/detectors/acronym-expansion.js.map +1 -1
  21. package/dist/detectors/capitalised-term.d.ts +44 -0
  22. package/dist/detectors/capitalised-term.d.ts.map +1 -0
  23. package/dist/detectors/capitalised-term.js +101 -0
  24. package/dist/detectors/capitalised-term.js.map +1 -0
  25. package/dist/detectors/date-outside-period.d.ts.map +1 -1
  26. package/dist/detectors/date-outside-period.js +4 -1
  27. package/dist/detectors/date-outside-period.js.map +1 -1
  28. package/dist/detectors/derived-numbers.d.ts +2 -0
  29. package/dist/detectors/derived-numbers.d.ts.map +1 -1
  30. package/dist/detectors/derived-numbers.js +72 -18
  31. package/dist/detectors/derived-numbers.js.map +1 -1
  32. package/dist/detectors/name-variant.d.ts.map +1 -1
  33. package/dist/detectors/name-variant.js +57 -10
  34. package/dist/detectors/name-variant.js.map +1 -1
  35. package/dist/detectors/party-order.d.ts +3 -0
  36. package/dist/detectors/party-order.d.ts.map +1 -0
  37. package/dist/detectors/party-order.js +46 -0
  38. package/dist/detectors/party-order.js.map +1 -0
  39. package/dist/detectors/registry/connection-time-short.d.ts +3 -0
  40. package/dist/detectors/registry/connection-time-short.d.ts.map +1 -0
  41. package/dist/detectors/registry/connection-time-short.js +3 -0
  42. package/dist/detectors/registry/connection-time-short.js.map +1 -0
  43. package/dist/detectors/registry/duration-product-mismatch.d.ts +3 -0
  44. package/dist/detectors/registry/duration-product-mismatch.d.ts.map +1 -0
  45. package/dist/detectors/registry/duration-product-mismatch.js +3 -0
  46. package/dist/detectors/registry/duration-product-mismatch.js.map +1 -0
  47. package/dist/detectors/registry/party-order.d.ts +3 -0
  48. package/dist/detectors/registry/party-order.d.ts.map +1 -0
  49. package/dist/detectors/registry/party-order.js +3 -0
  50. package/dist/detectors/registry/party-order.js.map +1 -0
  51. package/dist/detectors/registry/requirement-lists.d.ts +3 -0
  52. package/dist/detectors/registry/requirement-lists.d.ts.map +1 -0
  53. package/dist/detectors/registry/requirement-lists.js +3 -0
  54. package/dist/detectors/registry/requirement-lists.js.map +1 -0
  55. package/dist/detectors/registry/return-type.d.ts +3 -0
  56. package/dist/detectors/registry/return-type.d.ts.map +1 -0
  57. package/dist/detectors/registry/return-type.js +3 -0
  58. package/dist/detectors/registry/return-type.js.map +1 -0
  59. package/dist/detectors/requirement-lists.d.ts +3 -0
  60. package/dist/detectors/requirement-lists.d.ts.map +1 -0
  61. package/dist/detectors/requirement-lists.js +46 -0
  62. package/dist/detectors/requirement-lists.js.map +1 -0
  63. package/dist/detectors/return-type.d.ts +3 -0
  64. package/dist/detectors/return-type.d.ts.map +1 -0
  65. package/dist/detectors/return-type.js +24 -0
  66. package/dist/detectors/return-type.js.map +1 -0
  67. package/dist/detectors/structure-tree.d.ts.map +1 -1
  68. package/dist/detectors/structure-tree.js +9 -1
  69. package/dist/detectors/structure-tree.js.map +1 -1
  70. package/dist/detectors/time-order.d.ts +1 -0
  71. package/dist/detectors/time-order.d.ts.map +1 -1
  72. package/dist/detectors/time-order.js +30 -4
  73. package/dist/detectors/time-order.js.map +1 -1
  74. package/dist/detectors/undefined-term.d.ts.map +1 -1
  75. package/dist/detectors/undefined-term.js +19 -3
  76. package/dist/detectors/undefined-term.js.map +1 -1
  77. package/dist/document.d.ts.map +1 -1
  78. package/dist/document.js +4 -1
  79. package/dist/document.js.map +1 -1
  80. package/dist/facts/table-facts.d.ts +2 -0
  81. package/dist/facts/table-facts.d.ts.map +1 -1
  82. package/dist/facts/table-facts.js +2 -0
  83. package/dist/facts/table-facts.js.map +1 -1
  84. package/dist/name-variants.d.ts +6 -1
  85. package/dist/name-variants.d.ts.map +1 -1
  86. package/dist/name-variants.js +68 -0
  87. package/dist/name-variants.js.map +1 -1
  88. package/dist/name-word-order.d.ts +49 -0
  89. package/dist/name-word-order.d.ts.map +1 -0
  90. package/dist/name-word-order.js +114 -0
  91. package/dist/name-word-order.js.map +1 -0
  92. package/dist/plugin.d.ts +7 -0
  93. package/dist/plugin.d.ts.map +1 -1
  94. package/dist/return-type.d.ts +33 -0
  95. package/dist/return-type.d.ts.map +1 -0
  96. package/dist/return-type.js +302 -0
  97. package/dist/return-type.js.map +1 -0
  98. package/dist/structure/connection-times.d.ts +56 -0
  99. package/dist/structure/connection-times.d.ts.map +1 -0
  100. package/dist/structure/connection-times.js +165 -0
  101. package/dist/structure/connection-times.js.map +1 -0
  102. package/dist/structure/date-order-tie.d.ts +13 -0
  103. package/dist/structure/date-order-tie.d.ts.map +1 -0
  104. package/dist/structure/date-order-tie.js +61 -0
  105. package/dist/structure/date-order-tie.js.map +1 -0
  106. package/dist/structure/date-order.d.ts +2 -2
  107. package/dist/structure/date-order.d.ts.map +1 -1
  108. package/dist/structure/date-order.js +26 -9
  109. package/dist/structure/date-order.js.map +1 -1
  110. package/dist/structure/date-outside-period.d.ts +6 -0
  111. package/dist/structure/date-outside-period.d.ts.map +1 -1
  112. package/dist/structure/date-outside-period.js +49 -3
  113. package/dist/structure/date-outside-period.js.map +1 -1
  114. package/dist/structure/date-range.d.ts +5 -2
  115. package/dist/structure/date-range.d.ts.map +1 -1
  116. package/dist/structure/date-range.js +37 -9
  117. package/dist/structure/date-range.js.map +1 -1
  118. package/dist/structure/leg-times.d.ts +2 -0
  119. package/dist/structure/leg-times.d.ts.map +1 -1
  120. package/dist/structure/leg-times.js +13 -0
  121. package/dist/structure/leg-times.js.map +1 -1
  122. package/dist/structure/party-order.d.ts +33 -0
  123. package/dist/structure/party-order.d.ts.map +1 -0
  124. package/dist/structure/party-order.js +79 -0
  125. package/dist/structure/party-order.js.map +1 -0
  126. package/dist/structure/requirement-lists.d.ts +37 -0
  127. package/dist/structure/requirement-lists.d.ts.map +1 -0
  128. package/dist/structure/requirement-lists.js +89 -0
  129. package/dist/structure/requirement-lists.js.map +1 -0
  130. package/dist/structure/time-order.d.ts +21 -0
  131. package/dist/structure/time-order.d.ts.map +1 -1
  132. package/dist/structure/time-order.js +5 -6
  133. package/dist/structure/time-order.js.map +1 -1
  134. package/dist/structure/version-order.d.ts +2 -0
  135. package/dist/structure/version-order.d.ts.map +1 -1
  136. package/dist/structure/version-order.js +10 -0
  137. package/dist/structure/version-order.js.map +1 -1
  138. package/dist/surname-near.d.ts +6 -0
  139. package/dist/surname-near.d.ts.map +1 -0
  140. package/dist/surname-near.js +31 -0
  141. package/dist/surname-near.js.map +1 -0
  142. package/dist/table-cells.d.ts +13 -0
  143. package/dist/table-cells.d.ts.map +1 -0
  144. package/dist/table-cells.js +36 -0
  145. package/dist/table-cells.js.map +1 -0
  146. package/dist/table-names.d.ts +42 -0
  147. package/dist/table-names.d.ts.map +1 -0
  148. package/dist/table-names.js +94 -0
  149. package/dist/table-names.js.map +1 -0
  150. package/package.json +3 -3
  151. package/rules/connection-time-short.yaml +52 -0
  152. package/rules/date-outside-period.yaml +4 -4
  153. package/rules/duration-mismatch.yaml +7 -4
  154. package/rules/duration-product-mismatch.yaml +73 -0
  155. package/rules/name-variant.yaml +8 -2
  156. package/rules/party-order.yaml +84 -0
  157. package/rules/required-listed-as-preferred.yaml +102 -0
  158. package/rules/return-type-mismatch.yaml +79 -0
  159. package/rules/undefined-term.yaml +21 -7
  160. package/src/derived/session-hours.ts +62 -0
  161. package/src/derived/time-lengths.ts +226 -0
  162. package/src/derived/unit-counts.ts +18 -0
  163. package/src/derived/working-hours.ts +97 -0
  164. package/src/detectors/acronym-expansion.ts +57 -2
  165. package/src/detectors/capitalised-term.ts +147 -0
  166. package/src/detectors/date-outside-period.ts +4 -1
  167. package/src/detectors/derived-numbers.ts +94 -21
  168. package/src/detectors/name-variant.ts +71 -13
  169. package/src/detectors/party-order.ts +50 -0
  170. package/src/detectors/registry/connection-time-short.ts +4 -0
  171. package/src/detectors/registry/duration-product-mismatch.ts +4 -0
  172. package/src/detectors/registry/party-order.ts +4 -0
  173. package/src/detectors/registry/requirement-lists.ts +4 -0
  174. package/src/detectors/registry/return-type.ts +4 -0
  175. package/src/detectors/requirement-lists.ts +65 -0
  176. package/src/detectors/return-type.ts +30 -0
  177. package/src/detectors/structure-tree.ts +12 -1
  178. package/src/detectors/time-order.ts +40 -5
  179. package/src/detectors/undefined-term.ts +20 -3
  180. package/src/document.ts +4 -1
  181. package/src/facts/table-facts.ts +3 -0
  182. package/src/name-variants.ts +76 -1
  183. package/src/name-word-order.ts +149 -0
  184. package/src/plugin.ts +5 -0
  185. package/src/return-type.ts +361 -0
  186. package/src/structure/connection-times.ts +226 -0
  187. package/src/structure/date-order-tie.ts +86 -0
  188. package/src/structure/date-order.ts +41 -14
  189. package/src/structure/date-outside-period.ts +72 -7
  190. package/src/structure/date-range.ts +45 -9
  191. package/src/structure/leg-times.ts +15 -0
  192. package/src/structure/party-order.ts +115 -0
  193. package/src/structure/requirement-lists.ts +145 -0
  194. package/src/structure/time-order.ts +8 -7
  195. package/src/structure/version-order.ts +10 -0
  196. package/src/surname-near.ts +31 -0
  197. package/src/table-cells.ts +41 -0
  198. package/src/table-names.ts +113 -0
@@ -0,0 +1,86 @@
1
+ import { versionsAlong } from "./version-order.ts";
2
+
3
+ /**
4
+ * 隣どうしの二つの日付が入れ替わったように見えて、どちらを取り除いても同じだけ揃う(引き分け)とき、同じ項目の手がかりで決める。
5
+ * 項目がみな版を持ち、版が向きに沿って並ぶなら、並びの位置は版が裏付ける。位置に照らして逆らう相手の多い日付を指す。
6
+ * 版が無いか、版も乱れているなら、二つだけが入れ替わった並びに限り、外側の隣(前の日付の前、後ろの日付の後ろ)から遠いほうを指す。書き間違えた日付は隣から跳ぶ。
7
+ * どちらでも決まらなければ undefined(呼ぶ側はこれまでどおり後ろを指す)。
8
+ */
9
+ export type TieEntry = { readonly value: string; readonly version: string | undefined };
10
+
11
+ const DAY_MS = 86_400_000;
12
+ const MONTHS_PER_YEAR = 12;
13
+ /** 年の無い月日(09-14)を日数にするための、閏年の仮の年。 */
14
+ const LEAP_YEAR = 2000;
15
+
16
+ /** 同じ書き方の日付どうしで差をとれる数。年月日と月日は日数、年月は月数。 */
17
+ const pointOf = (value: string): number | undefined => {
18
+ const full = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(value);
19
+ if (full !== null) return Date.UTC(Number(full[1]), Number(full[2]) - 1, Number(full[3])) / DAY_MS;
20
+ const month = /^(\d{4})-(\d{2})$/u.exec(value);
21
+ if (month !== null) return Number(month[1]) * MONTHS_PER_YEAR + Number(month[2]);
22
+ const day = /^(\d{2})-(\d{2})$/u.exec(value);
23
+ return day === null ? undefined : Date.UTC(LEAP_YEAR, Number(day[1]) - 1, Number(day[2])) / DAY_MS;
24
+ };
25
+
26
+ /** 位置に照らして向きに逆らう相手の数。 */
27
+ const disagreements = (values: readonly string[], index: number, direction: number): number =>
28
+ values.filter((other, at) => {
29
+ if (at === index) return false;
30
+ const [earlier, later] = at < index ? [other, values[index] ?? ""] : [values[index] ?? "", other];
31
+ return Math.sign(later.localeCompare(earlier)) === -direction;
32
+ }).length;
33
+
34
+ /** 多いほうを指す。同じなら決めない。 */
35
+ const larger = (before: number, after: number, scores: readonly [number, number]): number | undefined => {
36
+ if (scores[0] === scores[1]) return undefined;
37
+ return scores[0] > scores[1] ? before : after;
38
+ };
39
+
40
+ const byVersion = (entries: readonly TieEntry[], before: number, after: number, direction: number): number | undefined => {
41
+ if (
42
+ !versionsAlong(
43
+ entries.map((entry) => entry.version),
44
+ direction,
45
+ )
46
+ )
47
+ return undefined;
48
+ const values = entries.map((entry) => entry.value);
49
+ return larger(before, after, [disagreements(values, before, direction), disagreements(values, after, direction)]);
50
+ };
51
+
52
+ const distance = (entries: readonly TieEntry[], from: number, to: number): number | undefined => {
53
+ const start = pointOf(entries[from]?.value ?? "");
54
+ const end = pointOf(entries[to]?.value ?? "");
55
+ return start === undefined || end === undefined ? undefined : Math.abs(end - start);
56
+ };
57
+
58
+ /** from から to へ、向きに逆らわない(等しくてもよい)。 */
59
+ const along = (from: string, to: string, direction: number): boolean => Math.sign(to.localeCompare(from)) !== -direction;
60
+
61
+ /** 隣どうしの入れ替わりだけ: 外側の二つが向きに沿い、入れ替わった二つがどちらもその間に収まる。乱れが広い並びでは、外側の隣も当てにならない。 */
62
+ const isSwap = (values: readonly string[], before: number, after: number, direction: number): boolean => {
63
+ const [outerBefore, outerAfter] = [values[before - 1], values[after + 1]];
64
+ if (outerBefore === undefined || outerAfter === undefined) return false;
65
+ return [values[before] ?? "", values[after] ?? ""].every((value) => along(outerBefore, value, direction) && along(value, outerAfter, direction));
66
+ };
67
+
68
+ const byGap = (entries: readonly TieEntry[], before: number, after: number, direction: number): number | undefined => {
69
+ if (
70
+ !isSwap(
71
+ entries.map((entry) => entry.value),
72
+ before,
73
+ after,
74
+ direction,
75
+ )
76
+ )
77
+ return undefined;
78
+ const outerBefore = distance(entries, before - 1, before);
79
+ const outerAfter = distance(entries, after, after + 1);
80
+ if (outerBefore === undefined || outerAfter === undefined) return undefined;
81
+ return larger(before, after, [outerBefore, outerAfter]);
82
+ };
83
+
84
+ /** 引き分けの二つ(before = at - 1、after = at)のうち外れたほう。決まらなければ undefined。 */
85
+ export const breakDateTie = (entries: readonly TieEntry[], before: number, after: number, direction: number): number | undefined =>
86
+ byVersion(entries, before, after, direction) ?? byGap(entries, before, after, direction);
@@ -1,6 +1,8 @@
1
1
  import type { StructureIssue } from "./issues.ts";
2
2
  import { siblingHeadingRunsOf } from "./heading-runs.ts";
3
3
  import { runsOf } from "./runs.ts";
4
+ import { breakDateTie, type TieEntry } from "./date-order-tie.ts";
5
+ import { leadingVersion } from "./version-order.ts";
4
6
 
5
7
  /**
6
8
  * 日程として並べた日付の順番。箇条書きの続いた項目、表の続いた行、同じ親の下で同じ深さに並ぶ見出しで、
@@ -32,17 +34,30 @@ const onlyDateIn = (line: Span, points: readonly DatedPoint[]): DatedPoint | und
32
34
  return inside.length === 1 ? inside[0] : undefined;
33
35
  };
34
36
 
35
- const datedLines = (run: readonly Span[], points: readonly DatedPoint[]): DatedPoint[] =>
37
+ /** 日付を一つ持つ一行。行が版で始まれば(「## 3.1.1 - 2026-05-08」「- 3.1.0 (2026-06-02)」)その版を添える。 */
38
+ type DatedEntry = DatedPoint & Pick<TieEntry, "version">;
39
+
40
+ /** 見出しの #、箇条書きの記号、表の最初の |。 */
41
+ const LINE_MARKER = /^[ \t]*(?:#{1,6}|[-*+]|\d{1,3}[.)]|\|)?[ \t]*/u;
42
+
43
+ const entryOf = (source: string, line: Span, points: readonly DatedPoint[]): DatedEntry | undefined => {
44
+ const date = onlyDateIn(line, points);
45
+ if (date === undefined) return undefined;
46
+ const text = source.slice(line.start, line.end);
47
+ return { ...date, version: leadingVersion(text.slice(LINE_MARKER.exec(text)?.[0].length ?? 0)) };
48
+ };
49
+
50
+ const datedLines = (source: string, run: readonly Span[], points: readonly DatedPoint[]): DatedEntry[] =>
36
51
  run.flatMap((line) => {
37
- const date = onlyDateIn(line, points);
38
- return date === undefined ? [] : [date];
52
+ const entry = entryOf(source, line, points);
53
+ return entry === undefined ? [] : [entry];
39
54
  });
40
55
 
41
56
  /** 見出しの並びは、日付を一つ持つ見出しが続くあいだだけ。日付の無い見出しが混じる並び(問いに更新日を添えた FAQ)は、日付で並べたものではない。 */
42
- const datedStretches = (run: readonly Span[], points: readonly DatedPoint[]): DatedPoint[][] =>
43
- run.reduce<DatedPoint[][]>(
57
+ const datedStretches = (source: string, run: readonly Span[], points: readonly DatedPoint[]): DatedEntry[][] =>
58
+ run.reduce<DatedEntry[][]>(
44
59
  (stretches, heading) => {
45
- const date = onlyDateIn(heading, points);
60
+ const date = entryOf(source, heading, points);
46
61
  if (date === undefined) return [...stretches, []];
47
62
  return [...stretches.slice(0, -1), [...(stretches.at(-1) ?? []), date]];
48
63
  },
@@ -71,10 +86,19 @@ const without = (values: readonly string[], index: number): string[] => values.f
71
86
 
72
87
  /**
73
88
  * 向きに逆らう一歩(at - 1 から at)のうち、並びから外れているほう。取り除いたとき残りが長く揃うほうを指す。
74
- * 前を取っても後ろを取っても同じだけ揃う(隣どうしの入れ替わり)なら、どちらとも決められないので後ろを指す。
89
+ * 前を取っても後ろを取っても同じだけ揃う(隣どうしの入れ替わり)なら、tieBreak が決めたほう、決まらなければ後ろを指す。
75
90
  */
76
- export const outOfPlace = (values: readonly string[], at: number, direction: number): number =>
77
- longestInOrder(without(values, at - 1), direction) > longestInOrder(without(values, at), direction) ? at - 1 : at;
91
+ export const outOfPlace = (
92
+ values: readonly string[],
93
+ at: number,
94
+ direction: number,
95
+ tieBreak: (before: number, after: number) => number | undefined = () => undefined,
96
+ ): number => {
97
+ const withoutBefore = longestInOrder(without(values, at - 1), direction);
98
+ const withoutAfter = longestInOrder(without(values, at), direction);
99
+ if (withoutBefore === withoutAfter) return tieBreak(at - 1, at) ?? at;
100
+ return withoutBefore > withoutAfter ? at - 1 : at;
101
+ };
78
102
 
79
103
  /** 外れた日付に、前の日付を添える。先頭の日付には前が無いので、次の日付を添える。 */
80
104
  const issueAt = (dated: readonly DatedPoint[], at: number): StructureIssue[] => {
@@ -85,18 +109,21 @@ const issueAt = (dated: readonly DatedPoint[], at: number): StructureIssue[] =>
85
109
  };
86
110
 
87
111
  /** 多いほうの向きに逆らう一歩ごとに、並びから外れた日付を指す。 */
88
- const againstMajority = (dated: readonly DatedPoint[]): StructureIssue[] => {
112
+ const againstMajority = (dated: readonly DatedEntry[]): StructureIssue[] => {
89
113
  const values = dated.map((point) => point.value);
90
114
  const signs = values.slice(1).map((value, index) => Math.sign(value.localeCompare(values[index] ?? "")));
91
115
  const majority = majorityOf(signs.filter((sign) => sign > 0).length, signs.filter((sign) => sign < 0).length);
92
116
  if (majority === 0 || !mostlyInOrder(values, majority)) return [];
93
117
  return signs.flatMap((sign, index) => {
94
118
  if (sign !== -majority) return [];
95
- return issueAt(dated, outOfPlace(values, index + 1, majority));
119
+ return issueAt(
120
+ dated,
121
+ outOfPlace(values, index + 1, majority, (before, after) => breakDateTie(dated, before, after, majority)),
122
+ );
96
123
  });
97
124
  };
98
125
 
99
- const breaksIn = (dated: readonly DatedPoint[]): StructureIssue[] => (samePrecision(dated) ? againstMajority(dated) : []);
126
+ const breaksIn = (dated: readonly DatedEntry[]): StructureIssue[] => (samePrecision(dated) ? againstMajority(dated) : []);
100
127
 
101
128
  /** 見出しを箇条書きの中に書けば(「- ## 3.1.0 - 2026-10-02」)、同じ日付が両方の並びに入る。一度だけ言う。 */
102
129
  const oncePerOffset = (issues: readonly StructureIssue[]): StructureIssue[] =>
@@ -104,6 +131,6 @@ const oncePerOffset = (issues: readonly StructureIssue[]): StructureIssue[] =>
104
131
 
105
132
  export const dateOrderBreaks = (source: string, points: readonly DatedPoint[], headings: readonly HeadingSpan[] = []): StructureIssue[] =>
106
133
  oncePerOffset([
107
- ...runsOf(source).flatMap((run) => breaksIn(datedLines(run, points))),
108
- ...siblingHeadingRuns(headings).flatMap((run) => datedStretches(run, points).flatMap(breaksIn)),
134
+ ...runsOf(source).flatMap((run) => breaksIn(datedLines(source, run, points))),
135
+ ...siblingHeadingRuns(headings).flatMap((run) => datedStretches(source, run, points).flatMap(breaksIn)),
109
136
  ]);
@@ -8,11 +8,19 @@ import { statedPeriod, type DateMention, type Period, type PeriodWords } from ".
8
8
  * 地の文の日付は、年の無いもの(2月12日(金)の午前は…)だけを比べる。年のある日付は、過去の回や別の年の出来事を挙げることが多い。
9
9
  * 期間の前後に置く予定(前泊、follow-up)、締め切りや予約の日、過去の回を言う語(period-aside)が、その行、文、節の見出しにあれば比べない。
10
10
  * 年の無い期間(12月28日〜1月4日)は年をまたいで読む。
11
+ * 文書の仕事の期間(開講期間:…)を文書の頭か概要の節で書いたものは文書全体の期間で、後ろのどの節でも、締め切りの語(period-deadline)のある項目や文の、
12
+ * 期間の終わりより後の年のある日付を指す(課題の提出が学期の後)。期間の前の締め切り(申込締切)と、旅行期間のように後に締め切りが来てよい期間は比べない。
11
13
  */
12
14
  export type HeadingSpan = { readonly start: number; readonly end: number; readonly depth: number };
13
15
  type Span = { readonly start: number; readonly end: number };
14
16
 
15
- export type OutsideWords = PeriodWords & { readonly asides: readonly string[] };
17
+ export type OutsideWords = PeriodWords & {
18
+ readonly asides: readonly string[];
19
+ /** 文書の仕事がその中で済む期間の語(開講期間、Term)。この期間は締め切りとだけ比べる。 */
20
+ readonly terms: readonly string[];
21
+ readonly deadlines: readonly string[];
22
+ readonly overviews: readonly string[];
23
+ };
16
24
 
17
25
  const LIST_ITEM = /^[ \t]{0,12}(?:[-*+]|\d{1,3}[.)])[ \t]/u;
18
26
  const DAY_VALUE = /^(?:\d{4}-)?\d{2}-\d{2}$/u;
@@ -46,7 +54,7 @@ export const mentions = (text: string, words: readonly string[]): boolean => {
46
54
  });
47
55
  };
48
56
 
49
- type Stated = Period & { readonly scopeStart: number; readonly scopeEnd: number };
57
+ type Stated = Period & { readonly line: Span; readonly scopeStart: number; readonly scopeEnd: number; readonly nextPeriod: number };
50
58
 
51
59
  /**
52
60
  * 期間の及ぶ範囲の始まり: 期間の行の後ろ。期間の行が箇条書きの項目なら、その箇条書きの後ろ。
@@ -84,6 +92,8 @@ const statedPeriods = (
84
92
  });
85
93
  return found.map(({ period, line }, index) => ({
86
94
  ...period,
95
+ line,
96
+ nextPeriod: found[index + 1]?.line.start ?? source.length,
87
97
  scopeStart: scopeStart(source, lines, line),
88
98
  scopeEnd: scopeEnd(line, found[index + 1]?.line.start ?? source.length, headings, source.length),
89
99
  }));
@@ -110,6 +120,63 @@ const outsideOne = (date: DateMention, period: Stated, context: Context, asides:
110
120
  return !surroundings(date, line, item, context).some((text) => mentions(text, asides));
111
121
  };
112
122
 
123
+ const boundaryPattern = (word: string): RegExp => {
124
+ const target = escapeRegExp(word.toLowerCase());
125
+ return /[a-z]/u.test(word.toLowerCase()) ? new RegExp(`(?<![a-z])${target}(?![a-z])`, "gu") : new RegExp(target, "gu");
126
+ };
127
+
128
+ /** 締め切りの語を除いた文。「提出期限」の「期限」、「submit by」の「submit」は、期間の外に置く語として読まない。 */
129
+ const withoutWords = (text: string, words: readonly string[]): string =>
130
+ words.toSorted((left, right) => right.length - left.length).reduce((rest, word) => rest.replace(boundaryPattern(word), " "), text.toLowerCase());
131
+
132
+ /** 節の見出しの並び(その行の見出しと、それを囲む浅い見出し)。 */
133
+ const enclosingHeadings = (offset: number, headings: readonly HeadingSpan[]): HeadingSpan[] =>
134
+ headings
135
+ .filter((heading) => heading.start < offset)
136
+ .toReversed()
137
+ .reduce<HeadingSpan[]>((chain, heading) => (heading.depth < (chain.at(-1)?.depth ?? Infinity) ? [...chain, heading] : chain), []);
138
+
139
+ /** 文書の仕事の期間か: 表題(文書の頭の # の見出し)のほかの見出しより前か、概要の節(period-overview-heading)の行。 */
140
+ const isDocumentTerm = (period: Stated, context: Context, overviews: readonly string[]): boolean => {
141
+ const sections = context.headings.filter((heading, index) => heading.start < period.line.start && !(index === 0 && heading.depth === 1));
142
+ if (sections.length === 0) return true;
143
+ return enclosingHeadings(period.line.start, context.headings).some((heading) => mentions(context.source.slice(heading.start, heading.end), overviews));
144
+ };
145
+
146
+ const afterEnd = (value: string, period: Period): boolean => hasYear(value) && hasYear(period.end) && value > period.end;
147
+
148
+ const lateDeadline = (date: DateMention, period: Stated, context: Context, words: OutsideWords): boolean => {
149
+ if (!DAY_VALUE.test(date.value) || !afterEnd(date.value, period)) return false;
150
+ const line = containing(context.lines, date.offset);
151
+ if (line === undefined) return false;
152
+ const texts = surroundings(date, line, isItem(context.source.slice(line.start, line.end), line, context.headings), context);
153
+ if (!mentions(texts[0] ?? "", words.deadlines)) return false;
154
+ return !texts.some((text) => mentions(withoutWords(text, words.deadlines), words.asides));
155
+ };
156
+
157
+ const issueOf = (source: string, date: DateMention, period: Stated): StructureIssue => ({
158
+ offset: date.offset,
159
+ values: { date: source.slice(date.offset, date.end), period: period.written },
160
+ });
161
+
162
+ /** 期間の行の後ろ、同じ節の日程の日付で、期間の外のもの。 */
163
+ const outsideInSection = (dates: readonly DateMention[], context: Context, words: OutsideWords): StructureIssue[] =>
164
+ statedPeriods(context.source, context.lines, dates, context.headings, words).flatMap((period) =>
165
+ dates
166
+ .filter((date) => date.offset > period.scopeStart && date.offset < period.scopeEnd && outsideOne(date, period, context, words.asides))
167
+ .map((date) => issueOf(context.source, date, period)),
168
+ );
169
+
170
+ /** 文書の仕事の期間の行の後ろ、次の仕事の期間の行までの、期間の終わりより後の締め切り。 */
171
+ const lateDeadlines = (dates: readonly DateMention[], context: Context, words: OutsideWords): StructureIssue[] =>
172
+ statedPeriods(context.source, context.lines, dates, context.headings, { ...words, labels: words.terms })
173
+ .filter((period) => isDocumentTerm(period, context, words.overviews))
174
+ .flatMap((period) =>
175
+ dates
176
+ .filter((date) => date.offset > period.line.end && date.offset < period.nextPeriod && lateDeadline(date, period, context, words))
177
+ .map((date) => issueOf(context.source, date, period)),
178
+ );
179
+
113
180
  export const datesOutsidePeriod = (
114
181
  source: string,
115
182
  dates: readonly DateMention[],
@@ -119,9 +186,7 @@ export const datesOutsidePeriod = (
119
186
  ): StructureIssue[] => {
120
187
  const lines = linesOf(source);
121
188
  const context: Context = { source, lines, headings, sentences };
122
- return statedPeriods(source, lines, dates, headings, words).flatMap((period) =>
123
- dates
124
- .filter((date) => date.offset > period.scopeStart && date.offset < period.scopeEnd && outsideOne(date, period, context, words.asides))
125
- .map((date) => ({ offset: date.offset, values: { date: source.slice(date.offset, date.end), period: period.written } })),
126
- );
189
+ const inSection = outsideInSection(dates, context, words);
190
+ const reported = new Set(inSection.map((issue) => issue.offset));
191
+ return [...inSection, ...lateDeadlines(dates, context, words).filter((issue) => !reported.has(issue.offset))];
127
192
  };
@@ -107,20 +107,48 @@ const sentenceAround = (source: string, start: DatedSpan, end: DatedSpan): { rea
107
107
  return { text: flat(source.slice(paragraphStart + from, end.end + to)), before: flat(head.slice(from)).trimEnd() };
108
108
  };
109
109
 
110
- /** 前の語と間の語の組(from … to)。同じ文のどこかに変更の語(moved、postponed)があれば、日付を動かした文で、期間ではない。 */
110
+ /** 同じ文のどこかに変更の語(moved、postponed)があれば、日付を動かした文で、期間ではない。 */
111
+ const isChange = (source: string, start: DatedSpan, end: DatedSpan, words: RangeWords): boolean => {
112
+ const sentence = sentenceAround(source, start, end).text;
113
+ return words.changes.some((change) => hasWord(sentence, change.toLowerCase()));
114
+ };
115
+
116
+ /** 前の語と間の語の組(from … to)。 */
111
117
  const framed = (source: string, start: DatedSpan, end: DatedSpan, joint: string, words: RangeWords): boolean => {
112
- const sentence = sentenceAround(source, start, end);
113
- const before = withoutTrailingWeekday(sentence.before, words.weekdays ?? []);
118
+ const before = withoutTrailingWeekday(sentenceAround(source, start, end).before, words.weekdays ?? []);
114
119
  const frame = words.frames.find((candidate) => candidate.joint === joint && endsWithWord(before, candidate.lead));
115
- return frame !== undefined && !words.changes.some((change) => hasWord(sentence.text, change.toLowerCase()));
120
+ return frame !== undefined && !isChange(source, start, end, words);
121
+ };
122
+
123
+ const FULL_DATE = /^\d{4}-\d{2}-\d{2}$/u;
124
+
125
+ /** 日付の前に曜日の名を添えた(Monday, October 5, 2026)。同じ行の、日付の直前だけを見る。 */
126
+ const weekdayBefore = (source: string, date: DatedSpan, weekdays: readonly string[]): boolean => {
127
+ const before = source.slice(source.lastIndexOf("\n", date.offset - 1) + 1, date.offset).trimEnd();
128
+ return before !== "" && withoutTrailingWeekday(before, weekdays) !== before;
116
129
  };
117
130
 
118
- const isRange = (source: string, start: DatedSpan, end: DatedSpan, words: RangeWords): boolean => {
131
+ /**
132
+ * 前の語の無い組の間の語だけ(October 5, 2026 to October 30, 2026)。「to」は日付を動かす文にも出るので、
133
+ * 期間の語で始まる行(Dates: …)か、両方が曜日を添えた年月日のときだけ期間と読む。変更の語のある文は読まない。
134
+ */
135
+ const bareFramed = (source: string, start: DatedSpan, end: DatedSpan, written: string, words: RangeWords, isLabelled: (offset: number) => boolean): boolean => {
136
+ const weekdays = words.weekdays ?? [];
137
+ const joint = withoutTrailingWeekday(written, weekdays);
138
+ if (!words.frames.some((frame) => frame.joint === joint)) return false;
139
+ const withWeekdays = FULL_DATE.test(start.value) && FULL_DATE.test(end.value) && joint !== written && weekdayBefore(source, start, weekdays);
140
+ return (isLabelled(start.offset) || withWeekdays) && !isChange(source, start, end, words);
141
+ };
142
+
143
+ const isRange = (source: string, start: DatedSpan, end: DatedSpan, words: RangeWords, isLabelled: (offset: number) => boolean): boolean => {
119
144
  const written = jointOf(source, start, end);
120
145
  if (written === undefined) return false;
121
146
  const joint = withoutTrailingWeekday(written, words.weekdays ?? []);
122
147
  return (
123
- isOneOf(joint, words.connectors) || (isOneOf(joint, words.openers) && closedAfter(source, end, words.closers)) || framed(source, start, end, joint, words)
148
+ isOneOf(joint, words.connectors) ||
149
+ (isOneOf(joint, words.openers) && closedAfter(source, end, words.closers)) ||
150
+ framed(source, start, end, joint, words) ||
151
+ bareFramed(source, start, end, written, words, isLabelled)
124
152
  );
125
153
  };
126
154
 
@@ -132,11 +160,19 @@ const writtenPeriod = (source: string, start: DatedSpan, end: DatedSpan): string
132
160
  .map((part) => part.trim())
133
161
  .join(" ");
134
162
 
135
- /** 隣り合う二つの日付のうち、期間をなし、終わりが始まりより前のもの。期間の書き出しを指し、書いたままの期間を見せる。 */
136
- export const reversedRanges = (source: string, dates: readonly DatedSpan[], words: RangeWords): StructureIssue[] =>
163
+ /**
164
+ * 隣り合う二つの日付のうち、期間をなし、終わりが始まりより前のもの。期間の書き出しを指し、書いたままの期間を見せる。
165
+ * isLabelled は、その位置の日付が期間の語で始まる行(Dates: …)の、語より後ろにあるか。
166
+ */
167
+ export const reversedRanges = (
168
+ source: string,
169
+ dates: readonly DatedSpan[],
170
+ words: RangeWords,
171
+ isLabelled: (offset: number) => boolean = () => false,
172
+ ): StructureIssue[] =>
137
173
  dates.slice(1).flatMap((end, index) => {
138
174
  const start = dates[index];
139
175
  if (start === undefined || !comparable(start.value, end.value) || end.value >= start.value) return [];
140
- if (!isRange(source, start, end, words)) return [];
176
+ if (!isRange(source, start, end, words, isLabelled)) return [];
141
177
  return [{ offset: start.offset, values: { start: start.value, end: end.value, period: writtenPeriod(source, start, end) } }];
142
178
  });
@@ -196,3 +196,18 @@ export const arrivalsBeforeDeparture = (source: string, times: readonly TimeSpan
196
196
  .filter((issue, index) => issues.findIndex((other) => other.offset === issue.offset) === index)
197
197
  .toSorted((left, right) => left.offset - right.offset);
198
198
  };
199
+
200
+ /** 表の着の列に書いた時刻の位置。見出しに着の列の無い表からは読まない。 */
201
+ const columnArrivals = (inputs: Inputs): number[] =>
202
+ tablesOf(inputs.source).flatMap((table) => {
203
+ const arrives = columnOf(inputs.source, table.header, inputs.words.columns, ARRIVAL);
204
+ if (arrives === -1) return [];
205
+ return table.rows.flatMap((row) => cellTime(cellsOf(inputs.source, row.start, row.end)[arrives], inputs)?.offset ?? []);
206
+ });
207
+
208
+ /** 着と読む時刻の位置。行の中の印(「09:50着」「arrives 9:05 AM」)と、表の着の列から。 */
209
+ export const arrivalOffsets = (source: string, times: readonly TimeSpan[], dates: readonly DateSpan[], words: TimeWords): Set<number> => {
210
+ const inputs = { source, times, dates, words };
211
+ const inline = linesOf(source).flatMap((line) => legsInLine(line.start, line.end, inputs).flatMap((leg) => (leg.side === ARRIVAL ? [leg.offset] : [])));
212
+ return new Set([...inline, ...columnArrivals(inputs)]);
213
+ };
@@ -0,0 +1,115 @@
1
+ import type { BodyText } from "./definition-use.ts";
2
+
3
+ /**
4
+ * 当事者の呼び名を並べた所(甲及び乙、甲又は乙、甲乙、甲、乙及び丙)と、文書がふだん書く順と逆に並べた所。
5
+ * 並べる語は言語パッケージの語彙表から取る。向きのある言い方(乙から甲へ、甲が乙に)は並べる語でつながないので読まない。
6
+ */
7
+ export type ListingWords = {
8
+ /** 当事者の呼び名(甲、乙、文書が定めた委託者)。 */
9
+ readonly labels: readonly string[];
10
+ /** 間に語を置かずに並べてよい呼び名(甲乙)。 */
11
+ readonly bareLabels: readonly string[];
12
+ /** 並べる語(及び、又は、・)。 */
13
+ readonly joiners: readonly string[];
14
+ /** 同じ並びのあとに並べる語があるときだけつなぐ印(甲、乙及び丙の「、」)。 */
15
+ readonly seriesMarks: readonly string[];
16
+ };
17
+
18
+ export type PartyListing = { readonly start: number; readonly end: number; readonly labels: readonly string[] };
19
+
20
+ export type OrderSlip = { readonly listing: PartyListing; readonly usual: PartyListing; readonly usualCount: number; readonly reverseCount: number };
21
+
22
+ type Link = "joiner" | "series" | "bare";
23
+ type Step = { readonly link: Link; readonly label: string; readonly end: number };
24
+
25
+ /** 呼び名の前の一字がこれなら、呼び名は長い語の一部(乙種の前の語、1丁目)。 */
26
+ const WORD_CHAR = /[\p{Script=Han}\p{Script=Katakana}\p{Script=Latin}\p{N}ー]/u;
27
+
28
+ const longestAt = (text: string, at: number, words: readonly string[]): string | undefined =>
29
+ words
30
+ .filter((word) => word !== "" && text.startsWith(word, at))
31
+ .reduce<string | undefined>((best, word) => (best === undefined || word.length > best.length ? word : best), undefined);
32
+
33
+ const stepAfter = (text: string, at: number, previous: string, words: ListingWords): Step | undefined => {
34
+ const linked = (link: Link, mark: string | undefined): Step | undefined => {
35
+ if (mark === undefined) return undefined;
36
+ const label = longestAt(text, at + mark.length, words.labels);
37
+ return label === undefined ? undefined : { link, label, end: at + mark.length + label.length };
38
+ };
39
+ const bare = words.bareLabels.includes(previous) ? longestAt(text, at, words.bareLabels) : undefined;
40
+ const side: Step | undefined = bare === undefined ? undefined : { link: "bare", label: bare, end: at + bare.length };
41
+ return linked("joiner", longestAt(text, at, words.joiners)) ?? linked("series", longestAt(text, at, words.seriesMarks)) ?? side;
42
+ };
43
+
44
+ /** 並べた語の数。「、」は、そのあとに並べる語が無ければ並びの終わり(「甲、乙は」は並べたと読まない)。 */
45
+ const linksKept = (links: readonly Link[]): number => {
46
+ const lastJoiner = links.lastIndexOf("joiner");
47
+ const series = links.findIndex((link, index) => link === "series" && index > lastJoiner);
48
+ return series === -1 ? links.length : series;
49
+ };
50
+
51
+ /** 最後の呼び名が長い語の頭なら並びに入れない(乙及び甲種株式の甲)。間を置かずに並べた甲乙は、あとに語が続いてよい(甲乙間、甲乙双方)。 */
52
+ const endsInWord = (text: string, steps: readonly Step[]): boolean => {
53
+ const last = steps.at(-1);
54
+ return last !== undefined && last.link !== "bare" && WORD_CHAR.test(text.charAt(last.end));
55
+ };
56
+
57
+ const listingAt = (text: BodyText, at: number, first: string, words: ListingWords): PartyListing => {
58
+ const steps: Step[] = [];
59
+ let end = at + first.length;
60
+ for (let step = stepAfter(text.text, end, first, words); step !== undefined; step = stepAfter(text.text, end, step.label, words)) {
61
+ steps.push(step);
62
+ end = step.end;
63
+ }
64
+ const whole = endsInWord(text.text, steps) ? steps.slice(0, -1) : steps;
65
+ const kept = whole.slice(0, linksKept(whole.map((step) => step.link)));
66
+ const last = kept.at(-1);
67
+ return { start: text.start + at, end: text.start + (last === undefined ? at + first.length : last.end), labels: [first, ...kept.map((step) => step.label)] };
68
+ };
69
+
70
+ /** 一つの文の中の並び。呼び名が二つ以上で、違う呼び名を並べたものだけ。 */
71
+ const listingsIn = (text: BodyText, words: ListingWords): PartyListing[] => {
72
+ const found: PartyListing[] = [];
73
+ let at = 0;
74
+ while (at < text.text.length) {
75
+ const first = WORD_CHAR.test(text.text.charAt(at - 1)) ? undefined : longestAt(text.text, at, words.labels);
76
+ const listing = first === undefined ? undefined : listingAt(text, at, first, words);
77
+ if (listing !== undefined && new Set(listing.labels).size > 1) found.push(listing);
78
+ at = listing !== undefined && listing.labels.length > 1 ? listing.end - text.start : at + 1;
79
+ }
80
+ return found;
81
+ };
82
+
83
+ export const partyListings = (texts: readonly BodyText[], words: ListingWords): PartyListing[] => texts.flatMap((text) => listingsIn(text, words));
84
+
85
+ const pairsOf = (labels: readonly string[]): [string, string][] =>
86
+ labels.flatMap((first, index) => labels.slice(index + 1).flatMap((second): [string, string][] => (second === first ? [] : [[first, second]])));
87
+
88
+ const keyOf = (left: string, right: string): string => `${left}\u0000${right}`;
89
+
90
+ const byPair = (listings: readonly PartyListing[]): Map<string, PartyListing[]> => {
91
+ const pairs = new Map<string, PartyListing[]>();
92
+ listings.forEach((listing) =>
93
+ new Set(pairsOf(listing.labels).map(([first, second]) => keyOf(first, second))).forEach((key) => pairs.set(key, [...(pairs.get(key) ?? []), listing])),
94
+ );
95
+ return pairs;
96
+ };
97
+
98
+ /**
99
+ * 文書がふだん書く順と逆に並べた所。ふだんの順は、同じ二つの呼び名を並べた所の多い方で、逆の順の minRatio 倍以上あるときだけ
100
+ * 決まる(同じ数なら、どちらとも言えないので指さない)。
101
+ */
102
+ export const reversedListings = (listings: readonly PartyListing[], minRatio: number): OrderSlip[] => {
103
+ const pairs = byPair(listings);
104
+ return listings.flatMap((listing) => {
105
+ const slips = pairsOf(listing.labels).flatMap(([first, second]): OrderSlip[] => {
106
+ const reverse = pairs.get(keyOf(first, second)) ?? [];
107
+ const usual = pairs.get(keyOf(second, first)) ?? [];
108
+ const example = usual[0];
109
+ return example === undefined || usual.length < minRatio * reverse.length
110
+ ? []
111
+ : [{ listing, usual: example, usualCount: usual.length, reverseCount: reverse.length }];
112
+ });
113
+ return slips.slice(0, 1);
114
+ });
115
+ };