chaffjs 0.5.1 → 0.7.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 (133) hide show
  1. package/dist/adapter-load.d.ts.map +1 -1
  2. package/dist/adapter-load.js +18 -2
  3. package/dist/adapter-load.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +15 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/cite.d.ts +16 -0
  8. package/dist/commands/cite.d.ts.map +1 -0
  9. package/dist/commands/cite.js +69 -0
  10. package/dist/commands/cite.js.map +1 -0
  11. package/dist/commands/eval.d.ts.map +1 -1
  12. package/dist/commands/eval.js +1 -1
  13. package/dist/commands/eval.js.map +1 -1
  14. package/dist/commands/tree.d.ts +33 -0
  15. package/dist/commands/tree.d.ts.map +1 -0
  16. package/dist/commands/tree.js +76 -0
  17. package/dist/commands/tree.js.map +1 -0
  18. package/dist/config/load.d.ts +9 -0
  19. package/dist/config/load.d.ts.map +1 -1
  20. package/dist/config/load.js +27 -1
  21. package/dist/config/load.js.map +1 -1
  22. package/dist/config/rule-problems.d.ts +10 -0
  23. package/dist/config/rule-problems.d.ts.map +1 -0
  24. package/dist/config/rule-problems.js +20 -0
  25. package/dist/config/rule-problems.js.map +1 -0
  26. package/dist/detectors/index.d.ts.map +1 -1
  27. package/dist/detectors/index.js +7 -0
  28. package/dist/detectors/index.js.map +1 -1
  29. package/dist/detectors/orthography.d.ts +9 -0
  30. package/dist/detectors/orthography.d.ts.map +1 -0
  31. package/dist/detectors/orthography.js +51 -0
  32. package/dist/detectors/orthography.js.map +1 -0
  33. package/dist/detectors/structure-tree.d.ts +5 -0
  34. package/dist/detectors/structure-tree.d.ts.map +1 -0
  35. package/dist/detectors/structure-tree.js +25 -0
  36. package/dist/detectors/structure-tree.js.map +1 -0
  37. package/dist/document.d.ts +12 -0
  38. package/dist/document.d.ts.map +1 -1
  39. package/dist/document.js +34 -3
  40. package/dist/document.js.map +1 -1
  41. package/dist/eval.d.ts +5 -1
  42. package/dist/eval.d.ts.map +1 -1
  43. package/dist/eval.js +23 -7
  44. package/dist/eval.js.map +1 -1
  45. package/dist/heading-text.d.ts +14 -0
  46. package/dist/heading-text.d.ts.map +1 -0
  47. package/dist/heading-text.js +28 -0
  48. package/dist/heading-text.js.map +1 -0
  49. package/dist/orthography.d.ts +21 -0
  50. package/dist/orthography.d.ts.map +1 -0
  51. package/dist/orthography.js +110 -0
  52. package/dist/orthography.js.map +1 -0
  53. package/dist/plugin.d.ts +71 -0
  54. package/dist/plugin.d.ts.map +1 -1
  55. package/dist/position.d.ts.map +1 -1
  56. package/dist/position.js +21 -1
  57. package/dist/position.js.map +1 -1
  58. package/dist/render/rules-json.d.ts.map +1 -1
  59. package/dist/render/rules-json.js +11 -1
  60. package/dist/render/rules-json.js.map +1 -1
  61. package/dist/run.d.ts +3 -1
  62. package/dist/run.d.ts.map +1 -1
  63. package/dist/run.js +11 -5
  64. package/dist/run.js.map +1 -1
  65. package/dist/structure/build.d.ts +20 -0
  66. package/dist/structure/build.d.ts.map +1 -0
  67. package/dist/structure/build.js +165 -0
  68. package/dist/structure/build.js.map +1 -0
  69. package/dist/structure/cite.d.ts +21 -0
  70. package/dist/structure/cite.d.ts.map +1 -0
  71. package/dist/structure/cite.js +91 -0
  72. package/dist/structure/cite.js.map +1 -0
  73. package/dist/structure/issues.d.ts +15 -0
  74. package/dist/structure/issues.d.ts.map +1 -0
  75. package/dist/structure/issues.js +53 -0
  76. package/dist/structure/issues.js.map +1 -0
  77. package/dist/structure/lines.d.ts +15 -0
  78. package/dist/structure/lines.d.ts.map +1 -0
  79. package/dist/structure/lines.js +33 -0
  80. package/dist/structure/lines.js.map +1 -0
  81. package/dist/structure/markdown-path.d.ts +3 -0
  82. package/dist/structure/markdown-path.d.ts.map +1 -0
  83. package/dist/structure/markdown-path.js +5 -0
  84. package/dist/structure/markdown-path.js.map +1 -0
  85. package/dist/structure/numbering.d.ts +9 -0
  86. package/dist/structure/numbering.d.ts.map +1 -0
  87. package/dist/structure/numbering.js +38 -0
  88. package/dist/structure/numbering.js.map +1 -0
  89. package/dist/structure/of.d.ts +13 -0
  90. package/dist/structure/of.d.ts.map +1 -0
  91. package/dist/structure/of.js +8 -0
  92. package/dist/structure/of.js.map +1 -0
  93. package/dist/structure/sexp.d.ts +7 -0
  94. package/dist/structure/sexp.d.ts.map +1 -0
  95. package/dist/structure/sexp.js +25 -0
  96. package/dist/structure/sexp.js.map +1 -0
  97. package/dist/structure/universal.d.ts +7 -0
  98. package/dist/structure/universal.d.ts.map +1 -0
  99. package/dist/structure/universal.js +21 -0
  100. package/dist/structure/universal.js.map +1 -0
  101. package/package.json +3 -3
  102. package/rules/dangling-reference.yaml +27 -0
  103. package/rules/duplicate-definition.yaml +26 -0
  104. package/rules/latin-spacing.yaml +27 -0
  105. package/rules/numbering-gap.yaml +26 -0
  106. package/rules/preferred-term.yaml +27 -0
  107. package/src/adapter-load.ts +19 -2
  108. package/src/cli.ts +16 -1
  109. package/src/commands/cite.ts +74 -0
  110. package/src/commands/eval.ts +1 -0
  111. package/src/commands/tree.ts +94 -0
  112. package/src/config/load.ts +43 -1
  113. package/src/config/rule-problems.ts +24 -0
  114. package/src/detectors/index.ts +7 -0
  115. package/src/detectors/orthography.ts +55 -0
  116. package/src/detectors/structure-tree.ts +32 -0
  117. package/src/document.ts +49 -6
  118. package/src/eval.ts +27 -7
  119. package/src/heading-text.ts +26 -0
  120. package/src/orthography.ts +115 -0
  121. package/src/plugin.ts +75 -0
  122. package/src/position.ts +21 -1
  123. package/src/render/rules-json.ts +10 -1
  124. package/src/run.ts +22 -5
  125. package/src/structure/build.ts +205 -0
  126. package/src/structure/cite.ts +115 -0
  127. package/src/structure/issues.ts +63 -0
  128. package/src/structure/lines.ts +32 -0
  129. package/src/structure/markdown-path.ts +6 -0
  130. package/src/structure/numbering.ts +44 -0
  131. package/src/structure/of.ts +15 -0
  132. package/src/structure/sexp.ts +28 -0
  133. package/src/structure/universal.ts +21 -0
@@ -0,0 +1,55 @@
1
+ import type { Detector, Finding, Sentence } from "../plugin.ts";
2
+ import { latinBoundaries, minorityStyle, occurrencesOutside, type SpacingKind } from "../orthography.ts";
3
+
4
+ /** チームが chaff.yaml の prefer に書いた「使わない書き方」。書いていなければ何も言わない。 */
5
+ export const preferredTerm: Detector = (doc, options): Finding[] => {
6
+ const pairs = (options.lexicon ?? []).flatMap((entry) => (entry.instead_of === undefined ? [] : [{ avoid: entry.pattern, use: entry.instead_of }]));
7
+ const hits = doc.sentences.flatMap((sentence) =>
8
+ pairs.flatMap((pair) => occurrencesOutside(sentence.text, pair.avoid, pair.use).map((at) => ({ sentence, at, pair }))),
9
+ );
10
+ if (hits.length === 0 || hits.length < options.limit) return [];
11
+ return hits.map((hit) => ({
12
+ rule: "",
13
+ severity: "warning",
14
+ line: 0,
15
+ column: 0,
16
+ quote: hit.sentence.text.trim(),
17
+ values: { matched: hit.pair.avoid, preferred: hit.pair.use, count: hits.length, limit: options.limit, offset: hit.sentence.span.start + hit.at },
18
+ }));
19
+ };
20
+
21
+ const KIND_NAME: Readonly<Record<SpacingKind, string>> = { letter: "英字", "before-digit": "後ろの数字", "after-digit": "前の数字" };
22
+ const KINDS: readonly SpacingKind[] = ["letter", "before-digit", "after-digit"];
23
+
24
+ type Located = { readonly sentence: Sentence; readonly offset: number; readonly kind: SpacingKind; readonly spaced: boolean };
25
+
26
+ /**
27
+ * 日本語と英字・数字のあいだを、空けるか詰めるか。文書の中で混ざっていたら、少ないほうを指摘する。
28
+ * どちらが正しいかは決めない。決めるのはチームで、chaff はそろっているかだけを見る。
29
+ */
30
+ export const latinSpacing: Detector = (doc, options): Finding[] => {
31
+ const located: Located[] = doc.sentences.flatMap((sentence) => latinBoundaries(sentence.text).map((boundary) => ({ sentence, ...boundary })));
32
+ return KINDS.flatMap((kind) => {
33
+ const ofKind = located.filter((entry) => entry.kind === kind);
34
+ const minority = minorityStyle(ofKind);
35
+ if (minority === undefined) return [];
36
+ const odd = ofKind.filter((entry) => entry.spaced === minority);
37
+ if (odd.length < options.limit) return [];
38
+ return odd.map((entry) => ({
39
+ rule: "",
40
+ severity: "warning",
41
+ line: 0,
42
+ column: 0,
43
+ quote: entry.sentence.text.trim(),
44
+ values: {
45
+ kind: KIND_NAME[kind],
46
+ style: minority ? "空けています" : "詰めています",
47
+ usual: minority ? "詰める" : "空ける",
48
+ count: odd.length,
49
+ of: ofKind.length,
50
+ limit: options.limit,
51
+ offset: entry.sentence.span.start + entry.offset,
52
+ },
53
+ }));
54
+ });
55
+ };
@@ -0,0 +1,32 @@
1
+ import type { Detector, Finding, ProseDocument } from "../plugin.ts";
2
+ import { danglingReferences, duplicateDefinitions, type StructureIssue } from "../structure/issues.ts";
3
+ import { numberingBreaks } from "../structure/numbering.ts";
4
+
5
+ const QUOTE_LENGTH = 80;
6
+
7
+ /** 指摘の位置から行末までを引用する。条の見出しや参照を含む一行が、読み手の探す手がかりになる。 */
8
+ const quoteAt = (source: string, offset: number): string => {
9
+ const end = source.indexOf("\n", offset);
10
+ return source
11
+ .slice(offset, end === -1 ? source.length : end)
12
+ .slice(0, QUOTE_LENGTH)
13
+ .trim();
14
+ };
15
+
16
+ const findingsOf =
17
+ (rule: string, issuesOf: (tree: NonNullable<ProseDocument["structure"]>) => StructureIssue[]): Detector =>
18
+ (doc): Finding[] =>
19
+ doc.structure === undefined
20
+ ? []
21
+ : issuesOf(doc.structure).map((issue) => ({
22
+ rule,
23
+ severity: "error",
24
+ line: 0,
25
+ column: 0,
26
+ quote: quoteAt(doc.source, issue.offset),
27
+ values: { ...issue.values, offset: issue.offset },
28
+ }));
29
+
30
+ export const danglingReference: Detector = findingsOf("dangling-reference", danglingReferences);
31
+ export const numberingGap: Detector = findingsOf("numbering-gap", numberingBreaks);
32
+ export const duplicateDefinition: Detector = findingsOf("duplicate-definition", duplicateDefinitions);
package/src/document.ts CHANGED
@@ -3,8 +3,11 @@ import { gfmTable } from "micromark-extension-gfm-table";
3
3
  import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
4
4
  import { frontmatter } from "micromark-extension-frontmatter";
5
5
  import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
6
+ import { atxHeadingText, headingText } from "./heading-text.ts";
6
7
  import { maskSpans } from "./mask.ts";
7
- import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span } from "./plugin.ts";
8
+ import { buildTree, NO_OUTLINE, type Outline } from "./structure/build.ts";
9
+ import { isMarkdownPath } from "./structure/markdown-path.ts";
10
+ import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode } from "./plugin.ts";
8
11
 
9
12
  type Place = { readonly offset?: number | undefined };
10
13
  type Node = {
@@ -120,7 +123,7 @@ const textOf = (node: Node, source: string): string => {
120
123
  return parts.join("");
121
124
  };
122
125
 
123
- type Heading = { readonly depth: number; readonly text: string; readonly start: number; readonly end: number };
126
+ export type Heading = { readonly depth: number; readonly text: string; readonly start: number; readonly end: number };
124
127
 
125
128
  const headingsOf = (root: Node, source: string): Heading[] => {
126
129
  const found: Heading[] = [];
@@ -128,7 +131,10 @@ const headingsOf = (root: Node, source: string): Heading[] => {
128
131
  if (node.type !== "heading") return;
129
132
  const span = spanOf(node);
130
133
  const depth = typeof node === "object" && "depth" in node && typeof node.depth === "number" ? node.depth : 1;
131
- if (span !== undefined) found.push({ depth, text: textOf(node, source).trim(), start: span.start, end: span.end });
134
+ if (span === undefined) return;
135
+ // ATX(行頭が #)なら閉じの # も外す。setext の見出しの末尾の # は言葉なので残す。
136
+ const read = source.startsWith("#", span.start) ? atxHeadingText : headingText;
137
+ found.push({ depth, text: read(textOf(node, source)), start: span.start, end: span.end });
132
138
  });
133
139
  return found;
134
140
  };
@@ -227,13 +233,23 @@ const listsOf = (root: Node, source: string): BulletList[] => {
227
233
  };
228
234
 
229
235
  /** チームが chaff.yaml に書いたもの。語彙表と同じ器に入れて、detector には出所を見せない。 */
230
- export type TeamRules = { readonly jargon: readonly string[]; readonly requiredSections: readonly string[] };
236
+ export type TeamRules = {
237
+ readonly jargon: readonly string[];
238
+ readonly requiredSections: readonly string[];
239
+ /** { 使わない書き方: 使う書き方 }。preferred-term が語彙表として読む。 */
240
+ readonly prefer?: Readonly<Record<string, string>>;
241
+ };
231
242
 
232
243
  const EMPTY_TEAM: TeamRules = { jargon: [], requiredSections: [] };
233
244
 
234
245
  /** Config から取り出す。document は Config の形を知らない。 */
235
- export const teamRules = (config: { readonly jargon: readonly string[]; readonly requiredSections: readonly string[] }): TeamRules => ({
246
+ export const teamRules = (config: {
247
+ readonly jargon: readonly string[];
248
+ readonly requiredSections: readonly string[];
249
+ readonly prefer?: Readonly<Record<string, string>>;
250
+ }): TeamRules => ({
236
251
  jargon: config.jargon,
252
+ prefer: config.prefer ?? {},
237
253
  requiredSections: config.requiredSections,
238
254
  });
239
255
 
@@ -247,6 +263,9 @@ export const buildDocument = (path: string, source: string, adapter: LanguageAda
247
263
  const paragraphSpans = spansOfType(root, "paragraph");
248
264
  const listItems = spansOfType(root, "listItem");
249
265
  const sentences = sentencesOf(prose, paragraphSpans, adapter);
266
+ const patterns = adapter.structure;
267
+ // null は「作ったが構造を読めない言語だった」、undefined は「まだ作っていない」。
268
+ const tree: { value: StructureNode | null | undefined } = { value: undefined };
250
269
  return {
251
270
  path,
252
271
  source,
@@ -258,7 +277,31 @@ export const buildDocument = (path: string, source: string, adapter: LanguageAda
258
277
  listSpans: listItems,
259
278
  paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
260
279
  lists: listsOf(root, source),
261
- lexicons: { ...adapter.lexicons, "internal-jargon": team.jargon.map((pattern) => ({ pattern })) },
280
+ lexicons: {
281
+ ...adapter.lexicons,
282
+ "internal-jargon": team.jargon.map((pattern) => ({ pattern })),
283
+ // 使わない書き方を pattern に、使う書き方を instead_of に置く。語彙表の「同じことの別の書き方」と同じ向き。
284
+ "preferred-term": Object.entries(team.prefer ?? {}).map(([pattern, use]) => ({ pattern, instead_of: use })),
285
+ },
262
286
  requiredSections: team.requiredSections,
287
+ // 構造の rule(参照先が無い・番号の抜け)が読む木。どの rule も読まなければ作らない。何万行の契約書で、他の rule の lint に代金を払わせない。
288
+ get structure(): StructureNode | undefined {
289
+ tree.value ??=
290
+ patterns === undefined
291
+ ? null
292
+ : buildTree({ path, source, language: adapter.id, outline: isMarkdownPath(path) ? outlineOf(root, source) : NO_OUTLINE }, patterns);
293
+ return tree.value ?? undefined;
294
+ },
263
295
  };
264
296
  };
297
+
298
+ /** 番号を探してはいけない範囲。コードの中の「第3条」は条ではなく、参照でもない。 */
299
+ const OPAQUE = ["code", "inlineCode", "html", "yaml", "toml"];
300
+
301
+ const outlineOf = (root: Node, source: string): Outline => ({
302
+ headings: headingsOf(root, source),
303
+ opaque: OPAQUE.flatMap((type) => spansOfType(root, type)),
304
+ });
305
+
306
+ /** 構造を読むための Markdown の手がかり。見出しと、中を読まない範囲。 */
307
+ export const markdownOutline = (source: string): Outline => outlineOf(parse(source), source);
package/src/eval.ts CHANGED
@@ -35,11 +35,12 @@ const stricterIsHigher = (rule: RuleDefinition): boolean => (rule.levels.strict
35
35
  const MULTIPLIERS = [0.5, 0.7, 0.85, 1, 1.25, 1.5, 2, 3];
36
36
 
37
37
  /** rule 自身が持つ段を基点に掃引する。rule ごとに単位が違うので、倍率で散らす。 */
38
- const candidates = (rule: RuleDefinition): number[] => {
38
+ const candidates = (rule: RuleDefinition, current: number): number[] => {
39
39
  const base = rule.levels.normal ?? 1;
40
40
  const fromLevels = [rule.levels.strict, rule.levels.normal, rule.levels.relaxed].filter((value) => value !== undefined);
41
41
  const scaled = MULTIPLIERS.map((factor) => Math.max(1, Math.round(base * factor)));
42
- return [...new Set([...fromLevels, ...scaled])].sort((left, right) => left - right);
42
+ // 数値で決めた上限は、段の表に無くても掃引に入れる。入れないと「現在」の行が出ない。
43
+ return [...new Set([...fromLevels, ...scaled, current])].sort((left, right) => left - right);
43
44
  };
44
45
 
45
46
  const countAt = (docs: readonly ProseDocument[], rule: RuleDefinition, limit: number): Point => {
@@ -63,6 +64,16 @@ const recommend = (rule: RuleDefinition, sweep: readonly Point[], total: number)
63
64
  return strictest;
64
65
  };
65
66
 
67
+ /** lint と同じ要求の読み方。知らない要求は満たさないものとする。pos に読み替えると、動かない rule を測ってしまう。 */
68
+ const meets = (need: string, docs: readonly ProseDocument[]): boolean => {
69
+ const capabilities = docs[0]?.capabilities;
70
+ if (need === "pos") return capabilities?.pos === true;
71
+ if (need === "lemma") return capabilities?.lemma === true;
72
+ // 木は adapter が structure を持つときだけ作れる。無いまま測ると、どの閾値でも 0 件になる。
73
+ if (need === "structure") return docs.length > 0 && docs.every((doc) => doc.structure !== undefined);
74
+ return false;
75
+ };
76
+
66
77
  /**
67
78
  * lint で動かない rule は掃引しない。
68
79
  * 動かないものの閾値を測ると、「どの閾値でも 0 件」が「よく校正されている」に見える。
@@ -71,16 +82,25 @@ const measurable = (rule: RuleDefinition, docs: readonly ProseDocument[], genre:
71
82
  if (rule.layer === "L4" || !rule.use_for.some((target) => genre.startsWith(target))) return false;
72
83
  if (rule.languages !== undefined && !rule.languages.includes(language)) return false;
73
84
  if (rule.from.length > 0) return false;
74
- const capabilities = docs[0]?.capabilities;
75
- return !rule.requires.some((need) => (need === "lemma" ? capabilities?.lemma : capabilities?.pos) !== true);
85
+ return rule.requires.every((need) => meets(need, docs));
76
86
  };
77
87
 
78
- export const evaluate = (docs: readonly ProseDocument[], rules: readonly RuleDefinition[], genre: string, language: string): RuleReport[] =>
88
+ /**
89
+ * limits は chaff.yaml に数値で書いた上限。書いてあれば、それが lint で効いている「現在」の値。
90
+ * 段の言葉(relaxed など)で書いた設定は、これまでどおり normal の値を「現在」とする。
91
+ */
92
+ export const evaluate = (
93
+ docs: readonly ProseDocument[],
94
+ rules: readonly RuleDefinition[],
95
+ genre: string,
96
+ language: string,
97
+ limits: Readonly<Record<string, number>> = {},
98
+ ): RuleReport[] =>
79
99
  rules
80
100
  .filter((rule) => measurable(rule, docs, genre, language))
81
101
  .map((rule) => {
82
- const sweep = candidates(rule).map((limit) => countAt(docs, rule, limit));
83
- const current = rule.levels.normal ?? 1;
102
+ const current = limits[rule.id] ?? rule.levels.normal ?? 1;
103
+ const sweep = candidates(rule, current).map((limit) => countAt(docs, rule, limit));
84
104
  return {
85
105
  rule: rule.id,
86
106
  name: rule.name[language] ?? rule.id,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 見出しの末尾に付けた属性を外した、見出しの言葉。`## ステップ 1 {#step1}` は「ステップ 1」。
3
+ * kramdown(`{#id}`、`{: .class}`)と pandoc(`{#id .class}`)の書き方。属性は読者に見えないので、
4
+ * 残すと見出しの言い直しや番号の読み取りが、見えない文字に引きずられる。
5
+ * 波括弧で終わっていても、中身が `#` `.` `:` で始まらないもの(`## {name} の設定`)は言葉のうち。
6
+ */
7
+ export const headingText = (raw: string): string => {
8
+ const trimmed = raw.trim();
9
+ if (!trimmed.endsWith("}")) return trimmed;
10
+ const open = trimmed.lastIndexOf("{");
11
+ if (open === -1) return trimmed;
12
+ const inner = trimmed.slice(open + 1, -1).trimStart();
13
+ const isAttributes = inner.startsWith("#") || inner.startsWith(".") || inner.startsWith(":");
14
+ return isAttributes ? trimmed.slice(0, open).trim() : trimmed;
15
+ };
16
+
17
+ /**
18
+ * ATX 見出し(`## 見出し ##`)の言葉。属性を外してから、閉じの `#` を外す。
19
+ * `## Install ## {#install}` は閉じの `#` の後ろに属性があるので、この順でないと `##` が言葉に残る。
20
+ * 下線で書く見出し(setext)には使わない。そちらの末尾の `##` は本当に言葉の一部。
21
+ */
22
+ export const atxHeadingText = (content: string): string => {
23
+ const withoutAttributes = headingText(content);
24
+ const closing = withoutAttributes.search(/(?:^|[ \t])#+$/u);
25
+ return (closing === -1 ? withoutAttributes : withoutAttributes.slice(0, closing)).trim();
26
+ };
@@ -0,0 +1,115 @@
1
+ // 表記のそろい。書き方の決まりのうち、文字だけで決まるもの。
2
+
3
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
4
+
5
+ /** text の中で word が現れる [始まり, 終わり) を、左から順に。大文字小文字を区別しないときは i で探す(位置は元の文字列のまま)。 */
6
+ const spansOf = (text: string, word: string, ignoreCase: boolean): [number, number][] => {
7
+ const pattern = new RegExp(escapeRegExp(word), ignoreCase ? "giu" : "gu");
8
+ const found: [number, number][] = [];
9
+ for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
10
+ found.push([match.index, match.index + match[0].length]);
11
+ // 重なって現れるものも拾う。次は 1 つ先から探す。
12
+ pattern.lastIndex = match.index + 1;
13
+ }
14
+ return found;
15
+ };
16
+
17
+ /**
18
+ * avoid が現れる位置のうち、use の一部として現れたものを除いたもの。
19
+ * 「ユーザ」を避けて「ユーザー」と書く決まりのとき、「ユーザー」の中の「ユーザ」は指摘しない。
20
+ * 大文字小文字は区別しない(文頭の「E-mail」も「e-mail」)。ただし二つが大文字小文字だけ違う組
21
+ * (「Javascript」→「JavaScript」)は、それ自体が大文字小文字の決まりなので区別して探す。
22
+ */
23
+ export const occurrencesOutside = (text: string, avoid: string, use: string): number[] => {
24
+ if (avoid === "") return [];
25
+ const ignoreCase = avoid.toLowerCase() !== use.toLowerCase();
26
+ const covered = use === "" ? [] : spansOf(text, use, ignoreCase);
27
+ const found: number[] = [];
28
+ // covered は始まりの順に並ぶ。始まりが at 以前のものの終わりの最大が end 以上なら、どれかに収まっている。
29
+ let next = 0;
30
+ let reach = -1;
31
+ let at = 0;
32
+ for (const [start, end] of spansOf(text, avoid, ignoreCase)) {
33
+ if (start < at) continue;
34
+ while (next < covered.length && (covered[next]?.[0] ?? Infinity) <= start) {
35
+ reach = Math.max(reach, covered[next]?.[1] ?? -1);
36
+ next += 1;
37
+ }
38
+ if (reach < end) found.push(start);
39
+ at = end;
40
+ }
41
+ return found;
42
+ };
43
+
44
+ export type SpacingKind = "letter" | "before-digit" | "after-digit";
45
+ export type Boundary = { readonly offset: number; readonly kind: SpacingKind; readonly spaced: boolean };
46
+
47
+ const JAPANESE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]/u;
48
+ const LETTER = /[A-Za-z]/u;
49
+ const DIGIT = /\d/u;
50
+
51
+ /** Latin 側の字の種類。digitSide は、その数字が日本語の右(前に日本語)か左(後ろに日本語)か。 */
52
+ const kindOf = (char: string | undefined, digitSide: "before-digit" | "after-digit"): SpacingKind | undefined => {
53
+ if (char === undefined) return undefined;
54
+ if (LETTER.test(char)) return "letter";
55
+ return DIGIT.test(char) ? digitSide : undefined;
56
+ };
57
+
58
+ const isJapanese = (char: string | undefined): boolean => char !== undefined && JAPANESE.test(char);
59
+
60
+ /** japanese が日本語の字のとき、other の英字・数字の種類。 */
61
+ const latinBeside = (japanese: string | undefined, other: string | undefined, digitSide: "before-digit" | "after-digit"): SpacingKind | undefined =>
62
+ isJapanese(japanese) ? kindOf(other, digitSide) : undefined;
63
+
64
+ /** 日本語の字の隣にある英字・数字との境目。間が半角空白 1 つなら「空けている」、何も無ければ「詰めている」。 */
65
+ const ALPHANUMERIC = /[A-Za-z0-9.]/u;
66
+
67
+ /**
68
+ * 日本語の左にある英数字の並びの、先頭の字。「3GBの」の「GB」は数量の単位で、並びは数字で始まる。
69
+ * そうした並びと日本語の境目は、英字の空け方ではなく数字の後ろの空け方として数える(「3回」と同じ)。
70
+ */
71
+ const runStart = (chars: readonly string[], last: number): string | undefined => {
72
+ let first = last;
73
+ while (first > 0 && ALPHANUMERIC.test(chars[first - 1] ?? "")) first -= 1;
74
+ return chars[first];
75
+ };
76
+
77
+ /** 左の並びの種類。数字で始まる並び(3GB、10ms)は数字として扱う。 */
78
+ const leftRunBeside = (chars: readonly string[], last: number, japanese: string | undefined): SpacingKind | undefined => {
79
+ const kind = latinBeside(japanese, chars[last], "after-digit");
80
+ return kind === "letter" && DIGIT.test(runStart(chars, last) ?? "") ? "after-digit" : kind;
81
+ };
82
+
83
+ const boundaryAt = (chars: readonly string[], index: number): Omit<Boundary, "offset"> | undefined => {
84
+ const [left, right, after] = [chars[index], chars[index + 1], chars[index + 2]];
85
+ const touching = latinBeside(left, right, "before-digit") ?? leftRunBeside(chars, index, right);
86
+ if (touching !== undefined) return { kind: touching, spaced: false };
87
+ if (right !== " ") return undefined;
88
+ const spaced = latinBeside(left, after, "before-digit") ?? leftRunBeside(chars, index, after);
89
+ return spaced === undefined ? undefined : { kind: spaced, spaced: true };
90
+ };
91
+
92
+ /** 文の中の境目を全部。offset は境目の位置(左の字の直後、UTF-16)。配列を作り直さずに一度なめる。 */
93
+ export const latinBoundaries = (text: string): Boundary[] => {
94
+ const chars = [...text];
95
+ const found: Boundary[] = [];
96
+ let offset = 0;
97
+ chars.forEach((char, index) => {
98
+ offset += char.length;
99
+ const boundary = boundaryAt(chars, index);
100
+ if (boundary !== undefined) found.push({ ...boundary, offset });
101
+ });
102
+ return found;
103
+ };
104
+
105
+ /**
106
+ * 混ざっているとき、そろえるべき少数派。種類(英字・数字の前・数字の後ろ)ごとに数える。
107
+ * 同数なら、文書が先に使った書き方をその文書の書き方とみなし、後から出た書き方を少数派にする。
108
+ */
109
+ export const minorityStyle = (boundaries: readonly Boundary[]): boolean | undefined => {
110
+ const spaced = boundaries.filter((boundary) => boundary.spaced).length;
111
+ const touching = boundaries.length - spaced;
112
+ if (spaced === 0 || touching === 0) return undefined;
113
+ if (spaced !== touching) return spaced < touching;
114
+ return !(boundaries[0]?.spaced ?? false);
115
+ };
package/src/plugin.ts CHANGED
@@ -71,6 +71,79 @@ export type LanguageAdapter = {
71
71
  readonly segment: (text: string) => Segmentation;
72
72
  /** L2 rule が word_list で引く。アダプタが自分の言語のぶんだけを持つ。 */
73
73
  readonly lexicons: Readonly<Record<string, Lexicon>>;
74
+ /** 文書の構造を読む型。無い言語では `chaff tree` がそう言って止まる。 */
75
+ readonly structure?: StructurePatterns;
76
+ };
77
+
78
+ // ───────── 文書の構造 ─────────
79
+
80
+ /**
81
+ * 行頭の番号 1 つ。「第3条」「2」「(a)」を読むのは言語パッケージで、
82
+ * 入れ子と番地を決めるのは core。core は番号の書き方を知らない。
83
+ */
84
+ export type NumberedLine = {
85
+ readonly kind: "chapter" | "article" | "item";
86
+ /** 入れ子の深さ。条が 1、項が 2、号が 3 のように、言語パッケージが決める。条より外の編・章・節は 0 以下。 */
87
+ readonly depth: number;
88
+ /** 番地の部品。"3"、"4.2"、"a"。 */
89
+ readonly number: string;
90
+ /** true なら number だけで番地が決まる(条番号、4.2 のような通し番号)。false なら親の番地に続ける。 */
91
+ readonly absolute: boolean;
92
+ /** 書かれたままの番号。「第3条」「Section 4.2」。 */
93
+ readonly label: string;
94
+ /** 番号に続く見出し。「第3条(支払)」の「支払」。本文しか無ければ空。 */
95
+ readonly heading: string;
96
+ /** 番号の後ろの文字列全部。定義や参照はここから探す。 */
97
+ readonly rest: string;
98
+ /**
99
+ * 並びの中の位置。第三条は 3、(ii) は 2、(b) は 2、4.2 は 2。番号の抜けを見る rule が比べる。
100
+ * 枝番号(第3条の2)は並びの外なので付けない。読み方を知っているのは言語パッケージだけなので、core は計算しない。
101
+ */
102
+ readonly ordinal?: number | undefined;
103
+ };
104
+
105
+ /** 番号を読むときに見える周り。「(i)」がローマ数字か英字かは、開いている番号で決まる。 */
106
+ export type NumberingContext = {
107
+ readonly open: readonly NumberedLine[];
108
+ /** Markdown の見出しの行か。本文の「1. 」は箇条書きで、見出しの「1. 」は章番号。 */
109
+ readonly isHeading: boolean;
110
+ };
111
+
112
+ /** 行の中で見つけたもの。start / end は渡した文字列の中の位置。 */
113
+ export type Mention = { readonly start: number; readonly end: number; readonly attrs: Readonly<Record<string, string | number>> };
114
+
115
+ export type StructurePatterns = {
116
+ readonly numbered: (line: string, context: NumberingContext) => NumberedLine | undefined;
117
+ /** 定義。attrs.term に定義された語。 */
118
+ readonly definitions: (text: string) => readonly Mention[];
119
+ /** 参照。attrs.target に正規化した番地("12.1")、attrs.label に書かれたまま。 */
120
+ readonly references: (text: string) => readonly Mention[];
121
+ /** 義務・禁止・許可。attrs.marker に語、attrs.type に must / must-not / may。 */
122
+ readonly obligations: (text: string) => readonly Mention[];
123
+ /** 数量。attrs.value に数、attrs.unit に単位。 */
124
+ readonly quantities: (text: string) => readonly Mention[];
125
+ /** 日付。attrs.value に "2024-04-01"・"2024-04"・"04-01"・"2024" のどれか。無い言語は日付を読まない。 */
126
+ readonly dates?: (text: string) => readonly Mention[];
127
+ /**
128
+ * 「1.5 倍」「2.5 days」の 1.5 は通し番号ではない。番号と後ろの文字列を渡し、後ろが単位なら true。
129
+ * 数字と点だけの通し番号は core が言語を問わず読むので、それを数量と見分けられるのは言語パッケージだけ。
130
+ */
131
+ readonly countedAfter?: (number: string, rest: string) => boolean;
132
+ };
133
+
134
+ export type StructureKind = "doc" | "section" | "chapter" | "article" | "item" | "definition" | "reference" | "obligation" | "quantity" | "date";
135
+
136
+ /** 番地の付いた木の節点。すべて元の文書の位置(UTF-16)と行を持つ。 */
137
+ export type StructureNode = {
138
+ readonly kind: StructureKind;
139
+ /** 節・条・項の番地。"3.2"、見出しは "h2.1"。定義や参照のような葉は空。 */
140
+ readonly address: string;
141
+ readonly span: Span;
142
+ readonly line: number;
143
+ readonly attrs: Readonly<Record<string, string | number>>;
144
+ readonly children: readonly StructureNode[];
145
+ /** 番号付きのまとまりの、並びの中の位置(NumberedLine.ordinal)。S 式には出さない。 */
146
+ readonly ordinal?: number;
74
147
  };
75
148
 
76
149
  // ───────── 文書モデルと rule ─────────
@@ -115,6 +188,8 @@ export type ProseDocument = {
115
188
  readonly lexicons: Readonly<Record<string, Lexicon>>;
116
189
  /** この種類の文書に無いと困る見出し。チームが chaff.yaml で決める。 */
117
190
  readonly requiredSections: readonly string[];
191
+ /** 番地の付いた木(§27)。adapter が structure を持たない言語では無い。 */
192
+ readonly structure: StructureNode | undefined;
118
193
  };
119
194
 
120
195
  export type Severity = "error" | "warning" | "info";
package/src/position.ts CHANGED
@@ -12,7 +12,27 @@ export const lineStarts = (source: string): number[] => {
12
12
  return starts;
13
13
  };
14
14
 
15
+ /**
16
+ * offset を含む行。starts は昇順なので二分探索で引く。指摘が何千もある長い文書で、指摘ごとに全行をなめない。
17
+ * offset より前に行頭が無ければ(負の値、NaN)先頭の行とする。
18
+ */
19
+ const lineIndexOf = (starts: readonly number[], offset: number): number => {
20
+ let low = 0;
21
+ let high = starts.length - 1;
22
+ let found = 0;
23
+ while (low <= high) {
24
+ const middle = (low + high) >> 1;
25
+ if ((starts[middle] ?? Number.POSITIVE_INFINITY) <= offset) {
26
+ found = middle;
27
+ low = middle + 1;
28
+ } else {
29
+ high = middle - 1;
30
+ }
31
+ }
32
+ return found;
33
+ };
34
+
15
35
  export const placeOf = (starts: readonly number[], offset: number): Place => {
16
- const index = starts.reduce((best, start, at) => (start <= offset ? at : best), 0);
36
+ const index = lineIndexOf(starts, offset);
17
37
  return { line: index + 1, column: offset - (starts[index] ?? 0) + 1 };
18
38
  };
@@ -5,6 +5,8 @@ import type { RuleDefinition } from "../plugin.ts";
5
5
  const now = (rule: RuleDefinition, config: Config, genre: string): Record<string, unknown> => {
6
6
  const explicit = config.rules[rule.id];
7
7
  if (explicit === "off") return { level: "off", why_off: "設定で止めている" };
8
+ const limit = rule.layer === "L4" ? undefined : config.limits[rule.id];
9
+ if (limit !== undefined) return { level: "normal", limit, set_as: "number" };
8
10
  if (explicit !== undefined) return { level: explicit, limit: resolve(rule, explicit, genre).limit };
9
11
  if (rule.status === "experimental" && !config.experimental) {
10
12
  return { level: "off", why_off: "experimental な rule は既定で動かさない", turn_on_with: `npx chaff lint --experimental` };
@@ -12,6 +14,13 @@ const now = (rule: RuleDefinition, config: Config, genre: string): Record<string
12
14
  return { level: "normal", limit: resolve(rule, "normal", genre).limit };
13
15
  };
14
16
 
17
+ const yourSetting = (rule: RuleDefinition, config: Config): Record<string, unknown> | null => {
18
+ const level = config.rules[rule.id];
19
+ if (level === undefined) return null;
20
+ const limit = rule.layer === "L4" ? undefined : config.limits[rule.id];
21
+ return limit === undefined ? { level, from: config.path } : { level, limit, from: config.path };
22
+ };
23
+
15
24
  /** ジャンルで数字が変わる rule があるので、いま効いている表と既定の表の両方を出す。 */
16
25
  const levelsOf = (rule: RuleDefinition, genre: string): Record<string, unknown> => {
17
26
  const effective = Object.fromEntries(
@@ -42,7 +51,7 @@ export const rulesJson = (rules: readonly RuleDefinition[], config: Config, lang
42
51
  use_for: rule.use_for,
43
52
  ...levelsOf(rule, genre),
44
53
  levels_you_can_set: definedLevels(rule),
45
- your_setting: config.rules[rule.id] === undefined ? null : { level: config.rules[rule.id], from: config.path },
54
+ your_setting: yourSetting(rule, config),
46
55
  now: now(rule, config, genre),
47
56
  })),
48
57
  how_to_change: {
package/src/run.ts CHANGED
@@ -43,9 +43,9 @@ const hasTokens = (doc: ProseDocument): boolean => doc.sentences.length === 0 ||
43
43
  /** 要求を満たさない rule は動かせない。満たさないまま動かすと「指摘 0 件」が保証に見える。 */
44
44
  const unmet = (rule: RuleDefinition, doc: ProseDocument): string | undefined => {
45
45
  if (rule.languages !== undefined && !rule.languages.includes(doc.language)) return `${doc.language} 向けの rule ではないため`;
46
- const missing = rule.requires.find((need) => !has(doc.capabilities, need));
46
+ const missing = rule.requires.filter((need) => need !== "structure").find((need) => !has(doc.capabilities, need));
47
47
  if (missing !== undefined) return `この言語では${CAPABILITY_NAME[missing] ?? missing}が使えないため`;
48
- if (rule.requires.length > 0 && !hasTokens(doc)) return "アダプタが品詞を返さなかったため";
48
+ if (rule.requires.some((need) => need === "pos" || need === "lemma") && !hasTokens(doc)) return "アダプタが品詞を返さなかったため";
49
49
  return undefined;
50
50
  };
51
51
 
@@ -92,7 +92,19 @@ const compositeOf = (rule: RuleDefinition, found: readonly Finding[], limit: num
92
92
  ];
93
93
  };
94
94
 
95
- export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], settings: Settings, experimental: boolean, genre: string): RunResult => {
95
+ /** chaff.yaml の rules に数値で書いた上限。段階の表より先に効く。 */
96
+ export type Limits = Readonly<Record<string, number>>;
97
+
98
+ const limitFor = (rule: RuleDefinition, level: Level, genre: string, limits: Limits): number => limits[rule.id] ?? resolve(rule, level, genre).limit;
99
+
100
+ export const runRules = (
101
+ doc: ProseDocument,
102
+ rules: readonly RuleDefinition[],
103
+ settings: Settings,
104
+ experimental: boolean,
105
+ genre: string,
106
+ limits: Limits = {},
107
+ ): RunResult => {
96
108
  const starts = lineStarts(doc.source);
97
109
  const applicable = forGenre(rules, genre);
98
110
  const forced = applicable
@@ -111,12 +123,17 @@ export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], s
111
123
  const why = rule.status === "experimental" && settings[rule.id] === undefined ? "まだ試験中のため" : "設定で止めているため";
112
124
  return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why }] };
113
125
  }
126
+ // 木は capability ではなく、adapter が structure を持つかで決まる。持たない言語で動かすと「参照先が無い」が 0 件に見える。
127
+ // 段階を見た後で聞く。doc.structure は触れたときに木を作るので、止めている rule のために作らない。
128
+ if (rule.requires.includes("structure") && doc.structure === undefined) {
129
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: `${doc.language} のパッケージは文書の構造を読めないため` }] };
130
+ }
114
131
  // 複合シグナルは二段目で扱う。一段目では「検出器が無い」と言わせない。
115
132
  if (rule.from.length > 0) return acc;
116
133
  const detector = DETECTORS[rule.how_to_find];
117
134
  if (detector === undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: `検出器 ${rule.how_to_find} がないため` }] };
118
135
  const options = {
119
- limit: resolve(rule, level, genre).limit,
136
+ limit: limitFor(rule, level, genre, limits),
120
137
  lexicon: rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list],
121
138
  where: rule.where,
122
139
  };
@@ -131,7 +148,7 @@ export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], s
131
148
  );
132
149
  const composites = applicable
133
150
  .filter((rule) => rule.from.length > 0 && levelFor(rule, settings, experimental) !== "off")
134
- .flatMap((rule) => compositeOf(rule, outcome.findings, resolve(rule, levelFor(rule, settings, experimental), genre).limit, starts));
151
+ .flatMap((rule) => compositeOf(rule, outcome.findings, limitFor(rule, levelFor(rule, settings, experimental), genre, limits), starts));
135
152
  const all = [...outcome.findings, ...composites];
136
153
  return { ...outcome, findings: [...all].sort((left, right) => left.line - right.line), forcedExperimental: forced };
137
154
  };