chaffjs 0.11.0 → 0.12.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 +13 -0
- package/dist/detectors/acronym-context.d.ts.map +1 -0
- package/dist/detectors/acronym-context.js +42 -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 +67 -0
- package/dist/detectors/acronym-expansion.js.map +1 -0
- 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/signals.d.ts.map +1 -1
- package/dist/detectors/signals.js +9 -111
- 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/orthography.d.ts +1 -0
- package/dist/orthography.d.ts.map +1 -1
- package/dist/orthography.js +4 -3
- 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 +2 -2
- package/rules/undefined-acronym.yaml +1 -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 +53 -0
- package/src/detectors/acronym-expansion.ts +81 -0
- 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/signals.ts +11 -116
- 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/orthography.ts +4 -3
- package/src/plugin.ts +4 -0
- package/src/rule-load.ts +1 -0
- package/src/run.ts +5 -4
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Span } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
/** 段落 1 つ。dates は段落の中の日付の位置で、段落の先頭から数える。 */
|
|
4
|
+
export type StampCandidate = { readonly text: string; readonly dates: readonly Span[] };
|
|
5
|
+
|
|
6
|
+
/** 段落ごとの候補。dates は文書の中の日付の位置で、段落の中にすっかり収まるものだけを段落の先頭からの位置にする。 */
|
|
7
|
+
export const stampCandidates = (source: string, paragraphs: readonly Span[], dates: readonly Span[]): StampCandidate[] =>
|
|
8
|
+
paragraphs.map((paragraph) => ({
|
|
9
|
+
text: source.slice(paragraph.start, paragraph.end),
|
|
10
|
+
dates: dates
|
|
11
|
+
.filter((date) => date.start >= paragraph.start && date.end <= paragraph.end)
|
|
12
|
+
.map((date) => ({ start: date.start - paragraph.start, end: date.end - paragraph.start })),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
/** 文字か数字。日付を除いてこれが 1 つも残らなければ、日付だけの段落。 */
|
|
16
|
+
const WORD = /[\p{L}\p{N}]/u;
|
|
17
|
+
const ENDS_WITH_COLON = /[::]\s*$/u;
|
|
18
|
+
const LABEL_EDGE = /[\s::]/u;
|
|
19
|
+
|
|
20
|
+
/** 幅の無い日付は日付として読まない。段落が日付だけかどうかを、何も覆わない位置で決めない。 */
|
|
21
|
+
const datesOf = (candidate: StampCandidate): Span[] => candidate.dates.filter((date) => date.end > date.start);
|
|
22
|
+
|
|
23
|
+
const withoutDates = (candidate: StampCandidate): string => {
|
|
24
|
+
const ordered = datesOf(candidate).sort((left, right) => left.start - right.start);
|
|
25
|
+
const gaps = ordered.reduce<{ readonly parts: readonly string[]; readonly from: number }>(
|
|
26
|
+
(acc, date) => ({ parts: [...acc.parts, candidate.text.slice(acc.from, date.start)], from: Math.max(acc.from, date.end) }),
|
|
27
|
+
{ parts: [], from: 0 },
|
|
28
|
+
);
|
|
29
|
+
return [...gaps.parts, candidate.text.slice(gaps.from)].join(" ");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** 前後の空白とコロンを外したラベル。「Last updated:」も「:最終更新日」も同じ語として引く。 */
|
|
33
|
+
const bareLabel = (text: string): string => {
|
|
34
|
+
const chars = [...text];
|
|
35
|
+
const first = chars.findIndex((char) => !LABEL_EDGE.test(char));
|
|
36
|
+
const last = chars.findLastIndex((char) => !LABEL_EDGE.test(char));
|
|
37
|
+
return first === -1 ? "" : chars.slice(first, last + 1).join("");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const isLabel = (text: string, labels: readonly string[]): boolean => {
|
|
41
|
+
const bare = bareLabel(text).toLowerCase();
|
|
42
|
+
return bare !== "" && labels.some((label) => label.toLowerCase() === bare);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const isDateOnly = (candidate: StampCandidate | undefined): boolean =>
|
|
46
|
+
candidate !== undefined && datesOf(candidate).length > 0 && !WORD.test(withoutDates(candidate));
|
|
47
|
+
|
|
48
|
+
/** 「Updated 2026-03-03」「最終更新日:2025年6月20日」。日付を除くと、語彙表のラベルしか残らない。 */
|
|
49
|
+
const isLabelledDate = (candidate: StampCandidate, labels: readonly string[]): boolean =>
|
|
50
|
+
datesOf(candidate).length > 0 && isLabel(withoutDates(candidate), labels);
|
|
51
|
+
|
|
52
|
+
/** 「最終更新日:」の段落のすぐ後に日付だけの段落。Web ページを写すと、ラベルと日付が別の段落に割れる。 */
|
|
53
|
+
const isLabelBeforeDate = (candidate: StampCandidate, next: StampCandidate | undefined, labels: readonly string[]): boolean =>
|
|
54
|
+
ENDS_WITH_COLON.test(candidate.text) && isLabel(candidate.text, labels) && isDateOnly(next);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 更新日・公開日の刻印になっている段落の位置。ページの付帯情報で、書いた人が読み手に読ませる前置きではない。
|
|
58
|
+
* ラベルの語は言語パッケージの語彙表(date-stamp-label)から渡す。文の中に日付があるだけの段落は刻印ではない。
|
|
59
|
+
*/
|
|
60
|
+
export const dateStampIndexes = (candidates: readonly StampCandidate[], labels: readonly string[]): ReadonlySet<number> =>
|
|
61
|
+
new Set(
|
|
62
|
+
candidates.flatMap((candidate, index) =>
|
|
63
|
+
isDateOnly(candidate) || isLabelledDate(candidate, labels) || isLabelBeforeDate(candidate, candidates[index + 1], labels) ? [index] : [],
|
|
64
|
+
),
|
|
65
|
+
);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Lexicon, RuleDefinition } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
/** rule が宣言した語彙表のうち、その言語に無い最初のもの。無いまま動かすと、照らす語が無いだけの 0 件や指摘が本物に見える。 */
|
|
4
|
+
export const missingList = (rule: RuleDefinition, lexicons: Readonly<Record<string, Lexicon>>): string | undefined =>
|
|
5
|
+
[...(rule.word_list === undefined ? [] : [rule.word_list]), ...rule.extra_word_lists].find((name) => lexicons[name] === undefined);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 大文字の語が略語ではなく、決まった書き方の一部として読める所(3:30 PM、1pm ET、USD 1,000、Kansas City, MO 64108)。
|
|
3
|
+
* どれも閉じた集合で、しかも隣の数字があるときだけ認める。PM(project manager)、CT、CA(certificate authority)は
|
|
4
|
+
* 数字の隣でなければ略語のまま数える。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type Span = { readonly start: number; readonly end: number };
|
|
8
|
+
|
|
9
|
+
const MERIDIEM = ["AM", "PM"];
|
|
10
|
+
|
|
11
|
+
/** 業務文書で説明なしに書かれる時間帯。ここに無いもの(ACT など)は略語として数える。 */
|
|
12
|
+
const TIME_ZONE = ["UTC", "GMT", "JST", "ET", "EST", "EDT", "CT", "CST", "CDT", "MT", "MST", "MDT", "PT", "PST", "PDT", "CET", "CEST"];
|
|
13
|
+
|
|
14
|
+
/** ISO 4217 のうち、業務文書の読み手が説明なしで通じる主要な通貨。CAD は数の隣でも computer-aided design と読める(3 CAD files)ので入れない。 */
|
|
15
|
+
const CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY", "CHF", "AUD", "KRW", "HKD", "SGD", "INR"];
|
|
16
|
+
|
|
17
|
+
/** 米国の州・特別区・準州の郵便略号。住所の書き方(, CA 94720)の中でだけ見る。 */
|
|
18
|
+
const US_STATE = [
|
|
19
|
+
...["AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY"],
|
|
20
|
+
...["LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND"],
|
|
21
|
+
...["OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY"],
|
|
22
|
+
...["DC", "PR", "GU", "VI", "AS", "MP"],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 略語として読む字面を持たない英語の機能語。単独の NOT や AND は強調で、展開するものが無い。
|
|
27
|
+
* OR(Oregon、operating room)や IS(International Standard)のように略語でもある語は入れない。
|
|
28
|
+
*/
|
|
29
|
+
const PLAIN_WORD = ["NOT", "AND"];
|
|
30
|
+
|
|
31
|
+
const oneOf = (words: readonly string[]): string => `(?:${words.join("|")})`;
|
|
32
|
+
|
|
33
|
+
const MINUTES = String.raw`:[0-5]\d`;
|
|
34
|
+
const CLOCK_12 = String.raw`(?<![\d:.])(?:1[0-2]|0?[1-9])(?:${MINUTES})?`;
|
|
35
|
+
const CLOCK_24 = String.raw`(?<![\d:.])(?:[01]?\d|2[0-3])${MINUTES}`;
|
|
36
|
+
/** 時間帯の前の午前・午後は書き方を問わない(pm、p.m.、P.M.、PM)。 */
|
|
37
|
+
const ANY_MERIDIEM = String.raw`[AaPp]\.?[Mm]\.?`;
|
|
38
|
+
/** 桁区切りは 3 桁ずつ揃っているときだけ金額と読む。「item 1, USD」の 1, は金額ではない。 */
|
|
39
|
+
const AMOUNT = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?`;
|
|
40
|
+
|
|
41
|
+
// 強調の記号は空白に置き換えてある(**3:30** PM)ので、部品の間の空白は数を問わない。
|
|
42
|
+
const PATTERNS: readonly RegExp[] = [
|
|
43
|
+
new RegExp(String.raw`${CLOCK_12}\s*${oneOf(MERIDIEM)}`, "gu"),
|
|
44
|
+
new RegExp(String.raw`(?:${CLOCK_24}|${CLOCK_12}\s*${ANY_MERIDIEM})\s*${oneOf(TIME_ZONE)}`, "gu"),
|
|
45
|
+
new RegExp(String.raw`${oneOf(CURRENCY)}\s*[$€£¥]?${AMOUNT}`, "gu"),
|
|
46
|
+
new RegExp(String.raw`(?<![\w.,])${AMOUNT}\s*${oneOf(CURRENCY)}`, "gu"),
|
|
47
|
+
new RegExp(String.raw`,\s+${oneOf(US_STATE)}\s+\d{5}(?:-\d{4})?(?!\d)`, "gu"),
|
|
48
|
+
new RegExp(oneOf(PLAIN_WORD), "gu"),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** 文の中で、略語として数えない大文字を含む範囲。略語は範囲にまるごと入るときだけ外れるので、語の境目は見なくてよい(NOTE の中の NOT)。 */
|
|
52
|
+
export const notAcronymSpans = (text: string): Span[] =>
|
|
53
|
+
PATTERNS.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => ({ start: match.index, end: match.index + match[0].length })));
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 展開は略語の**すぐ隣**にあるときだけ認める。
|
|
5
|
+
* 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
|
|
6
|
+
*
|
|
7
|
+
* 認めるのは 4 つの形。どれも実際によく書かれる。
|
|
8
|
+
* CI(継続的インテグレーション) 略語のあとに括弧
|
|
9
|
+
* Continuous Integration (CI) 括弧の中が略語
|
|
10
|
+
* Tax Cuts and Jobs Act [TCJA] 角括弧の中が略語で、直前の語の頭文字と揃う
|
|
11
|
+
* 人事部(以下「HR」という。) 括弧の中が定義の語と略語だけ
|
|
12
|
+
* 括弧と略語の間には、引用符((“MNDA”)、(「MNDA」))と空白だけを許す。
|
|
13
|
+
*/
|
|
14
|
+
const WRAP = String.raw`[\s"“”'‘’「」『』]*`;
|
|
15
|
+
const OPENS = new RegExp(String.raw`^${WRAP}[((]`, "u");
|
|
16
|
+
const CLOSES = new RegExp(String.raw`^${WRAP}[))]`, "u");
|
|
17
|
+
const OPENED = new RegExp(String.raw`[((]${WRAP}$`, "u");
|
|
18
|
+
const SQUARE_CLOSES = new RegExp(String.raw`^${WRAP}\]`, "u");
|
|
19
|
+
const SQUARE_OPENED = new RegExp(String.raw`\[${WRAP}$`, "u");
|
|
20
|
+
|
|
21
|
+
/** 括弧と略語の間の幅。空白は 1 つに畳んであるので、(“ MNDA ”) まで収まる。 */
|
|
22
|
+
const NEAR = 3;
|
|
23
|
+
|
|
24
|
+
/** 定義の語を挟む形で、括弧から略語まで・略語から括弧までに見る幅。(hereinafter referred to as “SLA”) が収まる。 */
|
|
25
|
+
const DEFINITION_REACH = 60;
|
|
26
|
+
|
|
27
|
+
/** 定義の語どうし、定義の語と略語の間に来てよいもの(以下、HR)。英字が続けば語の途中((theSLA))。 */
|
|
28
|
+
const MARKER_GAP = String.raw`(?![A-Za-z])[\s、,]*`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 角括弧は引用の印にも使う([IANA]、[1])。直前の語のうち大文字で始まる語の頭文字が
|
|
32
|
+
* 略語と揃うときだけ展開と見なす。見る語は略語の文字数の 2 倍まで(and や of を挟むため)。
|
|
33
|
+
*/
|
|
34
|
+
const spellsOut = (before: string, acronym: string): boolean => {
|
|
35
|
+
const letters = acronym.replaceAll("&", "");
|
|
36
|
+
const words = before
|
|
37
|
+
.replace(SQUARE_OPENED, "")
|
|
38
|
+
.trim()
|
|
39
|
+
.split(/\s+/u)
|
|
40
|
+
.slice(-letters.length * 2);
|
|
41
|
+
const initials = words.filter((word) => /^[A-Z]/u.test(word)).map((word) => word.charAt(0));
|
|
42
|
+
return initials.join("").endsWith(letters);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** 言語パッケージの語彙表から読む、括弧の中で略語の前に書く語(以下、hereinafter)と後ろに書く語(という)。 */
|
|
46
|
+
export type DefinitionWords = { readonly markers: readonly string[]; readonly verbs: readonly string[] };
|
|
47
|
+
|
|
48
|
+
type DefinitionPatterns = { readonly opened: RegExp; readonly closes: RegExp };
|
|
49
|
+
|
|
50
|
+
const oneOf = (words: readonly string[]): string => (words.length === 0 ? "(?!)" : words.map(escapeRegExp).join("|"));
|
|
51
|
+
|
|
52
|
+
const definitionPatterns = (words: DefinitionWords): DefinitionPatterns => ({
|
|
53
|
+
opened: new RegExp(String.raw`[((]\s*(?<markers>(?:(?:${oneOf(words.markers)})${MARKER_GAP})*)${WRAP}(?<![A-Za-z])$`, "iu"),
|
|
54
|
+
closes: new RegExp(String.raw`^${WRAP}(?<verb>(?:${oneOf(words.verbs)})[。.]?)?${WRAP}[))]`, "iu"),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 括弧の中に定義の語と略語だけがある形。語が 1 つも無い(CI)は OPENED と CLOSES が見るので、ここでは数えない。
|
|
59
|
+
* 括弧の中身が語と略語で尽きることを求めるので、ふつうの意味の「以下」((以下のSRE手順))は定義にならない。
|
|
60
|
+
*/
|
|
61
|
+
const isDefinedAt = (patterns: DefinitionPatterns, body: string, acronym: string, at: number): boolean => {
|
|
62
|
+
const opened = patterns.opened.exec(body.slice(Math.max(0, at - DEFINITION_REACH), at));
|
|
63
|
+
const closes = patterns.closes.exec(body.slice(at + acronym.length, at + acronym.length + DEFINITION_REACH));
|
|
64
|
+
if (opened === null || closes === null) return false;
|
|
65
|
+
return (opened.groups?.["markers"] ?? "") !== "" || (closes.groups?.["verb"] ?? "") !== "";
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const isBracketedAt = (body: string, acronym: string, at: number): boolean => {
|
|
69
|
+
const after = body.slice(at + acronym.length, at + acronym.length + NEAR);
|
|
70
|
+
const before = body.slice(Math.max(0, at - NEAR), at);
|
|
71
|
+
if (OPENS.test(after) || (OPENED.test(before) && CLOSES.test(after))) return true;
|
|
72
|
+
return SQUARE_OPENED.test(before) && SQUARE_CLOSES.test(after) && spellsOut(body.slice(0, at), acronym);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type ExpandedAt = (body: string, acronym: string, at: number) => boolean;
|
|
76
|
+
|
|
77
|
+
/** body の at にある略語が、その場で展開・定義されているか。語彙表から一度だけ組み立てて、略語ごとに呼ぶ。 */
|
|
78
|
+
export const expansionAt = (words: DefinitionWords): ExpandedAt => {
|
|
79
|
+
const patterns = definitionPatterns(words);
|
|
80
|
+
return (body, acronym, at) => isBracketedAt(body, acronym, at) || isDefinedAt(patterns, body, acronym, at);
|
|
81
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Detector, Finding, Lexicon, Sentence, Token } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 語を書き損じて二度書いた(the the / 資料をを)。消し忘れと、書き換えの途中で残った語(our the platform)の二つ。
|
|
5
|
+
*
|
|
6
|
+
* 同じ語は、表層(大文字小文字は問わない)と品詞の両方が同じときだけ。「that that」は接続詞と指示語で品詞が違う。
|
|
7
|
+
* 冠詞と所有の語は名詞の前に一つしか立たないので、違う語でも二つ並べば書き損じ。印はアダプタが UD の FEATS で付ける。
|
|
8
|
+
* どの言語でも書き損じでない重なり(had had / 一つ一つ)は、言語ごとの語彙表に置く。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 重ねても書き損じにならない品詞。記号と数は語ではない。間投詞(はいはい / no no)と副詞(そうそう / very very)は
|
|
13
|
+
* 重ねて強める書き方がどの語でもできるので、語彙表では尽くせない。固有名詞は重なった名前(Walla Walla)がある。
|
|
14
|
+
*/
|
|
15
|
+
const REPEATABLE = new Set(["PUNCT", "SYM", "NUM", "X", "INTJ", "ADV", "PROPN"]);
|
|
16
|
+
|
|
17
|
+
/** 強調やリンクの印を挟んで重なった内容語は、画面名とその名詞(**app settings** settings screen)。機能語だけが書き損じ。 */
|
|
18
|
+
const FUNCTION_WORD = new Set(["DET", "ADP", "PRON", "AUX", "CCONJ", "SCONJ", "PART"]);
|
|
19
|
+
|
|
20
|
+
const LETTER = /\p{L}/u;
|
|
21
|
+
const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
|
|
22
|
+
const SPACE_ONLY = /^\s*$/u;
|
|
23
|
+
/** 語を空白で区切る言語で、語に接していれば長い語の一部(114A The の A、Content-Type の Content)。 */
|
|
24
|
+
const JOINED = /[\p{L}\p{N}-]/u;
|
|
25
|
+
|
|
26
|
+
const isWord = (token: Token): boolean => LETTER.test(token.surface) && !REPEATABLE.has(token.pos) && token.features?.["NumType"] !== "Card";
|
|
27
|
+
|
|
28
|
+
const isDeterminer = (token: Token): boolean => token.features?.["PronType"] === "Art" || token.features?.["Poss"] === "Yes";
|
|
29
|
+
|
|
30
|
+
const sameWord = (first: Token, second: Token): boolean => first.surface.toLowerCase() === second.surface.toLowerCase() && first.pos === second.pos;
|
|
31
|
+
|
|
32
|
+
/** 二語のあいだ。space は空白だけ、markup は印だけ(文字も数字も無い)、text はそれ以外で、別々の語。 */
|
|
33
|
+
export type Gap = "space" | "markup" | "text";
|
|
34
|
+
|
|
35
|
+
export const gapBetween = (source: string, first: Token, second: Token): Gap => {
|
|
36
|
+
const between = source.slice(first.span.end, second.span.start);
|
|
37
|
+
if (SPACE_ONLY.test(between)) return "space";
|
|
38
|
+
return LETTER_OR_DIGIT.test(between) ? "text" : "markup";
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 語を空白で区切る言語だけ。日本語は語どうしが接しているのが普通。 */
|
|
42
|
+
export const isPartOfLongerWord = (source: string, first: Token, second: Token): boolean =>
|
|
43
|
+
JOINED.test(source.charAt(first.span.start - 1)) || JOINED.test(source.charAt(second.span.end));
|
|
44
|
+
|
|
45
|
+
const surfacesOf = (tokens: readonly Token[]): string => tokens.map((token) => token.surface.toLowerCase()).join("\u0000");
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 語彙表の行(had had、sign in in、set up up to)の k 語目と k+1 語目を重なりに重ねて、行の語が全部並んでいるか。
|
|
49
|
+
* 一語の行は重なりを言っていないので、重ねる位置が無い。
|
|
50
|
+
*/
|
|
51
|
+
const coversPairAt = (tokens: readonly Token[], at: number, words: readonly Token[]): boolean =>
|
|
52
|
+
Array.from({ length: words.length - 1 }, (_, k) => tokens.slice(at - k, at - k + words.length)).some((row) => surfacesOf(row) === surfacesOf(words));
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* tokens の at 語目と次の語の重なりを、語彙表のどれかの行がそのまま含むか。
|
|
56
|
+
* 語彙表の語は、文と同じ解析器で分けたもの(entry.tokens)で比べる。書き方の空白の有無に左右されない。
|
|
57
|
+
*/
|
|
58
|
+
export const isAllowed = (tokens: readonly Token[], at: number, allowed: Lexicon): boolean =>
|
|
59
|
+
allowed.some((entry) => coversPairAt(tokens, at, entry.tokens ?? []));
|
|
60
|
+
|
|
61
|
+
export type Doubled = { readonly first: Token; readonly second: Token };
|
|
62
|
+
|
|
63
|
+
const LOWER_START = /^\p{Ll}/u;
|
|
64
|
+
const UPPER_START = /^\p{Lu}/u;
|
|
65
|
+
|
|
66
|
+
/** 小文字の語の後ろの大文字は、題名や名前の書き出し(the [Your Rights section])。同じ語の the The は書き損じのまま。 */
|
|
67
|
+
export const startsTitle = (first: Token, second: Token): boolean => LOWER_START.test(first.surface) && UPPER_START.test(second.surface);
|
|
68
|
+
|
|
69
|
+
/** アダプタが重ね言葉(UD の Echo=Rdp)と読んだ語。「会社会社で」の二つ目。 */
|
|
70
|
+
const isEcho = (token: Token): boolean => token.features?.["Echo"] === "Rdp";
|
|
71
|
+
|
|
72
|
+
const isDeterminerPair = (first: Token, second: Token): boolean => isDeterminer(first) && isDeterminer(second) && !startsTitle(first, second);
|
|
73
|
+
|
|
74
|
+
const isDoubled = (first: Token, second: Token): boolean =>
|
|
75
|
+
isWord(first) && isWord(second) && !isEcho(second) && (sameWord(first, second) || isDeterminerPair(first, second));
|
|
76
|
+
|
|
77
|
+
/** 並んだ二語が書き損じか。source は文書全体で、token の span もその座標。 */
|
|
78
|
+
export const doubledAt = (source: string, first: Token, second: Token, spaced: boolean): boolean => {
|
|
79
|
+
if (!isDoubled(first, second)) return false;
|
|
80
|
+
if (spaced && isPartOfLongerWord(source, first, second)) return false;
|
|
81
|
+
const gap = gapBetween(source, first, second);
|
|
82
|
+
return gap === "space" || (gap === "markup" && FUNCTION_WORD.has(first.pos));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 文の途中で、大文字の語の後ろに小文字の同じ語が来たら、一つ目は名前(Payment for May may be delayed)。
|
|
87
|
+
* 文頭の The the と、冠詞・所有の語(review The the draft)は名前にならないので書き損じ。
|
|
88
|
+
*/
|
|
89
|
+
export const isNameBefore = (first: Token, second: Token, opensSentence: boolean): boolean =>
|
|
90
|
+
!opensSentence && !isDeterminer(first) && UPPER_START.test(first.surface) && LOWER_START.test(second.surface);
|
|
91
|
+
|
|
92
|
+
const opensSentence = (tokens: readonly Token[], at: number): boolean => !tokens.slice(0, at).some((token) => LETTER.test(token.surface));
|
|
93
|
+
|
|
94
|
+
const repeatsPrevious = (tokens: readonly Token[], at: number): boolean => {
|
|
95
|
+
const [previous, token] = [tokens[at - 1], tokens[at]];
|
|
96
|
+
return previous !== undefined && token !== undefined && surfacesOf([previous]) === surfacesOf([token]);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** 語彙表の重なり(had had)も、三つ続けば(had had had)書き損じ。 */
|
|
100
|
+
const allowedAt = (tokens: readonly Token[], at: number, allowed: Lexicon): Lexicon => (repeatsPrevious(tokens, at) ? [] : allowed);
|
|
101
|
+
|
|
102
|
+
export const doubledIn = (source: string, tokens: readonly Token[], spaced: boolean, allowed: Lexicon): Doubled[] =>
|
|
103
|
+
tokens.flatMap((second, index) => {
|
|
104
|
+
const first = tokens[index - 1];
|
|
105
|
+
if (first === undefined || !doubledAt(source, first, second, spaced)) return [];
|
|
106
|
+
if (isAllowed(tokens, index - 1, allowedAt(tokens, index - 1, allowed))) return [];
|
|
107
|
+
return isNameBefore(first, second, opensSentence(tokens, index - 1)) ? [] : [{ first, second }];
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** 印を挟んでいても(the [the)、見せるのは二語だけ。 */
|
|
111
|
+
const wordOf = (doubled: Doubled, spaced: boolean): string => [doubled.first.surface, doubled.second.surface].join(spaced ? " " : "");
|
|
112
|
+
|
|
113
|
+
const findingOf = (sentence: Sentence, doubled: Doubled, spaced: boolean): Finding => ({
|
|
114
|
+
rule: "doubled-word",
|
|
115
|
+
severity: "warning",
|
|
116
|
+
line: 0,
|
|
117
|
+
column: 0,
|
|
118
|
+
quote: sentence.text.trim(),
|
|
119
|
+
values: { word: wordOf(doubled, spaced), offset: doubled.second.span.start },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export const doubledWord: Detector = (doc, options): Finding[] => {
|
|
123
|
+
const spaced = doc.lengthUnit === "word";
|
|
124
|
+
const allowed = options.lexicon ?? [];
|
|
125
|
+
return doc.sentences.flatMap((sentence) =>
|
|
126
|
+
doubledIn(doc.source, sentence.tokens ?? [], spaced, allowed).map((doubled) => findingOf(sentence, doubled, spaced)),
|
|
127
|
+
);
|
|
128
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lengthOf } from "../measure.ts";
|
|
2
|
-
import
|
|
2
|
+
import { handsOver } from "./lead-in.ts";
|
|
3
|
+
import type { Detector, Finding, LengthUnit, ProseDocument, Section } from "../plugin.ts";
|
|
3
4
|
|
|
4
5
|
/** 見出しが短すぎると、偶然の一致で 100% になる。これ未満の見出しは見ない。 */
|
|
5
6
|
const MIN_GRAMS = 4;
|
|
@@ -51,9 +52,17 @@ const addsLittle = (section: Section, unit: LengthUnit): boolean => {
|
|
|
51
52
|
return lengthOf(first, unit) - headingUnits(section.heading, unit) <= NEW_MATERIAL[unit];
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
/** 最初の文が、同じ節の後ろ(箇条書き・表・コード)へ読者を渡している。 */
|
|
56
|
+
const leadsIn = (doc: ProseDocument, section: Section, phrases: readonly string[]): boolean => {
|
|
57
|
+
const first = section.firstSentence;
|
|
58
|
+
return first !== undefined && handsOver(first.text, doc.source.slice(first.span.end, section.span.end), phrases);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const headingEcho: Detector = (doc, options): Finding[] => {
|
|
62
|
+
const leadIns = (doc.lexicons["lead-in"] ?? []).map((entry) => entry.pattern);
|
|
63
|
+
return doc.sections
|
|
56
64
|
.filter((section) => section.heading.length > 0 && section.firstSentence !== undefined && addsLittle(section, doc.lengthUnit))
|
|
65
|
+
.filter((section) => !leadsIn(doc, section, leadIns))
|
|
57
66
|
.map((section) => ({ section, overlap: Math.round(containment(trigrams(section.heading), trigrams(section.firstSentence?.text ?? "")) * 100) }))
|
|
58
67
|
.filter(({ overlap }) => overlap >= options.limit)
|
|
59
68
|
.map(({ section, overlap }) => ({
|
|
@@ -64,3 +73,4 @@ export const headingEcho: Detector = (doc, options): Finding[] =>
|
|
|
64
73
|
quote: section.firstSentence?.text.trim() ?? "",
|
|
65
74
|
values: { heading: section.heading, count: overlap, limit: options.limit, offset: section.firstSentence?.span.start ?? section.span.start },
|
|
66
75
|
}));
|
|
76
|
+
};
|
package/src/detectors/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { repeatedHead } from "./repeated-head.ts";
|
|
|
6
6
|
import { sentenceRhythm } from "./sentence-rhythm.ts";
|
|
7
7
|
import { phraseMatch } from "./phrase-match.ts";
|
|
8
8
|
import { agentlessPassive } from "./agentless-passive.ts";
|
|
9
|
+
import { doubledWord } from "./doubled-word.ts";
|
|
9
10
|
import { sentenceEnding } from "./sentence-ending.ts";
|
|
10
11
|
import { doubledParticle, nounEnding } from "./token-shape.ts";
|
|
11
12
|
import { kanjiRun, middleDot } from "./char-shape.ts";
|
|
@@ -29,6 +30,7 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
|
|
|
29
30
|
"sentence-ending": sentenceEnding,
|
|
30
31
|
"noun-ending": nounEnding,
|
|
31
32
|
"doubled-particle": doubledParticle,
|
|
33
|
+
"doubled-word": doubledWord,
|
|
32
34
|
"kanji-run": kanjiRun,
|
|
33
35
|
"middle-dot": middleDot,
|
|
34
36
|
"adverb-density": adverbDensity,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 文末のコロン。後ろに強調の印(**・_)や空白が残っていても見る。 */
|
|
2
|
+
const ENDS_WITH_COLON = /[::][\s*_]*$/u;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 後ろの箇条書きや表へ読者を渡す文(「次のとおりとする。」「The following expenses require receipts:」)。
|
|
6
|
+
* 見出しの語を繰り返していても、中身は後ろにある。言い回しは言語パッケージの語彙表 lead-in が持つ。
|
|
7
|
+
*/
|
|
8
|
+
export const isLeadIn = (sentence: string, phrases: readonly string[]): boolean => {
|
|
9
|
+
const text = sentence.trim().toLowerCase();
|
|
10
|
+
return ENDS_WITH_COLON.test(text) || phrases.some((phrase) => phrase !== "" && text.includes(phrase.toLowerCase()));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** 渡す先(following: 同じ節の、その文より後ろ)が空なら、渡す文ではなくただの繰り返し。 */
|
|
14
|
+
export const handsOver = (sentence: string, following: string, phrases: readonly string[]): boolean => following.trim() !== "" && isLeadIn(sentence, phrases);
|
|
@@ -32,6 +32,16 @@ export const entryIn = (sentence: Sentence, entry: LexiconEntry): boolean => {
|
|
|
32
32
|
return tokens.some((_token, start) => runsAt(tokens, words, start));
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
export type TokenRange = { readonly start: number; readonly end: number };
|
|
36
|
+
|
|
37
|
+
/** 語彙の語が文のどの語の並びに当たったか(始まりと、終わった次の位置)。品詞が無ければ位置を言えないので空。 */
|
|
38
|
+
export const entryRanges = (sentence: Sentence, entry: LexiconEntry): TokenRange[] => {
|
|
39
|
+
const tokens = sentence.tokens;
|
|
40
|
+
const words = entry.tokens;
|
|
41
|
+
if (tokens === undefined || words === undefined || words.length === 0) return [];
|
|
42
|
+
return tokens.flatMap((_token, start) => (runsAt(tokens, words, start) ? [{ start, end: start + words.length }] : []));
|
|
43
|
+
};
|
|
44
|
+
|
|
35
45
|
const LEADING_MARK = new Set(["PUNCT", "SYM"]);
|
|
36
46
|
|
|
37
47
|
/** 語彙の語で文が始まるか。文頭の記号は飛ばす。品詞が無ければ文字列の前方一致。 */
|
package/src/detectors/lexicon.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { joinWords } from "./word-list.ts";
|
|
2
2
|
import { wordsOf } from "./structure.ts";
|
|
3
|
-
import type { Detector, Finding, Lexicon, ProseDocument, Sentence } from "../plugin.ts";
|
|
4
|
-
import { entryIn, entryOpens } from "./lexicon-match.ts";
|
|
3
|
+
import type { Detector, Finding, Lexicon, LexiconEntry, ProseDocument, Sentence } from "../plugin.ts";
|
|
4
|
+
import { entryIn, entryOpens, entryRanges } from "./lexicon-match.ts";
|
|
5
|
+
import { scoped, scopeMarkersOf, type ScopeMarkers } from "./superlative-scope.ts";
|
|
5
6
|
|
|
6
7
|
const PER = 1000;
|
|
7
8
|
|
|
@@ -38,14 +39,39 @@ const densityRule =
|
|
|
38
39
|
export const hedgingDensity = densityRule("excessive-hedging");
|
|
39
40
|
export const cushionDensity = densityRule("cushion-phrase-density");
|
|
40
41
|
|
|
42
|
+
/** 数字は語ではないので言語を問わない。数があれば測った結果を言っている。品詞の数(NUM)は "the best one" の one まで含むので使わない。 */
|
|
43
|
+
const DIGIT = /\d/u;
|
|
44
|
+
|
|
45
|
+
type Qualifiers = { readonly comparison: Lexicon; readonly scope: ScopeMarkers };
|
|
46
|
+
|
|
47
|
+
const qualifiersOf = (doc: ProseDocument): Qualifiers => ({
|
|
48
|
+
comparison: doc.lexicons["comparison-marker"] ?? [],
|
|
49
|
+
scope: scopeMarkersOf(doc.lexicons["superlative-scope"] ?? []),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** どの出現も範囲を持つときだけ。1 つでも範囲の無い出現があれば、その文には限定の無い最上級がある。 */
|
|
53
|
+
const everyScoped = (sentence: Sentence, entry: LexiconEntry, scope: ScopeMarkers): boolean => {
|
|
54
|
+
const ranges = entryRanges(sentence, entry);
|
|
55
|
+
return ranges.length > 0 && ranges.every((range) => scoped(sentence.tokens ?? [], range, scope));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const qualified = (sentence: Sentence, entry: LexiconEntry, qualifiers: Qualifiers): boolean =>
|
|
59
|
+
DIGIT.test(sentence.text) || qualifiers.comparison.some((marker) => entryIn(sentence, marker)) || everyScoped(sentence, entry, qualifiers.scope);
|
|
60
|
+
|
|
61
|
+
const bareHits = (doc: ProseDocument, lexicon: Lexicon): Hit[] => {
|
|
62
|
+
const qualifiers = qualifiersOf(doc);
|
|
63
|
+
return doc.sentences.flatMap((sentence) =>
|
|
64
|
+
lexicon.filter((entry) => entryIn(sentence, entry) && !qualified(sentence, entry, qualifiers)).map((entry) => ({ sentence, matched: entry.pattern })),
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
41
68
|
/**
|
|
42
69
|
* 限定のない最上級。「最も速い」だけでは、何と比べて最もなのかが無い。
|
|
43
|
-
*
|
|
70
|
+
* 同じ文に比較対象・条件・範囲(日本で最も、the best in the world)があれば、それは主張であって誇張ではない。
|
|
71
|
+
* 比較と範囲を言う語は言語パッケージの語彙表が持つ。
|
|
44
72
|
*/
|
|
45
|
-
const QUALIFIER = /\d|より|に比べ|のうち|among|than|compared|based on|according/iu;
|
|
46
|
-
|
|
47
73
|
export const unqualifiedSuperlative: Detector = (doc, options): Finding[] => {
|
|
48
|
-
const bare =
|
|
74
|
+
const bare = bareHits(doc, options.lexicon ?? []);
|
|
49
75
|
if (bare.length < options.limit) return [];
|
|
50
76
|
return bare.map((hit) => ({
|
|
51
77
|
rule: "unqualified-superlative",
|
package/src/detectors/signals.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { proseText } from "../measure.ts";
|
|
2
2
|
import { wordsOf } from "./structure.ts";
|
|
3
3
|
import { compacted, placeOf } from "./gram-place.ts";
|
|
4
|
+
import { notAcronymSpans } from "./acronym-context.ts";
|
|
5
|
+
import { expansionAt, type ExpandedAt } from "./acronym-expansion.ts";
|
|
4
6
|
import type { Detector, Finding, ProseDocument, Section, Sentence, Token } from "../plugin.ts";
|
|
5
7
|
|
|
6
8
|
const PER = 1000;
|
|
@@ -185,6 +187,7 @@ const spansOf = (text: string): Span[] => [
|
|
|
185
187
|
...[...text.matchAll(UNCASED_STRETCH)].filter((match) => SHOUTED_RUN.test(match[0])).map(spanOf),
|
|
186
188
|
...[...text.matchAll(IDENTIFIER)].filter((match) => /\d/u.test(match[0])).map(spanOf),
|
|
187
189
|
...[QUOTED_CAPS, REQUIREMENT_WORD, LICENCE].flatMap((pattern) => [...text.matchAll(pattern)].map(spanOf)),
|
|
190
|
+
...notAcronymSpans(text),
|
|
188
191
|
];
|
|
189
192
|
|
|
190
193
|
type AcronymHit = { readonly word: string; readonly hit: Hit };
|
|
@@ -197,132 +200,24 @@ const acronymsOf = (doc: ProseDocument): AcronymHit[] =>
|
|
|
197
200
|
.map((match) => ({ word: match[0], hit: { sentence, offset: sentence.span.start + match.index } }));
|
|
198
201
|
});
|
|
199
202
|
|
|
200
|
-
/** 読み手が説明なしで通じると見なしてよい語。展開すると逆に読みにくい。 */
|
|
201
|
-
const COMMON = new Set([
|
|
202
|
-
"OK",
|
|
203
|
-
"NG",
|
|
204
|
-
"URL",
|
|
205
|
-
"API",
|
|
206
|
-
"CSS",
|
|
207
|
-
"HTML",
|
|
208
|
-
"JSON",
|
|
209
|
-
"YAML",
|
|
210
|
-
"HTTP",
|
|
211
|
-
"HTTPS",
|
|
212
|
-
"PDF",
|
|
213
|
-
"CPU",
|
|
214
|
-
"GPU",
|
|
215
|
-
"RAM",
|
|
216
|
-
"USB",
|
|
217
|
-
"AI",
|
|
218
|
-
"ID",
|
|
219
|
-
"FAQ",
|
|
220
|
-
"PR",
|
|
221
|
-
"OS",
|
|
222
|
-
"CLI",
|
|
223
|
-
"UI",
|
|
224
|
-
"UX",
|
|
225
|
-
"SQL",
|
|
226
|
-
"CSV",
|
|
227
|
-
"XML",
|
|
228
|
-
"TODO",
|
|
229
|
-
"NOTE",
|
|
230
|
-
// 通信と符号化。技術文書でなくても説明なしで通じる。
|
|
231
|
-
"DNS",
|
|
232
|
-
"IP",
|
|
233
|
-
"TCP",
|
|
234
|
-
"UDP",
|
|
235
|
-
"SSH",
|
|
236
|
-
"SSL",
|
|
237
|
-
"TLS",
|
|
238
|
-
"VPN",
|
|
239
|
-
"LAN",
|
|
240
|
-
"UTF",
|
|
241
|
-
"ASCII",
|
|
242
|
-
// 機器・形式・単位。
|
|
243
|
-
"PC",
|
|
244
|
-
"IT",
|
|
245
|
-
"SDK",
|
|
246
|
-
"IDE",
|
|
247
|
-
"PNG",
|
|
248
|
-
"JPEG",
|
|
249
|
-
"GIF",
|
|
250
|
-
"SVG",
|
|
251
|
-
"QR",
|
|
252
|
-
"GPS",
|
|
253
|
-
"SNS",
|
|
254
|
-
"TV",
|
|
255
|
-
"KB",
|
|
256
|
-
"MB",
|
|
257
|
-
"GB",
|
|
258
|
-
"TB",
|
|
259
|
-
// 役職と国・地域。
|
|
260
|
-
"CEO",
|
|
261
|
-
"CTO",
|
|
262
|
-
"CFO",
|
|
263
|
-
"US",
|
|
264
|
-
"UK",
|
|
265
|
-
"EU",
|
|
266
|
-
"UN",
|
|
267
|
-
"DNA",
|
|
268
|
-
]);
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* 展開は略語の**すぐ隣**にあるときだけ認める。
|
|
272
|
-
* 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
|
|
273
|
-
*
|
|
274
|
-
* 認めるのは 3 つの形。どれも実際によく書かれる。
|
|
275
|
-
* CI(継続的インテグレーション) 略語のあとに括弧
|
|
276
|
-
* Continuous Integration (CI) 括弧の中が略語
|
|
277
|
-
* Tax Cuts and Jobs Act [TCJA] 角括弧の中が略語で、直前の語の頭文字と揃う
|
|
278
|
-
* 括弧と略語の間には、引用符((“MNDA”)、(「MNDA」))と空白だけを許す。
|
|
279
|
-
*/
|
|
280
|
-
const WRAP = String.raw`[\s"“”'‘’「」『』]*`;
|
|
281
|
-
const OPENS = new RegExp(String.raw`^${WRAP}[((]`, "u");
|
|
282
|
-
const CLOSES = new RegExp(String.raw`^${WRAP}[))]`, "u");
|
|
283
|
-
const OPENED = new RegExp(String.raw`[((]${WRAP}$`, "u");
|
|
284
|
-
const SQUARE_CLOSES = new RegExp(String.raw`^${WRAP}\]`, "u");
|
|
285
|
-
const SQUARE_OPENED = new RegExp(String.raw`\[${WRAP}$`, "u");
|
|
286
|
-
|
|
287
|
-
/** 括弧と略語の間の幅。空白は 1 つに畳んであるので、(“ MNDA ”) まで収まる。 */
|
|
288
|
-
const NEAR = 3;
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* 角括弧は引用の印にも使う([IANA]、[1])。直前の語のうち大文字で始まる語の頭文字が
|
|
292
|
-
* 略語と揃うときだけ展開と見なす。見る語は略語の文字数の 2 倍まで(and や of を挟むため)。
|
|
293
|
-
*/
|
|
294
|
-
const spellsOut = (before: string, acronym: string): boolean => {
|
|
295
|
-
const letters = acronym.replaceAll("&", "");
|
|
296
|
-
const words = before
|
|
297
|
-
.replace(SQUARE_OPENED, "")
|
|
298
|
-
.trim()
|
|
299
|
-
.split(/\s+/u)
|
|
300
|
-
.slice(-letters.length * 2);
|
|
301
|
-
const initials = words.filter((word) => /^[A-Z]/u.test(word)).map((word) => word.charAt(0));
|
|
302
|
-
return initials.join("").endsWith(letters);
|
|
303
|
-
};
|
|
304
|
-
|
|
305
|
-
const isExpandedAt = (body: string, acronym: string, at: number): boolean => {
|
|
306
|
-
const after = body.slice(at + acronym.length, at + acronym.length + NEAR);
|
|
307
|
-
const before = body.slice(Math.max(0, at - NEAR), at);
|
|
308
|
-
if (OPENS.test(after) || (OPENED.test(before) && CLOSES.test(after))) return true;
|
|
309
|
-
return SQUARE_OPENED.test(before) && SQUARE_CLOSES.test(after) && spellsOut(body.slice(0, at), acronym);
|
|
310
|
-
};
|
|
311
|
-
|
|
312
203
|
/**
|
|
313
204
|
* どこか 1 か所で展開してあればよい。初出が節の見出し代わりの語(「5.3. DPA.」)で、
|
|
314
205
|
* 展開がその直後の文にあることが契約書では普通にある。見るのは語として現れた所だけ(CISA の中の CI は見ない)。
|
|
315
206
|
*/
|
|
316
|
-
const isExpanded = (body: string, acronym: string): boolean =>
|
|
317
|
-
[...body.matchAll(new RegExp(`${EDGE_BEFORE}${acronym}${EDGE_AFTER}`, "gu"))].some((match) =>
|
|
207
|
+
const isExpanded = (body: string, acronym: string, expandedAt: ExpandedAt): boolean =>
|
|
208
|
+
[...body.matchAll(new RegExp(`${EDGE_BEFORE}${acronym}${EDGE_AFTER}`, "gu"))].some((match) => expandedAt(body, acronym, match.index));
|
|
209
|
+
|
|
210
|
+
const patternsOf = (doc: ProseDocument, id: string): string[] => (doc.lexicons[id] ?? []).map((entry) => entry.pattern);
|
|
318
211
|
|
|
319
212
|
export const undefinedAcronym: Detector = (doc, options): Finding[] => {
|
|
320
213
|
const body = bodyOf(doc);
|
|
214
|
+
const common = new Set((options.lexicon ?? []).map((entry) => entry.pattern));
|
|
321
215
|
const seen = new Map<string, Hit>();
|
|
322
216
|
acronymsOf(doc).forEach(({ word, hit }) => {
|
|
323
|
-
if (!
|
|
217
|
+
if (!common.has(word) && !seen.has(word)) seen.set(word, hit);
|
|
324
218
|
});
|
|
325
|
-
const
|
|
219
|
+
const expandedAt = expansionAt({ markers: patternsOf(doc, "definition-marker"), verbs: patternsOf(doc, "definition-verb") });
|
|
220
|
+
const bare = [...seen.entries()].filter(([acronym]) => !isExpanded(body, acronym, expandedAt));
|
|
326
221
|
if (bare.length < options.limit) return [];
|
|
327
222
|
return bare.map(([acronym, hit]) => ({
|
|
328
223
|
rule: "undefined-acronym",
|