chaffjs 0.9.0 → 0.10.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 (77) hide show
  1. package/dist/detectors/index.d.ts.map +1 -1
  2. package/dist/detectors/index.js +4 -1
  3. package/dist/detectors/index.js.map +1 -1
  4. package/dist/detectors/lexicon.d.ts.map +1 -1
  5. package/dist/detectors/lexicon.js +8 -1
  6. package/dist/detectors/lexicon.js.map +1 -1
  7. package/dist/detectors/structure-tree.d.ts +5 -0
  8. package/dist/detectors/structure-tree.d.ts.map +1 -1
  9. package/dist/detectors/structure-tree.js +50 -1
  10. package/dist/detectors/structure-tree.js.map +1 -1
  11. package/dist/detectors/team.d.ts.map +1 -1
  12. package/dist/detectors/team.js +7 -2
  13. package/dist/detectors/team.js.map +1 -1
  14. package/dist/detectors/word-list.d.ts +3 -0
  15. package/dist/detectors/word-list.d.ts.map +1 -0
  16. package/dist/detectors/word-list.js +3 -0
  17. package/dist/detectors/word-list.js.map +1 -0
  18. package/dist/plugin.d.ts +11 -1
  19. package/dist/plugin.d.ts.map +1 -1
  20. package/dist/profile/parse.d.ts.map +1 -1
  21. package/dist/profile/parse.js +15 -2
  22. package/dist/profile/parse.js.map +1 -1
  23. package/dist/render/compact.d.ts.map +1 -1
  24. package/dist/render/compact.js +3 -1
  25. package/dist/render/compact.js.map +1 -1
  26. package/dist/run.d.ts.map +1 -1
  27. package/dist/run.js +17 -5
  28. package/dist/run.js.map +1 -1
  29. package/dist/structure/build.d.ts.map +1 -1
  30. package/dist/structure/build.js +13 -6
  31. package/dist/structure/build.js.map +1 -1
  32. package/dist/structure/date-order.d.ts +12 -0
  33. package/dist/structure/date-order.d.ts.map +1 -0
  34. package/dist/structure/date-order.js +30 -0
  35. package/dist/structure/date-order.js.map +1 -0
  36. package/dist/structure/relative-find.d.ts +2 -0
  37. package/dist/structure/relative-find.d.ts.map +1 -1
  38. package/dist/structure/relative-find.js +11 -0
  39. package/dist/structure/relative-find.js.map +1 -1
  40. package/dist/structure/runs.d.ts +20 -0
  41. package/dist/structure/runs.d.ts.map +1 -0
  42. package/dist/structure/runs.js +63 -0
  43. package/dist/structure/runs.js.map +1 -0
  44. package/dist/structure/total.d.ts +14 -0
  45. package/dist/structure/total.d.ts.map +1 -0
  46. package/dist/structure/total.js +119 -0
  47. package/dist/structure/total.js.map +1 -0
  48. package/dist/structure/unnumbered.d.ts +15 -0
  49. package/dist/structure/unnumbered.d.ts.map +1 -0
  50. package/dist/structure/unnumbered.js +28 -0
  51. package/dist/structure/unnumbered.js.map +1 -0
  52. package/dist/structure/weekday.d.ts +7 -0
  53. package/dist/structure/weekday.d.ts.map +1 -0
  54. package/dist/structure/weekday.js +21 -0
  55. package/dist/structure/weekday.js.map +1 -0
  56. package/package.json +3 -3
  57. package/profiles/statute.yaml +5 -1
  58. package/rules/date-order.yaml +26 -0
  59. package/rules/date-weekday-mismatch.yaml +26 -0
  60. package/rules/internal-jargon.yaml +1 -1
  61. package/rules/total-mismatch.yaml +26 -0
  62. package/src/detectors/index.ts +4 -1
  63. package/src/detectors/lexicon.ts +11 -1
  64. package/src/detectors/structure-tree.ts +67 -1
  65. package/src/detectors/team.ts +7 -2
  66. package/src/detectors/word-list.ts +2 -0
  67. package/src/plugin.ts +15 -2
  68. package/src/profile/parse.ts +15 -2
  69. package/src/render/compact.ts +4 -1
  70. package/src/run.ts +18 -5
  71. package/src/structure/build.ts +19 -7
  72. package/src/structure/date-order.ts +44 -0
  73. package/src/structure/relative-find.ts +13 -0
  74. package/src/structure/runs.ts +71 -0
  75. package/src/structure/total.ts +157 -0
  76. package/src/structure/unnumbered.ts +33 -0
  77. package/src/structure/weekday.ts +25 -0
@@ -0,0 +1,21 @@
1
+ import { inDocumentOrder } from "./issues.js";
2
+ // 日付と、その横に書いた曜日の食い違い。暦で決まるので、両方読めれば誤りは機械で言える。
3
+ const FULL_DATE = /^(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})$/u;
4
+ /** 暦の曜日。日曜日が 0。存在しない日(2月30日)は undefined。 */
5
+ export const weekdayOf = (value) => {
6
+ const groups = FULL_DATE.exec(value)?.groups;
7
+ if (groups === undefined)
8
+ return undefined;
9
+ const [year, month, day] = [Number(groups["y"]), Number(groups["m"]), Number(groups["d"])];
10
+ const date = new Date(Date.UTC(year, month - 1, day));
11
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date.getUTCDay() : undefined;
12
+ };
13
+ /** 年まで書いた日付で、書いた曜日が暦と違うもの。年の無い日付は曜日を決められないので見ない。 */
14
+ export const weekdayMismatches = (tree) => inDocumentOrder(tree).flatMap((node) => {
15
+ const written = node.attrs["weekday"];
16
+ if (node.kind !== "date" || typeof written !== "number")
17
+ return [];
18
+ const actual = weekdayOf(String(node.attrs["value"]));
19
+ return actual === undefined || actual === written ? [] : [{ offset: node.span.start, values: { date: String(node.attrs["value"]), written, actual } }];
20
+ });
21
+ //# sourceMappingURL=weekday.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"weekday.js","sourceRoot":"","sources":["../../src/structure/weekday.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C,8CAA8C;AAE9C,MAAM,SAAS,GAAG,wCAAwC,CAAC;AAE3D,4CAA4C;AAC5C,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,KAAa,EAAsB,EAAE;IAC7D,MAAM,MAAM,GAAG,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAC7C,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC3C,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAC3F,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IACtD,OAAO,IAAI,CAAC,cAAc,EAAE,KAAK,IAAI,IAAI,IAAI,CAAC,WAAW,EAAE,KAAK,KAAK,GAAG,CAAC,IAAI,IAAI,CAAC,UAAU,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AACxI,CAAC,CAAC;AAEF,oDAAoD;AACpD,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,IAAmB,EAAoB,EAAE,CACzE,eAAe,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE;IACrC,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IACtC,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM,IAAI,OAAO,OAAO,KAAK,QAAQ;QAAE,OAAO,EAAE,CAAC;IACnE,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IACtD,OAAO,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;AACzJ,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chaffjs",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Prose validation harness. Finds what is hard to read, and never rewrites it for you.",
5
5
  "keywords": [
6
6
  "chaff",
@@ -54,8 +54,8 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@anthropic-ai/sdk": "^0.125.0",
57
- "@chaffjs/lang-en": "0.7.0",
58
- "@chaffjs/lang-ja": "0.8.0",
57
+ "@chaffjs/lang-en": "0.8.0",
58
+ "@chaffjs/lang-ja": "0.9.0",
59
59
  "mdast-util-from-markdown": "^2.0.3",
60
60
  "mdast-util-frontmatter": "^2.0.1",
61
61
  "mdast-util-gfm-table": "^2.0.0",
@@ -26,6 +26,9 @@ ja:
26
26
  address_end: "[^\\p{Script=Han}]|$"
27
27
  # 条の見出しは、条の前の行に括弧だけで書く(「(解雇の予告)」)。括弧の中が、すぐ次の条の見出しになる。
28
28
  caption: "^[ \\t\u3000]*[((]([^()()]{1,40})[))][ \\t\u3000]*$"
29
+ # 古い法令は第 2 項以降に番号を振らず、全角空白で字下げした行を新しい項(深さ 2)にする。
30
+ # 条の行に本文が続く条(「第三十四条 使用者は、…。」)の中だけ。契約書の「第1条(目的)」の次の字下げの行は、第 1 項の本文。
31
+ unnumbered: { indent: "\u3000", inside: article, depth: 2 }
29
32
  # 書いた場所から番地が決まる参照(前条・次項・同号・本条・前各項・前二項・前条第二項)。
30
33
  relative:
31
34
  before: [前]
@@ -42,7 +45,8 @@ ja:
42
45
  # 漢字の後ろの「前条」は語の一部(事前条件)。後ろの決まりは address_end と connectives を使う。
43
46
  not_after: "\\p{Script=Han}"
44
47
  # 読み替え(「『前条』とあるのは『…』と読み替える」)の括弧の中の前条・同項は、読み替える先の文の言葉。
45
- substitution: { open: 「, close: 」, after: [とあるのは], before: [とあるのは, とあるのは、] }
48
+ # 中の「第三条」も読み替える先の法令の番地なので、この文書の木では引かず、document の名前の文書を指す。
49
+ substitution: { open: 「, close: 」, after: [とあるのは], before: [とあるのは, とあるのは、], document: (読み替えの中) }
46
50
  # 条を書かない「第一項」「第二号」は、書いた条・項の中を指す。
47
51
  inside: [項, 号]
48
52
  # 並びをつなぐもの(正規表現)。「第三十三条第七項若しくは第九項」の「第九項」は、前の参照(第三十三条)の続き。
@@ -0,0 +1,26 @@
1
+ id: date-order
2
+ layer: L3
3
+ status: experimental
4
+ how_to_find: date-order
5
+ requires: [dates]
6
+ severity: warning
7
+
8
+ name:
9
+ ja: 日程の順番が前後している
10
+ en: A date out of order in a schedule
11
+
12
+ why:
13
+ ja: 旅程やマイルストーンのように日付を並べた箇条書きや表で、ひとつだけ前の日付より早い日付があると、書き間違いか順番の入れ替わりかを読み手が確かめることになります。並びが古い順か新しい順かは多いほうで決め、それに逆らう日付だけを指します。
14
+ en: In a list or a table of dates, such as an itinerary or milestones, one date earlier than the one before it makes the reader check whether it is a typo or a swapped row. The direction of the list (oldest or newest first) is taken from most of its steps, and only a date against it is pointed out.
15
+
16
+ message:
17
+ ja: 「{date}」が、前後の日付の並びから外れています(前は「{previous}」)
18
+ en: "{date} breaks the order of the dates around it (after {previous})"
19
+
20
+ how_to_fix:
21
+ ja: 日付を確かめて、書き間違いなら直し、順番が違うなら並べ替えてください。
22
+ en: Check the date. Correct it if it is a typo, or move the row if the order is wrong.
23
+
24
+ levels: { strict: 1, normal: 1, relaxed: 1 }
25
+
26
+ use_for: [business, technical, blog]
@@ -0,0 +1,26 @@
1
+ id: date-weekday-mismatch
2
+ layer: L3
3
+ status: experimental
4
+ how_to_find: date-weekday-mismatch
5
+ requires: [dates]
6
+ severity: error
7
+
8
+ name:
9
+ ja: 日付と曜日の食い違い
10
+ en: Date and weekday disagree
11
+
12
+ why:
13
+ ja: 「2026年10月1日(金)」のように、日付と曜日が食い違っていると、読み手はどちらが正しいのか確かめに行くことになります。日程を書き写すときや、年をまたいで予定を使い回すときに起きます。年まで書いた日付の曜日だけを、暦と比べます。
14
+ en: When a date and its weekday disagree ("Friday, 1 October 2026"), the reader has to find out which one is right. It happens when a schedule is copied, or reused for another year. Only dates written with their year are checked against the calendar.
15
+
16
+ message:
17
+ ja: 「{date}」は{actual}です({written}と書いてあります)
18
+ en: "{date} is a {actual}, not a {written}"
19
+
20
+ how_to_fix:
21
+ ja: 日付と曜日のどちらが正しいかを確かめて、片方を直してください。
22
+ en: Check which is right, the date or the weekday, and correct the other.
23
+
24
+ levels: { strict: 1, normal: 1, relaxed: 1 }
25
+
26
+ use_for: [business, technical, blog]
@@ -15,7 +15,7 @@ why:
15
15
 
16
16
  message:
17
17
  ja: 「{matched}」は社内でしか通じないかもしれません(そういう語が {count} 箇所)
18
- en: '"{matched}" may only work inside ({count} such words)'
18
+ en: '"{matched}" may only be understood inside your team ({count} such words)'
19
19
 
20
20
  how_to_fix:
21
21
  ja: 初出のところで普通の言葉に言い換えるか、括弧で説明を添えてください。jargon には辞書形(握る)でも語幹(巻き取)でも書けますが、複合動詞は語幹のほうが確実です。
@@ -0,0 +1,26 @@
1
+ id: total-mismatch
2
+ layer: L3
3
+ status: experimental
4
+ how_to_find: total-mismatch
5
+ requires: [quantities]
6
+ severity: error
7
+
8
+ name:
9
+ ja: 合計が内訳の和と合わない
10
+ en: A total that is not the sum of its items
11
+
12
+ why:
13
+ ja: 見積や請求の「合計」が、上に並べた金額の和と合わないと、読み手はどちらが正しいのかを計算し直すことになります。行を足したり消したりしたあとに、合計だけ直し忘れると起きます。箇条書きか表で、合計の語(合計・小計)で始まる行を、同じ単位で同じ列の金額の和と比べます。小計のあとの総計のように、足し方が何通りかあるときは、どの足し方でも合わないときだけ指します。
14
+ en: When the total of a quote or an invoice is not the sum of the amounts listed above it, the reader has to add them up again to find out which is right. It happens when a line is added or removed and the total is not updated. In a list or a table, a line starting with a total word (Total, Subtotal) is compared with the sum of the amounts in the same unit and column above it. When there is more than one way to add them up, such as a grand total after a subtotal and tax, it is pointed out only when none of them matches.
15
+
16
+ message:
17
+ ja: 合計「{written}」が、上の金額の和({sum})と合いません
18
+ en: "The total {written} is not the sum of the amounts above it ({sum})"
19
+
20
+ how_to_fix:
21
+ ja: 内訳の金額と合計のどちらが正しいかを確かめて、片方を直してください。
22
+ en: Check which is right, the items or the total, and correct the other.
23
+
24
+ levels: { strict: 1, normal: 1, relaxed: 1 }
25
+
26
+ use_for: [business, technical, blog]
@@ -15,7 +15,7 @@ import { concreteEvidence, dashDensity, emojiDensity, ngramRepetition, undefined
15
15
  import { aiTell, contractionMix, cushionDensity, hedgingDensity, repeatedConjunction, unqualifiedSuperlative } from "./lexicon.ts";
16
16
  import { internalJargon, properNounDensity, requiredSections } from "./team.ts";
17
17
  import { latinSpacing, preferredTerm } from "./orthography.ts";
18
- import { danglingReference, duplicateDefinition, numberingGap } from "./structure-tree.ts";
18
+ import { danglingReference, dateOrder, dateWeekdayMismatch, duplicateDefinition, numberingGap, totalMismatch } from "./structure-tree.ts";
19
19
 
20
20
  /** rule 定義の how_to_find がここを引く。rule 側は実装を知らない。 */
21
21
  export const DETECTORS: Readonly<Record<string, Detector>> = {
@@ -58,6 +58,9 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
58
58
  "preferred-term": preferredTerm,
59
59
  "latin-spacing": latinSpacing,
60
60
  "dangling-reference": danglingReference,
61
+ "date-weekday-mismatch": dateWeekdayMismatch,
62
+ "date-order": dateOrder,
63
+ "total-mismatch": totalMismatch,
61
64
  "numbering-gap": numberingGap,
62
65
  "duplicate-definition": duplicateDefinition,
63
66
  };
@@ -1,3 +1,4 @@
1
+ import { joinWords } from "./word-list.ts";
1
2
  import { proseText } from "../measure.ts";
2
3
  import { wordsOf } from "./structure.ts";
3
4
  import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
@@ -118,7 +119,16 @@ export const aiTell: Detector = (doc, options): Finding[] => {
118
119
  line: 0,
119
120
  column: 0,
120
121
  quote: first.text.trim(),
121
- values: { word: found.map(([pattern]) => pattern).join("、"), count: found.length, density: score, limit: options.limit, offset: first.span.start },
122
+ values: {
123
+ word: joinWords(
124
+ found.map(([pattern]) => pattern),
125
+ doc.language,
126
+ ),
127
+ count: found.length,
128
+ density: score,
129
+ limit: options.limit,
130
+ offset: first.span.start,
131
+ },
122
132
  },
123
133
  ];
124
134
  };
@@ -1,6 +1,9 @@
1
1
  import type { Detector, Finding, ProseDocument } from "../plugin.ts";
2
- import { danglingReferences, duplicateDefinitions, type StructureIssue } from "../structure/issues.ts";
2
+ import { danglingReferences, duplicateDefinitions, inDocumentOrder, type StructureIssue } from "../structure/issues.ts";
3
3
  import { numberingBreaks } from "../structure/numbering.ts";
4
+ import { weekdayMismatches } from "../structure/weekday.ts";
5
+ import { dateOrderBreaks } from "../structure/date-order.ts";
6
+ import { totalMismatches, type Amount } from "../structure/total.ts";
4
7
 
5
8
  const QUOTE_LENGTH = 80;
6
9
 
@@ -30,3 +33,66 @@ const findingsOf =
30
33
  export const danglingReference: Detector = findingsOf("dangling-reference", danglingReferences);
31
34
  export const numberingGap: Detector = findingsOf("numbering-gap", numberingBreaks);
32
35
  export const duplicateDefinition: Detector = findingsOf("duplicate-definition", duplicateDefinitions);
36
+
37
+ /** 曜日の名前は言語パッケージの語彙表(weekday、日曜日から順)から取る。無ければ番号のまま。 */
38
+ const dayName = (doc: ProseDocument, day: unknown): string => {
39
+ const index = Number(day);
40
+ return doc.lexicons["weekday"]?.[index]?.pattern ?? String(index);
41
+ };
42
+
43
+ export const dateWeekdayMismatch: Detector = (doc): Finding[] =>
44
+ doc.structure === undefined
45
+ ? []
46
+ : weekdayMismatches(doc.structure).map((issue) => ({
47
+ rule: "date-weekday-mismatch",
48
+ severity: "error",
49
+ line: 0,
50
+ column: 0,
51
+ quote: quoteAt(doc.source, issue.offset),
52
+ values: {
53
+ date: String(issue.values["date"]),
54
+ written: dayName(doc, issue.values["written"]),
55
+ actual: dayName(doc, issue.values["actual"]),
56
+ offset: issue.offset,
57
+ },
58
+ }));
59
+
60
+ /** 木の日付を、原文の位置と一緒に並べる。 */
61
+ const datedPoints = (tree: NonNullable<ProseDocument["structure"]>): { offset: number; value: string }[] =>
62
+ inDocumentOrder(tree).flatMap((node) => (node.kind === "date" ? [{ offset: node.span.start, value: String(node.attrs["value"]) }] : []));
63
+
64
+ /** 日程の並びに逆らう日付。並びの読み方は原文の行を見るので、木と原文の両方を渡す。 */
65
+ export const dateOrder: Detector = (doc): Finding[] =>
66
+ doc.structure === undefined
67
+ ? []
68
+ : dateOrderBreaks(doc.source, datedPoints(doc.structure)).map((issue) => ({
69
+ rule: "date-order",
70
+ severity: "warning",
71
+ line: 0,
72
+ column: 0,
73
+ quote: quoteAt(doc.source, issue.offset),
74
+ values: { ...issue.values, offset: issue.offset },
75
+ }));
76
+
77
+ /** 木の数量を、原文の位置と一緒に並べる。 */
78
+ const amountsOf = (tree: NonNullable<ProseDocument["structure"]>): Amount[] =>
79
+ inDocumentOrder(tree).flatMap((node) =>
80
+ node.kind === "quantity" ? [{ offset: node.span.start, end: node.span.end, value: Number(node.attrs["value"]), unit: String(node.attrs["unit"]) }] : [],
81
+ );
82
+
83
+ /** 合計の行が、上の金額の和と合わない。合計の語は言語パッケージの語彙表(total-label)から取る。 */
84
+ export const totalMismatch: Detector = (doc): Finding[] =>
85
+ doc.structure === undefined
86
+ ? []
87
+ : totalMismatches(
88
+ doc.source,
89
+ amountsOf(doc.structure),
90
+ (doc.lexicons["total-label"] ?? []).map((entry) => entry.pattern),
91
+ ).map((issue) => ({
92
+ rule: "total-mismatch",
93
+ severity: "error",
94
+ line: 0,
95
+ column: 0,
96
+ quote: quoteAt(doc.source, issue.offset),
97
+ values: { ...issue.values, offset: issue.offset },
98
+ }));
@@ -1,4 +1,5 @@
1
1
  import { proseText } from "../measure.ts";
2
+ import { joinWords } from "./word-list.ts";
2
3
  import { wordsOf } from "./structure.ts";
3
4
  import type { Detector, Finding, Sentence, Token } from "../plugin.ts";
4
5
 
@@ -12,7 +13,11 @@ import type { Detector, Finding, Sentence, Token } from "../plugin.ts";
12
13
  * 表層でも原形でも当てる。利用者は辞書形で書く(「握る」)が、本文は活用している(「握った」)。
13
14
  * 品詞解析があれば原形で当たり、無くても表層で当たる。要求はしない。
14
15
  */
15
- const matches = (sentence: Sentence, word: string): boolean => sentence.text.includes(word) || (sentence.tokens ?? []).some((token) => token.lemma === word);
16
+ const matches = (sentence: Sentence, word: string): boolean => {
17
+ // 大文字小文字は区別しない。英語の文頭の「Circle back」も「circle back」として当てる。
18
+ const wanted = word.toLowerCase();
19
+ return sentence.text.toLowerCase().includes(wanted) || (sentence.tokens ?? []).some((token) => token.lemma?.toLowerCase() === wanted);
20
+ };
16
21
 
17
22
  export const internalJargon: Detector = (doc, options): Finding[] => {
18
23
  const words = (options.lexicon ?? []).map((entry) => entry.pattern);
@@ -53,7 +58,7 @@ export const requiredSections: Detector = (doc, options): Finding[] => {
53
58
  line: 0,
54
59
  column: 0,
55
60
  quote: headings.filter((heading) => heading.length > 0).join(" / "),
56
- values: { word: missing.join("、"), count: missing.length, limit: options.limit, offset: 0 },
61
+ values: { word: joinWords(missing, doc.language), count: missing.length, limit: options.limit, offset: 0 },
57
62
  },
58
63
  ];
59
64
  };
@@ -0,0 +1,2 @@
1
+ /** 語の並べ方は文書の言語に合わせる。日本語は「、」、ほかは ", "。英語の画面に「Risks、Costs」と出さない。 */
2
+ export const joinWords = (words: readonly string[], language: string): string => words.join(language === "ja" ? "、" : ", ");
package/src/plugin.ts CHANGED
@@ -213,6 +213,11 @@ export type DocumentProfile = {
213
213
  readonly caption?: string | undefined;
214
214
  /** 前条・同項のように、書いた場所から番地が決まる参照の読み方。無ければ読まない。 */
215
215
  readonly relative?: RelativeVocabulary | undefined;
216
+ /**
217
+ * 番号を書かない単位(古い法令の第 2 項以降)。indent で始まる行を、inside の種類の単位の中の、depth の深さの次の単位にする。
218
+ * inside の単位の行に、番号の後ろの本文が続くときだけ。
219
+ */
220
+ readonly unnumbered?: { readonly indent: string; readonly inside: string; readonly depth: number } | undefined;
216
221
  };
217
222
 
218
223
  /**
@@ -239,9 +244,17 @@ export type RelativeVocabulary = {
239
244
  readonly notAfter: string | undefined;
240
245
  /**
241
246
  * 読み替え(「『前条』とあるのは『…』」)の括弧。中の前条・同項は読み替える先の文の言葉で、ここからは決められないので読まない。
242
- * 閉じの直後に after、開きの直前に before のどれかがあるものだけ。
247
+ * 閉じの直後に after、開きの直前に before のどれかがあるものだけ。中の番地を名指しした参照は、document の名前の文書を指す。
243
248
  */
244
- readonly substitution: { readonly open: string; readonly close: string; readonly after: readonly string[]; readonly before: readonly string[] } | undefined;
249
+ readonly substitution:
250
+ | {
251
+ readonly open: string;
252
+ readonly close: string;
253
+ readonly after: readonly string[];
254
+ readonly before: readonly string[];
255
+ readonly document: string | undefined;
256
+ }
257
+ | undefined;
245
258
  /** 条を書かずにこの単位で始まる番地(法令の「第一項」「第二号」)は、書いた場所を含むまとまりの中を指す。 */
246
259
  readonly inside: readonly string[];
247
260
  /**
@@ -41,7 +41,16 @@ const substitutionOf = (value: unknown): RelativeVocabulary["substitution"] => {
41
41
  if (!isRecord(value)) return undefined;
42
42
  const [open, close] = [text(value["open"]), text(value["close"])];
43
43
  const [after, before] = [strings(value["after"]), strings(value["before"])];
44
- return open === undefined || close === undefined || after.length + before.length === 0 ? undefined : { open, close, after, before };
44
+ return open === undefined || close === undefined || after.length + before.length === 0
45
+ ? undefined
46
+ : { open, close, after, before, document: text(value["document"]) };
47
+ };
48
+
49
+ /** 字下げと、どの種類の中か、どの深さかのどれかが無ければ読まない。 */
50
+ const unnumberedOf = (value: unknown): DocumentProfile["unnumbered"] => {
51
+ if (!isRecord(value)) return undefined;
52
+ const [indent, inside, depth] = [text(value["indent"]), text(value["inside"]), value["depth"]];
53
+ return indent === undefined || inside === undefined || !Number.isInteger(depth) || typeof depth !== "number" ? undefined : { indent, inside, depth };
45
54
  };
46
55
 
47
56
  /** 単位が一つも無ければ読まない。何を指すのか決められない。 */
@@ -78,7 +87,11 @@ const languagesOf = (id: string, raw: Record<string, unknown>): Record<string, D
78
87
  const addressEnd = text(section["address_end"]);
79
88
  const caption = text(section["caption"]);
80
89
  const relative = relativeOf(section["relative"]);
81
- return [language, { id, addresses: strings(section["addresses"]), connectives: strings(section["connectives"]), addressEnd, caption, relative }];
90
+ const unnumbered = unnumberedOf(section["unnumbered"]);
91
+ return [
92
+ language,
93
+ { id, addresses: strings(section["addresses"]), connectives: strings(section["connectives"]), addressEnd, caption, relative, unnumbered },
94
+ ];
82
95
  }),
83
96
  );
84
97
 
@@ -2,6 +2,9 @@ import type { RuleDefinition } from "../plugin.ts";
2
2
  import type { RunResult } from "../run.ts";
3
3
  import { messageOf } from "./text.ts";
4
4
 
5
+ /** 「1 finding」「2 findings」。 */
6
+ const counted = (count: number, noun: string): string => `${String(count)} ${noun}${count === 1 ? "" : "s"}`;
7
+
5
8
  /** エンジニア向け。1 件 2 行。 */
6
9
  export const renderCompact = (header: string, result: RunResult, rules: readonly RuleDefinition[], language: string): string => {
7
10
  const byId = new Map(rules.map((rule) => [rule.id, rule]));
@@ -11,7 +14,7 @@ export const renderCompact = (header: string, result: RunResult, rules: readonly
11
14
  const at = `${finding.line}:${finding.column}`.padEnd(8);
12
15
  return [` ${at}${finding.severity.padEnd(8)}${messageOf(rule, finding, language)}`, ` ${" ".repeat(8)}${finding.rule}`];
13
16
  });
14
- const tail = [`${result.findings.length} findings`, result.skipped.length > 0 ? `${result.skipped.length} rules not run` : undefined].filter(
17
+ const tail = [counted(result.findings.length, "finding"), result.skipped.length > 0 ? `${counted(result.skipped.length, "rule")} not run` : undefined].filter(
15
18
  (part) => part !== undefined,
16
19
  );
17
20
  return ["", header, "", ...lines, "", tail.join(", "), ""].join("\n");
package/src/run.ts CHANGED
@@ -3,6 +3,7 @@ import { resolve } from "./levels.ts";
3
3
  import type { AdapterNeeds, Finding, Level, ProseDocument, RuleDefinition } from "./plugin.ts";
4
4
  import { lineStarts, placeOf } from "./position.ts";
5
5
  import { REASONS, type Reasons } from "./reasons.ts";
6
+ import { joinWords } from "./detectors/word-list.ts";
6
7
  import { unreadStructure, type Unread } from "./structure/unread.ts";
7
8
  import { isMarkdownPath } from "./structure/markdown-path.ts";
8
9
  import { uiLanguageOf } from "./ui.ts";
@@ -32,6 +33,18 @@ const reasonsFor = (doc: ProseDocument): Reasons => REASONS[uiLanguageOf(doc.lan
32
33
  * 構造の rule が動けない理由。木を作れない言語か、番号の行を読めなかった文書。
33
34
  * 読めなかった文書で「参照先が無い」が 0 件なのは、確かめた結果ではないので、読めなかったと言う。
34
35
  */
36
+ /**
37
+ * 木を読む rule の要求。structure は番号の並びを読む(読めなかった文書では動かない)。dates と quantities は木の日付と数量だけを
38
+ * 読むので、番号を読めなかった文書でも動く。どちらも capability ではなく、adapter が木を作れるかで決まる。
39
+ */
40
+ const TREE_NEEDS: ReadonlySet<string> = new Set(["structure", "dates", "quantities"]);
41
+ const VALUE_NEEDS: readonly string[] = ["dates", "quantities"];
42
+
43
+ const treeNeed = (rule: RuleDefinition, doc: ProseDocument): string | undefined => {
44
+ if (rule.requires.includes("structure")) return treeProblem(doc);
45
+ return rule.requires.some((need) => VALUE_NEEDS.includes(need)) && doc.structure === undefined ? reasonsFor(doc).noStructure(doc.language) : undefined;
46
+ };
47
+
35
48
  const treeProblem = (doc: ProseDocument): string | undefined => {
36
49
  if (doc.structure === undefined) return reasonsFor(doc).noStructure(doc.language);
37
50
  const unread = unreadOf(doc);
@@ -68,7 +81,7 @@ const hasTokens = (doc: ProseDocument): boolean => doc.sentences.length === 0 ||
68
81
  /** 要求を満たさない rule は動かせない。満たさないまま動かすと「指摘 0 件」が保証に見える。 */
69
82
  const unmet = (rule: RuleDefinition, doc: ProseDocument): string | undefined => {
70
83
  if (rule.languages !== undefined && !rule.languages.includes(doc.language)) return reasonsFor(doc).otherLanguage(doc.language);
71
- const missing = rule.requires.filter((need) => need !== "structure").find((need) => !has(doc.capabilities, need));
84
+ const missing = rule.requires.filter((need) => !TREE_NEEDS.has(need)).find((need) => !has(doc.capabilities, need));
72
85
  if (missing !== undefined) return reasonsFor(doc).noCapability(missing);
73
86
  return undefined;
74
87
  };
@@ -107,7 +120,7 @@ const place = (starts: readonly number[], finding: Finding): Finding => {
107
120
  * 元の指摘は消さない。`padded-intro` のように単独でも正しい指摘が混ざっており、
108
121
  * まとめるために消すと本物の指摘が見えなくなる。
109
122
  */
110
- const compositeOf = (rule: RuleDefinition, found: readonly Finding[], limit: number, starts: readonly number[]): Finding[] => {
123
+ const compositeOf = (rule: RuleDefinition, found: readonly Finding[], limit: number, starts: readonly number[], language: string): Finding[] => {
111
124
  const fired = rule.from.filter((id) => found.some((finding) => finding.rule === id));
112
125
  if (fired.length < limit) return [];
113
126
  const first = found.find((finding) => fired.includes(finding.rule));
@@ -118,7 +131,7 @@ const compositeOf = (rule: RuleDefinition, found: readonly Finding[], limit: num
118
131
  line: 0,
119
132
  column: 0,
120
133
  quote: first?.quote ?? "",
121
- values: { word: fired.join("、"), count: fired.length, limit, offset: first === undefined ? 0 : Number(first.values["offset"] ?? 0) },
134
+ values: { word: joinWords(fired, language), count: fired.length, limit, offset: first === undefined ? 0 : Number(first.values["offset"] ?? 0) },
122
135
  }),
123
136
  ];
124
137
  };
@@ -158,7 +171,7 @@ export const runRules = (
158
171
  if (noTags !== undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: noTags }] };
159
172
  // 木は capability ではなく、adapter が structure を持つかで決まる。持たない言語で動かすと「参照先が無い」が 0 件に見える。
160
173
  // 段階を見た後で聞く。doc.structure は触れたときに木を作るので、止めている rule のために作らない。
161
- const noTree = rule.requires.includes("structure") ? treeProblem(doc) : undefined;
174
+ const noTree = treeNeed(rule, doc);
162
175
  if (noTree !== undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: noTree }] };
163
176
  // 複合シグナルは二段目で扱う。一段目では「検出器が無い」と言わせない。
164
177
  if (rule.from.length > 0) return acc;
@@ -181,7 +194,7 @@ export const runRules = (
181
194
  );
182
195
  const composites = applicable
183
196
  .filter((rule) => rule.from.length > 0 && levelFor(rule, settings, experimental) !== "off")
184
- .flatMap((rule) => compositeOf(rule, outcome.findings, limitFor(rule, levelFor(rule, settings, experimental), genre, limits), starts));
197
+ .flatMap((rule) => compositeOf(rule, outcome.findings, limitFor(rule, levelFor(rule, settings, experimental), genre, limits), starts, doc.language));
185
198
  const all = [...outcome.findings, ...composites];
186
199
  return { ...outcome, findings: [...all].sort((left, right) => left.line - right.line), forcedExperimental: forced };
187
200
  };
@@ -1,12 +1,13 @@
1
1
  import type { Heading } from "../document.ts";
2
2
  import { atxHeadingText, headingText } from "../heading-text.ts";
3
3
  import { addressSpans } from "../address-chain.ts";
4
- import { relativeMentions } from "./relative-find.ts";
4
+ import { relativeMentions, substitutedDocuments } from "./relative-find.ts";
5
5
  import { resolveRelative } from "./relative-resolve.ts";
6
6
  import { maskSpans } from "../mask.ts";
7
7
  import type { DocumentProfile, Mention, NumberedLine, NumberingContext, Span, StructureKind, StructureNode, StructurePatterns } from "../plugin.ts";
8
8
  import { lineNumberAt, linesOf, type Line } from "./lines.ts";
9
9
  import { dottedNumber } from "./universal.ts";
10
+ import { unnumberedUnit, type OpenUnit } from "./unnumbered.ts";
10
11
 
11
12
  /** 組み立て中の節点。できあがったら StructureNode に固める。 */
12
13
  type Draft = {
@@ -24,7 +25,7 @@ type Draft = {
24
25
  };
25
26
 
26
27
  /** 開いている節点。rank の大きいものほど内側。 */
27
- type Frame = { readonly draft: Draft; readonly rank: number; readonly numbered?: NumberedLine | undefined };
28
+ type Frame = { readonly draft: Draft; readonly rank: number; readonly numbered?: NumberedLine | undefined; readonly bodyOnLine?: boolean };
28
29
 
29
30
  /**
30
31
  * 見出しは深さ 1〜6 を rank にする。番号のまとまりは必ず見出しの内側に入るので、
@@ -96,7 +97,7 @@ const headingAddress = (state: State, depth: number): string => {
96
97
  * 見出しに書いた番号(「## 第3条」)は、見出しの深さで入れ子にする。本文の番号は見出しの内側に入る。
97
98
  * 見出しの深さを捨てると、「## 第3条」の下の「### 詳細」が条の外に出てしまう。
98
99
  */
99
- const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingDepth: number | undefined): void => {
100
+ const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingDepth: number | undefined, bodyOnLine: boolean): void => {
100
101
  const attrs = { label: numbered.label, ...(numbered.heading === "" ? {} : { heading: numbered.heading }) };
101
102
  const rank = headingDepth ?? NUMBERED_RANK + numbered.depth;
102
103
  // 番地は、自分と同じか内側を閉じてから決める。閉じる前だと、同じ深さの前の条を親と取り違える。
@@ -108,7 +109,7 @@ const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingD
108
109
  ordinalTo: numbered.ordinalTo,
109
110
  numbering: numbered.numbering,
110
111
  };
111
- open(state, { draft, rank, numbered });
112
+ open(state, { draft, rank, numbered, bodyOnLine });
112
113
  };
113
114
 
114
115
  const openSection = (state: State, line: Line, heading: Heading): void => {
@@ -130,7 +131,7 @@ type LeafFinder = (patterns: StructurePatterns, text: string, profile: DocumentP
130
131
 
131
132
  const LEAVES: readonly { readonly kind: StructureKind; readonly find: LeafFinder }[] = [
132
133
  { kind: "definition", find: (patterns, text) => patterns.definitions(text) },
133
- { kind: "reference", find: (patterns, text) => patterns.references(text) },
134
+ { kind: "reference", find: (patterns, text, profile) => substitutedDocuments(patterns.references(text), text, profile) },
134
135
  {
135
136
  kind: "reference",
136
137
  find: (patterns, text, profile) => {
@@ -265,6 +266,16 @@ const captionOf = (profile: DocumentProfile | undefined, text: string): string |
265
266
  return words === "" ? undefined : words;
266
267
  };
267
268
 
269
+ const openUnits = (state: State): OpenUnit[] =>
270
+ state.stack.flatMap((frame) => (frame.numbered === undefined ? [] : [{ numbered: frame.numbered, bodyOnLine: frame.bodyOnLine === true }]));
271
+
272
+ /** 言語の番号の読み方、文書の種類の番号を書かない単位、言語を問わない通し番号の順に読む。 */
273
+ const numberedLine = (state: State, patterns: StructurePatterns, text: string, context: NumberingContext): NumberedLine | undefined =>
274
+ patterns.numbered(text, context) ?? unnumberedUnit(text, openUnits(state), state.profile?.unnumbered) ?? universalNumber(patterns, text, context);
275
+
276
+ /** 番号の後ろが見出しでなく本文か。前の行から見出しが付く前の、行そのもので決める。 */
277
+ const carriesBody = (found: NumberedLine | undefined): boolean => found !== undefined && found.heading === "" && found.rest !== "";
278
+
268
279
  /** 見出しの無い条のすぐ前の行が見出しだけの行なら、それを条の見出しにする。 */
269
280
  const withCaption = (state: State, line: Line, numbered: NumberedLine | undefined): NumberedLine | undefined => {
270
281
  const caption = numbered?.kind === "article" && numbered.heading === "" ? state.captions.get(line.number - 1) : undefined;
@@ -275,12 +286,13 @@ const readLine = (state: State, patterns: StructurePatterns, line: Line, heading
275
286
  const text = heading === undefined ? line.text : headingLineText(line.text);
276
287
  const openNumbers = state.stack.flatMap((frame) => (frame.numbered === undefined ? [] : [frame.numbered]));
277
288
  const context = { open: openNumbers, isHeading: heading !== undefined };
278
- const numbered = withCaption(state, line, patterns.numbered(text, context) ?? universalNumber(patterns, text, context));
289
+ const found = numberedLine(state, patterns, text, context);
290
+ const numbered = withCaption(state, line, found);
279
291
  const caption = numbered === undefined && heading === undefined ? captionOf(state.profile, text) : undefined;
280
292
  if (caption !== undefined) state.captions.set(line.number, caption);
281
293
  // 番号付きの見出しも見出しの通し番号を進める。進めないと、その下の「### 詳細」が前の見出しの番地を名乗る。
282
294
  if (numbered !== undefined && heading !== undefined) headingAddress(state, heading.depth);
283
- if (numbered !== undefined) openNumbered(state, line, numbered, heading?.depth);
295
+ if (numbered !== undefined) openNumbered(state, line, numbered, heading?.depth, carriesBody(found));
284
296
  else if (heading !== undefined) openSection(state, line, heading);
285
297
  extend(state, line.start + line.text.length);
286
298
  // 番号付きの行は番号の後ろだけを読む。「第3条(支払)」の「第3条」を自分への参照として拾わない。
@@ -0,0 +1,44 @@
1
+ import type { StructureIssue } from "./issues.ts";
2
+ import { runsOf, type Line } from "./runs.ts";
3
+
4
+ /**
5
+ * 日程として並べた日付の順番。箇条書きの続いた項目か、表の続いた行で、日付をちょうど一つ持つものを並びとして読む。
6
+ * 並びの向き(古い順か新しい順か)は多いほうで決め、それに逆らう一歩だけを言う。新しい順に並べた履歴は正しい並び。
7
+ * 向きが決まらない並び(上がりと下がりが同じ数)は何も言わない。逆らう一歩を言えるのは、日付が 4 つ以上の並びだけになる。
8
+ */
9
+ export type DatedPoint = { readonly offset: number; readonly value: string };
10
+
11
+ /** 並びとして比べられる日付の書き方。年月日、年月、月日は、同じ書き方どうしでしか比べない。 */
12
+ const PRECISIONS = [/^\d{4}-\d{2}-\d{2}$/u, /^\d{4}-\d{2}$/u, /^\d{2}-\d{2}$/u];
13
+
14
+ /** 行ごとの日付。ちょうど一つの行だけを並びに入れる。二つある行(期間)はどちらを並べたのか決められない。 */
15
+ const datedLines = (run: readonly Line[], points: readonly DatedPoint[]): DatedPoint[] =>
16
+ run.flatMap((line) => {
17
+ const inside = points.filter((point) => point.offset >= line.start && point.offset <= line.end);
18
+ return inside.length === 1 && inside[0] !== undefined ? [inside[0]] : [];
19
+ });
20
+
21
+ const samePrecision = (dated: readonly DatedPoint[]): boolean => PRECISIONS.some((pattern) => dated.every((point) => pattern.test(point.value)));
22
+
23
+ /** 上がりが多ければ 1、下がりが多ければ -1、同じ数なら 0(向きを決めない)。 */
24
+ const majorityOf = (up: number, down: number): number => Math.sign(up - down);
25
+
26
+ /** 多いほうの向きに逆らう一歩。後ろの項目の日付を指す。 */
27
+ const againstMajority = (dated: readonly DatedPoint[]): StructureIssue[] => {
28
+ const steps = dated
29
+ .slice(1)
30
+ .map((point, index) => ({ point, previous: dated[index], sign: Math.sign(point.value.localeCompare(dated[index]?.value ?? "")) }));
31
+ const up = steps.filter((step) => step.sign > 0).length;
32
+ const down = steps.filter((step) => step.sign < 0).length;
33
+ const majority = majorityOf(up, down);
34
+ if (majority === 0) return [];
35
+ return steps
36
+ .filter((step) => step.sign === -majority)
37
+ .map((step) => ({ offset: step.point.offset, values: { date: step.point.value, previous: step.previous?.value ?? "" } }));
38
+ };
39
+
40
+ export const dateOrderBreaks = (source: string, points: readonly DatedPoint[]): StructureIssue[] =>
41
+ runsOf(source).flatMap((run) => {
42
+ const dated = datedLines(run, points);
43
+ return samePrecision(dated) ? againstMajority(dated) : [];
44
+ });
@@ -105,6 +105,19 @@ const substitutionSpans = (text: string, quote: Substitution | undefined): Span[
105
105
  (span) => quote.after.some((marker) => text.startsWith(marker, span.end)) || quote.before.some((marker) => text.slice(0, span.start).endsWith(marker)),
106
106
  );
107
107
 
108
+ /** 読み替えの括弧の中の、文書を名指ししていない参照に、読み替える先の文書の名前を付ける。 */
109
+ export const substitutedDocuments = (mentions: readonly Mention[], text: string, profile: DocumentProfile | undefined): readonly Mention[] => {
110
+ const quote = profile?.relative?.substitution;
111
+ const document = quote?.document;
112
+ if (document === undefined) return mentions;
113
+ const spans = substitutionSpans(text, quote);
114
+ return mentions.map((mention) =>
115
+ mention.attrs["document"] === undefined && spans.some((span) => span.start <= mention.start && mention.start < span.end)
116
+ ? { ...mention, attrs: { ...mention.attrs, document } }
117
+ : mention,
118
+ );
119
+ };
120
+
108
121
  /** 閉じた括弧書きを内側から外す。 */
109
122
  const withoutAsides = (gap: string, aside: RelativeVocabulary["aside"]): string => {
110
123
  if (aside === undefined) return gap;