chaffjs 0.11.0 → 0.13.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.
- package/dist/date-stamp.d.ts +14 -0
- package/dist/date-stamp.d.ts.map +1 -0
- package/dist/date-stamp.js +40 -0
- package/dist/date-stamp.js.map +1 -0
- package/dist/declared-lists.d.ts +4 -0
- package/dist/declared-lists.d.ts.map +1 -0
- package/dist/declared-lists.js +3 -0
- package/dist/declared-lists.js.map +1 -0
- package/dist/detectors/acronym-context.d.ts +25 -0
- package/dist/detectors/acronym-context.d.ts.map +1 -0
- package/dist/detectors/acronym-context.js +28 -0
- package/dist/detectors/acronym-context.js.map +1 -0
- package/dist/detectors/acronym-expansion.d.ts +9 -0
- package/dist/detectors/acronym-expansion.d.ts.map +1 -0
- package/dist/detectors/acronym-expansion.js +94 -0
- package/dist/detectors/acronym-expansion.js.map +1 -0
- package/dist/detectors/char-shape.d.ts.map +1 -1
- package/dist/detectors/char-shape.js +20 -34
- package/dist/detectors/char-shape.js.map +1 -1
- package/dist/detectors/doubled-word.d.ts +27 -0
- package/dist/detectors/doubled-word.d.ts.map +1 -0
- package/dist/detectors/doubled-word.js +94 -0
- package/dist/detectors/doubled-word.js.map +1 -0
- package/dist/detectors/heading-echo.d.ts.map +1 -1
- package/dist/detectors/heading-echo.js +22 -12
- package/dist/detectors/heading-echo.js.map +1 -1
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +2 -0
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/lead-in.d.ts +8 -0
- package/dist/detectors/lead-in.d.ts.map +1 -0
- package/dist/detectors/lead-in.js +13 -0
- package/dist/detectors/lead-in.js.map +1 -0
- package/dist/detectors/lexicon-match.d.ts +6 -0
- package/dist/detectors/lexicon-match.d.ts.map +1 -1
- package/dist/detectors/lexicon-match.js +8 -0
- package/dist/detectors/lexicon-match.js.map +1 -1
- package/dist/detectors/lexicon.d.ts +5 -0
- package/dist/detectors/lexicon.d.ts.map +1 -1
- package/dist/detectors/lexicon.js +21 -4
- package/dist/detectors/lexicon.js.map +1 -1
- package/dist/detectors/orthography.d.ts.map +1 -1
- package/dist/detectors/orthography.js +17 -1
- package/dist/detectors/orthography.js.map +1 -1
- package/dist/detectors/place-run.d.ts +8 -0
- package/dist/detectors/place-run.d.ts.map +1 -0
- package/dist/detectors/place-run.js +54 -0
- package/dist/detectors/place-run.js.map +1 -0
- package/dist/detectors/register.d.ts +32 -0
- package/dist/detectors/register.d.ts.map +1 -0
- package/dist/detectors/register.js +62 -0
- package/dist/detectors/register.js.map +1 -0
- package/dist/detectors/sentence-ending.d.ts.map +1 -1
- package/dist/detectors/sentence-ending.js +17 -28
- package/dist/detectors/sentence-ending.js.map +1 -1
- package/dist/detectors/signals.d.ts.map +1 -1
- package/dist/detectors/signals.js +20 -115
- package/dist/detectors/signals.js.map +1 -1
- package/dist/detectors/structure.d.ts.map +1 -1
- package/dist/detectors/structure.js +14 -1
- package/dist/detectors/structure.js.map +1 -1
- package/dist/detectors/superlative-scope.d.ts +16 -0
- package/dist/detectors/superlative-scope.d.ts.map +1 -0
- package/dist/detectors/superlative-scope.js +40 -0
- package/dist/detectors/superlative-scope.js.map +1 -0
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +17 -11
- package/dist/document.js.map +1 -1
- package/dist/eval.d.ts.map +1 -1
- package/dist/eval.js +3 -2
- package/dist/eval.js.map +1 -1
- package/dist/heading-title.d.ts +6 -0
- package/dist/heading-title.d.ts.map +1 -0
- package/dist/heading-title.js +8 -0
- package/dist/heading-title.js.map +1 -0
- package/dist/in-page-nav.d.ts +19 -0
- package/dist/in-page-nav.d.ts.map +1 -0
- package/dist/in-page-nav.js +44 -0
- package/dist/in-page-nav.js.map +1 -0
- package/dist/number-name.d.ts +9 -0
- package/dist/number-name.d.ts.map +1 -0
- package/dist/number-name.js +57 -0
- package/dist/number-name.js.map +1 -0
- package/dist/orthography.d.ts +7 -2
- package/dist/orthography.d.ts.map +1 -1
- package/dist/orthography.js +79 -17
- package/dist/orthography.js.map +1 -1
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +1 -0
- package/dist/rule-load.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +5 -4
- package/dist/run.js.map +1 -1
- package/package.json +3 -3
- package/rules/doubled-word.yaml +27 -0
- package/rules/latin-spacing.yaml +4 -2
- package/rules/max-kanji-continuous.yaml +2 -0
- package/rules/undefined-acronym.yaml +2 -0
- package/rules/unqualified-superlative.yaml +1 -0
- package/src/date-stamp.ts +65 -0
- package/src/declared-lists.ts +5 -0
- package/src/detectors/acronym-context.ts +50 -0
- package/src/detectors/acronym-expansion.ts +109 -0
- package/src/detectors/char-shape.ts +12 -30
- package/src/detectors/doubled-word.ts +128 -0
- package/src/detectors/heading-echo.ts +13 -3
- package/src/detectors/index.ts +2 -0
- package/src/detectors/lead-in.ts +14 -0
- package/src/detectors/lexicon-match.ts +10 -0
- package/src/detectors/lexicon.ts +32 -6
- package/src/detectors/orthography.ts +20 -2
- package/src/detectors/place-run.ts +63 -0
- package/src/detectors/register.ts +83 -0
- package/src/detectors/sentence-ending.ts +24 -28
- package/src/detectors/signals.ts +24 -120
- package/src/detectors/structure.ts +21 -2
- package/src/detectors/superlative-scope.ts +66 -0
- package/src/document.ts +19 -11
- package/src/eval.ts +2 -1
- package/src/heading-title.ts +8 -0
- package/src/in-page-nav.ts +67 -0
- package/src/number-name.ts +68 -0
- package/src/orthography.ts +77 -15
- package/src/plugin.ts +4 -0
- package/src/rule-load.ts +1 -0
- package/src/run.ts +5 -4
package/src/document.ts
CHANGED
|
@@ -4,16 +4,21 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
|
|
4
4
|
import { frontmatter } from "micromark-extension-frontmatter";
|
|
5
5
|
import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
|
|
6
6
|
import { atxHeadingText, headingText } from "./heading-text.ts";
|
|
7
|
+
import { hasTitle } from "./heading-title.ts";
|
|
7
8
|
import { maskSpans } from "./mask.ts";
|
|
8
9
|
import { buildTree, type Outline } from "./structure/build.ts";
|
|
9
10
|
import { isMarkdownPath } from "./structure/markdown-path.ts";
|
|
10
11
|
import { pageFurniture, textOutline } from "./page-furniture.ts";
|
|
11
12
|
import { tokenizedLexicons } from "./lexicon-tokens.ts";
|
|
13
|
+
import { inPageAnchors, isInPageNavigation, isNavigationList, type InPageAnchors } from "./in-page-nav.ts";
|
|
12
14
|
import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode, DocumentProfile } from "./plugin.ts";
|
|
13
15
|
|
|
14
16
|
type Place = { readonly offset?: number | undefined };
|
|
15
17
|
type Node = {
|
|
16
18
|
readonly type: string;
|
|
19
|
+
readonly url?: string | undefined;
|
|
20
|
+
readonly value?: string | undefined;
|
|
21
|
+
readonly identifier?: string | undefined;
|
|
17
22
|
readonly position?: { readonly start: Place; readonly end: Place } | undefined;
|
|
18
23
|
readonly children?: readonly Node[] | undefined;
|
|
19
24
|
};
|
|
@@ -93,14 +98,14 @@ const matchSpans = (source: string, pattern: RegExp): Span[] =>
|
|
|
93
98
|
|
|
94
99
|
const directiveSpans = (source: string): Span[] => [...matchSpans(source, DIRECTIVE), ...matchSpans(source, BARE_URL)];
|
|
95
100
|
|
|
96
|
-
const collectMasks = (root: Node, source: string): Span[] => {
|
|
101
|
+
const collectMasks = (root: Node, source: string, anchors: InPageAnchors): Span[] => {
|
|
97
102
|
const spans: Span[] = [...directiveSpans(source)];
|
|
98
103
|
walk(root, (node) => {
|
|
99
104
|
if (node.type === "link" || node.type === "linkReference") {
|
|
100
105
|
spans.push(...linkChrome(node));
|
|
101
106
|
return;
|
|
102
107
|
}
|
|
103
|
-
if (!NOT_PROSE.has(node.type) && node.type !== "heading") return;
|
|
108
|
+
if (!NOT_PROSE.has(node.type) && node.type !== "heading" && !isInPageNavigation(node, anchors)) return;
|
|
104
109
|
const span = spanOf(node);
|
|
105
110
|
if (span !== undefined) spans.push(span);
|
|
106
111
|
});
|
|
@@ -136,7 +141,8 @@ const headingsOf = (root: Node, source: string): Heading[] => {
|
|
|
136
141
|
if (span === undefined) return;
|
|
137
142
|
// ATX(行頭が #)なら閉じの # も外す。setext の見出しの末尾の # は言葉なので残す。
|
|
138
143
|
const read = source.startsWith("#", span.start) ? atxHeadingText : headingText;
|
|
139
|
-
|
|
144
|
+
const text = read(textOf(node, source));
|
|
145
|
+
if (hasTitle(text)) found.push({ depth, text, start: span.start, end: span.end });
|
|
140
146
|
});
|
|
141
147
|
return found;
|
|
142
148
|
};
|
|
@@ -162,10 +168,10 @@ const strongSpans = (root: Node, masked: readonly Span[]): Span[] => {
|
|
|
162
168
|
|
|
163
169
|
const within = (span: Span, from: number, to: number): boolean => span.start >= from && span.start < to;
|
|
164
170
|
|
|
165
|
-
const spansOfType = (root: Node, type: string): Span[] => {
|
|
171
|
+
const spansOfType = (root: Node, type: string, keep: (node: Node) => boolean = () => true): Span[] => {
|
|
166
172
|
const found: Span[] = [];
|
|
167
173
|
walk(root, (node) => {
|
|
168
|
-
if (node.type !== type) return;
|
|
174
|
+
if (node.type !== type || !keep(node)) return;
|
|
169
175
|
const span = spanOf(node);
|
|
170
176
|
if (span !== undefined) found.push(span);
|
|
171
177
|
});
|
|
@@ -218,11 +224,11 @@ const paragraphsOf = (spans: readonly Span[], sentences: readonly Sentence[], li
|
|
|
218
224
|
.filter((span) => !listSpans.some((list) => span.start >= list.start && span.start < list.end))
|
|
219
225
|
.map((span) => ({ span, sentences: sentences.filter((sentence) => within(sentence.span, span.start, span.end)) }));
|
|
220
226
|
|
|
221
|
-
/** 箇条書きは list ノードの直下の項目を数える。入れ子の項目は内側の list
|
|
222
|
-
const listsOf = (root: Node, source: string): BulletList[] => {
|
|
227
|
+
/** 箇条書きは list ノードの直下の項目を数える。入れ子の項目は内側の list のものとして数える。目次のような案内だけの箇条書きは数えない。 */
|
|
228
|
+
const listsOf = (root: Node, source: string, anchors: InPageAnchors): BulletList[] => {
|
|
223
229
|
const found: BulletList[] = [];
|
|
224
230
|
walk(root, (node) => {
|
|
225
|
-
if (node.type !== "list") return;
|
|
231
|
+
if (node.type !== "list" || isNavigationList(node, anchors)) return;
|
|
226
232
|
const span = spanOf(node);
|
|
227
233
|
if (span === undefined) return;
|
|
228
234
|
const items = (node.children ?? []).flatMap((child) => {
|
|
@@ -263,13 +269,15 @@ export const buildDocument = (
|
|
|
263
269
|
profile: DocumentProfile | undefined = undefined,
|
|
264
270
|
): ProseDocument => {
|
|
265
271
|
const root = parse(source);
|
|
272
|
+
const anchors = inPageAnchors(root);
|
|
266
273
|
// 強調の記号は「本文でないもの」だが、太字の数を数えるときの「覆われた場所」ではない。
|
|
267
274
|
// 同じ集合にすると、太字が自分の記号のせいで覆われた場所にあることになり、1 つも数えられなくなる。
|
|
268
275
|
// テキストの文書は、ページのヘッダーとフッターも本文ではない(Markdown には改ページが無い)。
|
|
269
|
-
const blocks = [...collectMasks(root, source), ...(isMarkdownPath(path) ? [] : pageFurniture(source))];
|
|
276
|
+
const blocks = [...collectMasks(root, source, anchors), ...(isMarkdownPath(path) ? [] : pageFurniture(source))];
|
|
270
277
|
const masked = [...blocks, ...emphasisSpans(root, source)];
|
|
271
278
|
const prose = maskSpans(source, masked);
|
|
272
|
-
|
|
279
|
+
// ページの案内は段落としても数えない。数えると、目次の行が「本題までの段落」に入る。
|
|
280
|
+
const paragraphSpans = spansOfType(root, "paragraph", (node) => !isInPageNavigation(node, anchors));
|
|
273
281
|
const listItems = spansOfType(root, "listItem");
|
|
274
282
|
const sentences = sentencesOf(prose, paragraphSpans, adapter);
|
|
275
283
|
const lexicons = {
|
|
@@ -292,7 +300,7 @@ export const buildDocument = (
|
|
|
292
300
|
sentences,
|
|
293
301
|
listSpans: listItems,
|
|
294
302
|
paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
|
|
295
|
-
lists: listsOf(root, source),
|
|
303
|
+
lists: listsOf(root, source, anchors),
|
|
296
304
|
lexicons: tagged ? tokenizedLexicons(lexicons, adapter) : lexicons,
|
|
297
305
|
requiredSections: team.requiredSections,
|
|
298
306
|
// 構造の rule(参照先が無い・番号の抜け)が読む木。どの rule も読まなければ作らない。何万行の契約書で、他の rule の lint に代金を払わせない。
|
package/src/eval.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DETECTORS } from "./detectors/index.ts";
|
|
2
2
|
import { charLength } from "./measure.ts";
|
|
3
3
|
import type { ProseDocument, RuleDefinition } from "./plugin.ts";
|
|
4
|
+
import { missingList } from "./declared-lists.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* 閾値の較正。
|
|
@@ -48,7 +49,6 @@ const countAt = (docs: readonly ProseDocument[], rule: RuleDefinition, limit: nu
|
|
|
48
49
|
if (detector === undefined) return { limit, findings: 0, documents: 0, per10k: 0 };
|
|
49
50
|
const perDoc = docs.map((doc) => {
|
|
50
51
|
const lexicon = rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list];
|
|
51
|
-
if (rule.word_list !== undefined && lexicon === undefined) return 0;
|
|
52
52
|
return detector(doc, { limit, lexicon, where: rule.where }).length;
|
|
53
53
|
});
|
|
54
54
|
const findings = perDoc.reduce((sum, count) => sum + count, 0);
|
|
@@ -82,6 +82,7 @@ const measurable = (rule: RuleDefinition, docs: readonly ProseDocument[], genre:
|
|
|
82
82
|
if (rule.layer === "L4" || !rule.use_for.some((target) => genre.startsWith(target))) return false;
|
|
83
83
|
if (rule.languages !== undefined && !rule.languages.includes(language)) return false;
|
|
84
84
|
if (rule.from.length > 0) return false;
|
|
85
|
+
if (docs.some((doc) => missingList(rule, doc.lexicons) !== undefined)) return false;
|
|
85
86
|
return rule.requires.every((need) => meets(need, docs));
|
|
86
87
|
};
|
|
87
88
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** 文字か数字。記号・空白・絵文字だけの並びには、読者が指せる題が無い。 */
|
|
2
|
+
const TITLE_CHARACTER = /[\p{L}\p{N}]/u;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 見出しに題があるか。`###` だけの行や `## ---` は、変換で残った空の `<h3>` のような区切りで、
|
|
6
|
+
* 本文の始まりでも読者が辿れる節でもない。題の無い見出しは見出しとして数えず、前後の本文を 1 つの節として読む。
|
|
7
|
+
*/
|
|
8
|
+
export const hasTitle = (headingWords: string): boolean => TITLE_CHARACTER.test(headingWords);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** mdast の節のうち、ページ内の案内かどうかの判定に要る部分だけ。 */
|
|
2
|
+
export type NavNode = {
|
|
3
|
+
readonly type: string;
|
|
4
|
+
readonly url?: string | undefined;
|
|
5
|
+
readonly value?: string | undefined;
|
|
6
|
+
readonly identifier?: string | undefined;
|
|
7
|
+
readonly children?: readonly NavNode[] | undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** 参照形式のリンク(`[目次][toc]`)の名前のうち、定義(`[toc]: #目次`)が同じページの中を指すもの。 */
|
|
11
|
+
export type InPageAnchors = ReadonlySet<string>;
|
|
12
|
+
|
|
13
|
+
/** 文字か数字。これが 1 つでもあれば、書いた人の言葉がある。記号(▲ ↑ | ・)だけなら飾り。 */
|
|
14
|
+
const WORD = /[\p{L}\p{N}]/u;
|
|
15
|
+
|
|
16
|
+
const WRAPPERS = new Set(["strong", "emphasis", "delete"]);
|
|
17
|
+
|
|
18
|
+
const pointsInPage = (url: string | undefined): boolean => (url ?? "").startsWith("#");
|
|
19
|
+
|
|
20
|
+
const collectAnchors = (node: NavNode, found: Set<string>): void => {
|
|
21
|
+
if (node.type === "definition" && node.identifier !== undefined && pointsInPage(node.url)) found.add(node.identifier);
|
|
22
|
+
(node.children ?? []).forEach((child) => collectAnchors(child, found));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const inPageAnchors = (root: NavNode): InPageAnchors => {
|
|
26
|
+
const found = new Set<string>();
|
|
27
|
+
collectAnchors(root, found);
|
|
28
|
+
return found;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const isInPageLink = (node: NavNode, anchors: InPageAnchors): boolean =>
|
|
32
|
+
(node.type === "link" && pointsInPage(node.url)) || (node.type === "linkReference" && anchors.has(node.identifier ?? ""));
|
|
33
|
+
|
|
34
|
+
const isNavigationPart = (node: NavNode, anchors: InPageAnchors): boolean => {
|
|
35
|
+
if (isInPageLink(node, anchors) || node.type === "break") return true;
|
|
36
|
+
if (node.type === "text") return !WORD.test(node.value ?? "");
|
|
37
|
+
return WRAPPERS.has(node.type) && (node.children ?? []).every((child) => isNavigationPart(child, anchors));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const containsInPageLink = (node: NavNode, anchors: InPageAnchors): boolean =>
|
|
41
|
+
isInPageLink(node, anchors) || (node.children ?? []).some((child) => containsInPageLink(child, anchors));
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 同じページの中へのリンク(`](#…)`)と記号だけでできた段落。「▲ 目次に戻る」や目次の項目で、本文ではなくページの案内。
|
|
45
|
+
* 言葉が 1 つでも混ざれば本文(「詳しくは[第3章](#第3章)を参照」)。他のページへのリンクは書いた人が選んだ一覧なので残す。
|
|
46
|
+
*/
|
|
47
|
+
export const isInPageNavigation = (paragraph: NavNode, anchors: InPageAnchors = new Set()): boolean => {
|
|
48
|
+
const children = paragraph.children ?? [];
|
|
49
|
+
return (
|
|
50
|
+
paragraph.type === "paragraph" &&
|
|
51
|
+
children.every((child) => isNavigationPart(child, anchors)) &&
|
|
52
|
+
children.some((child) => containsInPageLink(child, anchors))
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const isNavigationBlock = (node: NavNode, anchors: InPageAnchors): boolean => isInPageNavigation(node, anchors) || isNavigationList(node, anchors);
|
|
57
|
+
|
|
58
|
+
const isNavigationItem = (item: NavNode, anchors: InPageAnchors): boolean => {
|
|
59
|
+
const children = item.children ?? [];
|
|
60
|
+
return item.type === "listItem" && children.length > 0 && children.every((child) => isNavigationBlock(child, anchors));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** 項目がどれもページの案内(入れ子も含む)の箇条書き。目次の項目の数は節の数で決まり、書いた人が選んだ並べ方ではない。 */
|
|
64
|
+
export const isNavigationList = (list: NavNode, anchors: InPageAnchors = new Set()): boolean => {
|
|
65
|
+
const items = list.children ?? [];
|
|
66
|
+
return list.type === "list" && items.length > 0 && items.every((item) => isNavigationItem(item, anchors));
|
|
67
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Span, Token } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 数量ではなく名前として書かれた数(番号・識別子)。鉤括弧で引いた「3.1 リサーチの原則」の節番号、※1 のような注の番号、
|
|
5
|
+
* 「〒102-0094 東京都」の郵便番号は、後ろの空白が番号と題・項目の区切りで、「3回」「3 回」のような空け方の好みではない。
|
|
6
|
+
* 空け方を数える材料にしない。
|
|
7
|
+
*
|
|
8
|
+
* 形で名前と読むのは三つだけ。0 で始まる組を含むハイフンつなぎの番号、※ や鉤括弧のすぐ後ろの番号、文頭の階層つきの節番号(2.1、3.1.2)。
|
|
9
|
+
* 文頭でも「223 言語」「1ターン」は数量なので、階層の無い数は文頭というだけでは名前と読まない。
|
|
10
|
+
* そのうえで、すぐ後ろ(空白 1 つまで)の語が数につく語(助数詞・数・助詞・助動詞)なら数量として数え続ける(「3-5 日」「26.7 万行」)。
|
|
11
|
+
* 品詞が無ければ判断せず、名前とは読まない。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 数と結びついて読まれる語。助数詞は NounType=Class、数は NumType=Card で渡る。 */
|
|
15
|
+
const BOUND_TO_NUMBER = new Set(["ADP", "PART", "AUX", "SCONJ", "CCONJ"]);
|
|
16
|
+
|
|
17
|
+
/** 番号が立つ位置の直前: 題を包む鉤括弧、注の印。 */
|
|
18
|
+
const LABEL_LEAD = /[「『【※]$/u;
|
|
19
|
+
|
|
20
|
+
/** 文頭(前に空白と箇条書きの印だけ)。 */
|
|
21
|
+
const LINE_HEAD = /^[\s\-*+・•]*$/u;
|
|
22
|
+
|
|
23
|
+
/** 階層つきの節番号(2.1、3.1.2)。 */
|
|
24
|
+
const SECTION_NUMBER = /^\d+(?:\.\d+)+$/u;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 0 で始まる組を含む、ハイフンでつないだ番号(〒102-0094、03-3501)。二つの数だけの「3-5」は範囲(3-5 営業日)とも読めるので
|
|
28
|
+
* 数え続ける。三つ以上つないだもの(073-489-5909)は、orthography.ts が境目を作る前に外している。
|
|
29
|
+
*/
|
|
30
|
+
const isHyphenIdentifier = (digits: string): boolean => {
|
|
31
|
+
const parts = digits.split("-").filter((part) => part !== "");
|
|
32
|
+
return parts.length > 1 && parts.some((part) => part.startsWith("0"));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const RUN_CHAR = /[\d.-]/u;
|
|
36
|
+
|
|
37
|
+
/** at を含む、数字・小数点・ハイフンの並び。at が並びの中に無ければ undefined。 */
|
|
38
|
+
export const digitRunAround = (text: string, at: number): Span | undefined => {
|
|
39
|
+
if (!RUN_CHAR.test(text[at] ?? "")) return undefined;
|
|
40
|
+
const startAt = (index: number): number => (index > 0 && RUN_CHAR.test(text[index - 1] ?? "") ? startAt(index - 1) : index);
|
|
41
|
+
const endAt = (index: number): number => (index < text.length && RUN_CHAR.test(text[index] ?? "") ? endAt(index + 1) : index);
|
|
42
|
+
return { start: startAt(at), end: endAt(at) };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** 並びの後ろ(空白 1 つまで)で始まる語。 */
|
|
46
|
+
const wordAfter = (tokens: readonly Token[], text: string, run: Span, base: number): Token | undefined => {
|
|
47
|
+
const next = text[run.end] === " " ? run.end + 1 : run.end;
|
|
48
|
+
return tokens.find((token) => token.span.start === base + next);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isBoundToNumber = (token: Token): boolean =>
|
|
52
|
+
token.features?.["NounType"] === "Class" || token.features?.["NumType"] === "Card" || BOUND_TO_NUMBER.has(token.pos);
|
|
53
|
+
|
|
54
|
+
/** ハイフンでつないだ識別子か、番号の立つ位置の番号か、文頭の節番号か。 */
|
|
55
|
+
const isNameShaped = (text: string, run: Span): boolean => {
|
|
56
|
+
const [before, digits] = [text.slice(0, run.start), text.slice(run.start, run.end)];
|
|
57
|
+
return isHyphenIdentifier(digits) || LABEL_LEAD.test(before) || (LINE_HEAD.test(before) && SECTION_NUMBER.test(digits));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* text の run が名前として書かれた数か。tokens は文書全体の座標で、base は text の先頭の位置。
|
|
62
|
+
* 後ろの語が読めない(品詞が無い、語の切れ目が合わない)ときは、数量として数え続ける。
|
|
63
|
+
*/
|
|
64
|
+
export const isNumberName = (text: string, run: Span, tokens: readonly Token[] | undefined, base: number): boolean => {
|
|
65
|
+
if (tokens === undefined || !isNameShaped(text, run)) return false;
|
|
66
|
+
const next = wordAfter(tokens, text, run, base);
|
|
67
|
+
return next !== undefined && !isBoundToNumber(next);
|
|
68
|
+
};
|
package/src/orthography.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 表記のそろい。書き方の決まりのうち、文字だけで決まるもの。
|
|
2
2
|
|
|
3
|
-
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
3
|
+
export const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
4
4
|
|
|
5
5
|
/** text の中で word が現れる [始まり, 終わり) を、左から順に。大文字小文字を区別しないときは i で探す(位置は元の文字列のまま)。 */
|
|
6
6
|
const spansOf = (text: string, word: string, ignoreCase: boolean): [number, number][] => {
|
|
@@ -62,22 +62,53 @@ const latinBeside = (japanese: string | undefined, other: string | undefined, di
|
|
|
62
62
|
isJapanese(japanese) ? kindOf(other, digitSide) : undefined;
|
|
63
63
|
|
|
64
64
|
/** 日本語の字の隣にある英字・数字との境目。間が半角空白 1 つなら「空けている」、何も無ければ「詰めている」。 */
|
|
65
|
-
|
|
65
|
+
// 「confidence=0」のような設定の書き方も、= を含めて一つの並びとして読む。
|
|
66
|
+
const ALPHANUMERIC = /[A-Za-z0-9.=]/u;
|
|
67
|
+
// 名前の語は英数字だけ。「API = 0」の空白で囲んだ = は式の記号で、名前の一部ではない。
|
|
68
|
+
const WORD_CHAR = /[A-Za-z0-9.]/u;
|
|
69
|
+
|
|
70
|
+
/** chars[last] を末尾とし、pattern の字が続く並びの、先頭の位置。 */
|
|
71
|
+
const runStartIndex = (chars: readonly string[], last: number, pattern: RegExp = ALPHANUMERIC): number => {
|
|
72
|
+
let first = last;
|
|
73
|
+
while (first > 0 && pattern.test(chars[first - 1] ?? "")) first -= 1;
|
|
74
|
+
return first;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** 並び first の前に、半角空白 1 つずつで続く英数字の語。「Phase 1」の「Phase」、「JIS X 0301」の「JIS X」。無ければ空。 */
|
|
78
|
+
const wordsBefore = (chars: readonly string[], first: number): string => {
|
|
79
|
+
const words: string[] = [];
|
|
80
|
+
let space = first - 1;
|
|
81
|
+
while (chars[space] === " " && WORD_CHAR.test(chars[space - 1] ?? "")) {
|
|
82
|
+
const start = runStartIndex(chars, space - 1, WORD_CHAR);
|
|
83
|
+
words.unshift(chars.slice(start, space).join(""));
|
|
84
|
+
space = start - 1;
|
|
85
|
+
}
|
|
86
|
+
return words.join(" ");
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// 「1ファイル x 1シート」の x のように、1 字だけの英字は記号か変数で、名前の頭ではない。
|
|
90
|
+
const MIN_NAME_LETTERS = 2;
|
|
66
91
|
|
|
67
92
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
93
|
+
* 「Phase 1 は」「iOS 17以上」の数字は、英字の語に続く名前の一部。空白は名前の中の空白で、日本語との境目は
|
|
94
|
+
* 「H30 等」と同じく英字の空け方として数える。
|
|
70
95
|
*/
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return chars[first];
|
|
96
|
+
const isNameTail = (chars: readonly string[], first: number): boolean => {
|
|
97
|
+
const words = wordsBefore(chars, first);
|
|
98
|
+
return LETTER.test(words[0] ?? "") && (words.match(/[A-Za-z]/gu)?.length ?? 0) >= MIN_NAME_LETTERS;
|
|
75
99
|
};
|
|
76
100
|
|
|
77
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* 日本語の左にある英数字の並びの種類は、先頭の字で決める。「3GBの」の「GB」は数量の単位で、並びは数字で始まる。
|
|
103
|
+
* そうした並びと日本語の境目は、英字の空け方ではなく数字の後ろの空け方として数える(「3回」と同じ)。
|
|
104
|
+
* 逆に「H30 等」「EC2 で」「v1.2の」は英字で始まる名前で、書き手は英単語と同じに空ける。
|
|
105
|
+
*/
|
|
78
106
|
const leftRunBeside = (chars: readonly string[], last: number, japanese: string | undefined): SpacingKind | undefined => {
|
|
79
107
|
const kind = latinBeside(japanese, chars[last], "after-digit");
|
|
80
|
-
|
|
108
|
+
if (kind === undefined) return undefined;
|
|
109
|
+
const first = runStartIndex(chars, last);
|
|
110
|
+
if (DIGIT.test(chars[first] ?? "") && isNameTail(chars, first)) return "letter";
|
|
111
|
+
return kindOf(chars[first], "after-digit") ?? kind;
|
|
81
112
|
};
|
|
82
113
|
|
|
83
114
|
/**
|
|
@@ -93,10 +124,37 @@ const isOrdinalRun = (chars: readonly string[], index: number): boolean => {
|
|
|
93
124
|
return chars[before] === ORDINAL_PREFIX;
|
|
94
125
|
};
|
|
95
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 「073-489-5909」「2026-06-02」「Ⅰ-4-1-3」のように - でつないだ 3 組以上の数字は、電話番号・日付・図の番号で
|
|
129
|
+
* 数量ではない。後ろの空白は欄の区切りで、「3回」の空け方とは別のもの。両側とも数えない。
|
|
130
|
+
* 2 組(「1-3ヶ月」「20-30分」)は幅のある数量なので数える。
|
|
131
|
+
*/
|
|
132
|
+
const MIN_CODE_GROUPS = 3;
|
|
133
|
+
const CODE_CHAR = /[\d-]/u;
|
|
134
|
+
|
|
135
|
+
const isCode = (chars: readonly string[], digit: number): boolean => {
|
|
136
|
+
let [first, last] = [digit, digit];
|
|
137
|
+
while (first > 0 && CODE_CHAR.test(chars[first - 1] ?? "")) first -= 1;
|
|
138
|
+
while (last < chars.length - 1 && CODE_CHAR.test(chars[last + 1] ?? "")) last += 1;
|
|
139
|
+
const groups = chars
|
|
140
|
+
.slice(first, last + 1)
|
|
141
|
+
.join("")
|
|
142
|
+
.split("-");
|
|
143
|
+
return groups.filter((group) => group !== "").length >= MIN_CODE_GROUPS;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** 日本語との境目にある数字が、数えない書き方(「第3条」の番地、「073-489-5909」の符号)か。 */
|
|
147
|
+
const isUncountedNumber = (chars: readonly string[], index: number): boolean => {
|
|
148
|
+
const [left, right] = [chars[index], chars[index + 1]];
|
|
149
|
+
if (DIGIT.test(left ?? "")) return isOrdinalRun(chars, index) || isCode(chars, index);
|
|
150
|
+
const next = right === " " ? index + 2 : index + 1;
|
|
151
|
+
if (!DIGIT.test(chars[next] ?? "")) return false;
|
|
152
|
+
return left === ORDINAL_PREFIX || (isJapanese(left) && isCode(chars, next));
|
|
153
|
+
};
|
|
154
|
+
|
|
96
155
|
const boundaryAt = (chars: readonly string[], index: number): Omit<Boundary, "offset"> | undefined => {
|
|
156
|
+
if (isUncountedNumber(chars, index)) return undefined;
|
|
97
157
|
const [left, right, after] = [chars[index], chars[index + 1], chars[index + 2]];
|
|
98
|
-
const numberAfterPrefix = left === ORDINAL_PREFIX && DIGIT.test((right === " " ? after : right) ?? "");
|
|
99
|
-
if (numberAfterPrefix || (DIGIT.test(left ?? "") && isOrdinalRun(chars, index))) return undefined;
|
|
100
158
|
const touching = latinBeside(left, right, "before-digit") ?? leftRunBeside(chars, index, right);
|
|
101
159
|
if (touching !== undefined) return { kind: touching, spaced: false };
|
|
102
160
|
if (right !== " ") return undefined;
|
|
@@ -104,15 +162,19 @@ const boundaryAt = (chars: readonly string[], index: number): Omit<Boundary, "of
|
|
|
104
162
|
return spaced === undefined ? undefined : { kind: spaced, spaced: true };
|
|
105
163
|
};
|
|
106
164
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
165
|
+
/**
|
|
166
|
+
* 文の中の境目を全部。offset は境目の位置(左の字の直後、UTF-16)。配列を作り直さずに一度なめる。
|
|
167
|
+
* text は本文でないものを空白で覆った文、written は覆う前の同じ範囲。覆ってできた空白(リンクの「[」など)は
|
|
168
|
+
* 書き手が空けたものではないので、「空けている」とは数えない。
|
|
169
|
+
*/
|
|
170
|
+
export const latinBoundaries = (text: string, written: string = text): Boundary[] => {
|
|
109
171
|
const chars = [...text];
|
|
110
172
|
const found: Boundary[] = [];
|
|
111
173
|
let offset = 0;
|
|
112
174
|
chars.forEach((char, index) => {
|
|
113
175
|
offset += char.length;
|
|
114
176
|
const boundary = boundaryAt(chars, index);
|
|
115
|
-
if (boundary !== undefined) found.push({ ...boundary, offset });
|
|
177
|
+
if (boundary !== undefined && (!boundary.spaced || written[offset] === " ")) found.push({ ...boundary, offset });
|
|
116
178
|
});
|
|
117
179
|
return found;
|
|
118
180
|
};
|
package/src/plugin.ts
CHANGED
|
@@ -40,6 +40,8 @@ export type LexiconEntry = {
|
|
|
40
40
|
readonly pattern: string;
|
|
41
41
|
readonly weight?: number | undefined;
|
|
42
42
|
readonly instead_of?: string | undefined;
|
|
43
|
+
/** 語が、かかる語のどちら側に立つか。語順が言語で違うものを語彙表が言う(範囲の「で」は前、"in" は後ろ)。 */
|
|
44
|
+
readonly position?: "before" | "after" | undefined;
|
|
43
45
|
/** pattern を adapter が語に分けたもの。品詞が読めるときだけ core が入れる。語彙表を書く側は書かない。 */
|
|
44
46
|
readonly tokens?: readonly Token[] | undefined;
|
|
45
47
|
};
|
|
@@ -344,6 +346,8 @@ export type RuleDefinition = {
|
|
|
344
346
|
readonly by_genre: Readonly<Record<string, LevelTable>>;
|
|
345
347
|
readonly how_to_find: string;
|
|
346
348
|
readonly word_list: string | undefined;
|
|
349
|
+
/** word_list のほかに detector が名前で引く語彙表。どれかが無い言語では rule を動かさない。 */
|
|
350
|
+
readonly extra_word_lists: readonly string[];
|
|
347
351
|
/** L4 のみ。LLM に渡す決まり。言語別。 */
|
|
348
352
|
readonly what_to_check: Localized | undefined;
|
|
349
353
|
readonly where: string | undefined;
|
package/src/rule-load.ts
CHANGED
|
@@ -99,6 +99,7 @@ const toRule = (raw: unknown, language: string, file: string): RuleDefinition =>
|
|
|
99
99
|
by_genre: genreTables(raw["by_genre"], language),
|
|
100
100
|
how_to_find: String(raw["how_to_find"]),
|
|
101
101
|
word_list: typeof raw["word_list"] === "string" ? raw["word_list"] : undefined,
|
|
102
|
+
extra_word_lists: stringList(raw["extra_word_lists"]) ?? [],
|
|
102
103
|
what_to_check: isLocalized(raw["what_to_check"]) ? raw["what_to_check"] : undefined,
|
|
103
104
|
where: typeof raw["where"] === "string" ? raw["where"] : undefined,
|
|
104
105
|
requires: stringList(raw["requires"]) ?? [],
|
package/src/run.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { AdapterNeeds, Finding, Level, ProseDocument, RuleDefinition } from
|
|
|
4
4
|
import { lineStarts, placeOf } from "./position.ts";
|
|
5
5
|
import { REASONS, type Reasons } from "./reasons.ts";
|
|
6
6
|
import { joinWords } from "./detectors/word-list.ts";
|
|
7
|
+
import { missingList } from "./declared-lists.ts";
|
|
7
8
|
import { unreadStructure, type Unread } from "./structure/unread.ts";
|
|
8
9
|
import { isMarkdownPath } from "./structure/markdown-path.ts";
|
|
9
10
|
import { maskSpans } from "./mask.ts";
|
|
@@ -181,15 +182,15 @@ export const runRules = (
|
|
|
181
182
|
const detector = DETECTORS[rule.how_to_find];
|
|
182
183
|
if (detector === undefined)
|
|
183
184
|
return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noDetector(rule.how_to_find) }] };
|
|
185
|
+
// 語彙表を要求する rule で、その言語に語彙表が無ければ動かせない。黙って通さない。
|
|
186
|
+
const absent = missingList(rule, doc.lexicons);
|
|
187
|
+
if (absent !== undefined)
|
|
188
|
+
return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noLexicon(doc.language, absent) }] };
|
|
184
189
|
const options = {
|
|
185
190
|
limit: limitFor(rule, level, genre, limits),
|
|
186
191
|
lexicon: rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list],
|
|
187
192
|
where: rule.where,
|
|
188
193
|
};
|
|
189
|
-
// 語彙表を要求する rule で、その言語に語彙表が無ければ動かせない。黙って通さない。
|
|
190
|
-
if (rule.word_list !== undefined && options.lexicon === undefined) {
|
|
191
|
-
return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noLexicon(doc.language, rule.word_list) }] };
|
|
192
|
-
}
|
|
193
194
|
const found = detector(doc, options).map((finding) => place(starts, { ...finding, rule: rule.id, severity: rule.severity }));
|
|
194
195
|
return { findings: [...acc.findings, ...found], skipped: acc.skipped };
|
|
195
196
|
},
|