chaffjs 0.8.0 → 0.9.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/address-chain.d.ts +9 -0
- package/dist/address-chain.d.ts.map +1 -0
- package/dist/address-chain.js +105 -0
- package/dist/address-chain.js.map +1 -0
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +1 -1
- package/dist/cli-args.js.map +1 -1
- package/dist/cli-text.d.ts.map +1 -1
- package/dist/cli-text.js +4 -0
- package/dist/cli-text.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +19 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +2 -1
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/feedback.d.ts +35 -0
- package/dist/commands/feedback.d.ts.map +1 -0
- package/dist/commands/feedback.js +157 -0
- package/dist/commands/feedback.js.map +1 -0
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +3 -2
- package/dist/commands/test.js.map +1 -1
- package/dist/commands/tree.d.ts.map +1 -1
- package/dist/commands/tree.js +3 -1
- package/dist/commands/tree.js.map +1 -1
- package/dist/config/by-path.d.ts +5 -1
- package/dist/config/by-path.d.ts.map +1 -1
- package/dist/config/by-path.js +1 -4
- package/dist/config/by-path.js.map +1 -1
- package/dist/config/load.d.ts +2 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +3 -1
- package/dist/config/load.js.map +1 -1
- package/dist/detectors/char-shape.d.ts.map +1 -1
- package/dist/detectors/char-shape.js +3 -2
- package/dist/detectors/char-shape.js.map +1 -1
- package/dist/document.d.ts +2 -2
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +4 -2
- package/dist/document.js.map +1 -1
- package/dist/feedback/draft.d.ts +28 -0
- package/dist/feedback/draft.d.ts.map +1 -0
- package/dist/feedback/draft.js +66 -0
- package/dist/feedback/draft.js.map +1 -0
- package/dist/feedback/excerpt.d.ts +10 -0
- package/dist/feedback/excerpt.d.ts.map +1 -0
- package/dist/feedback/excerpt.js +20 -0
- package/dist/feedback/excerpt.js.map +1 -0
- package/dist/legal-address.d.ts +2 -0
- package/dist/legal-address.d.ts.map +1 -0
- package/dist/legal-address.js +16 -0
- package/dist/legal-address.js.map +1 -0
- package/dist/orthography.d.ts.map +1 -1
- package/dist/orthography.js +15 -0
- package/dist/orthography.js.map +1 -1
- package/dist/plugin.d.ts +88 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/profile/for-file.d.ts +7 -0
- package/dist/profile/for-file.d.ts.map +1 -0
- package/dist/profile/for-file.js +6 -0
- package/dist/profile/for-file.js.map +1 -0
- package/dist/profile/load.d.ts +4 -0
- package/dist/profile/load.d.ts.map +1 -0
- package/dist/profile/load.js +25 -0
- package/dist/profile/load.js.map +1 -0
- package/dist/profile/parse.d.ts +19 -0
- package/dist/profile/parse.d.ts.map +1 -0
- package/dist/profile/parse.js +82 -0
- package/dist/profile/parse.js.map +1 -0
- package/dist/profile/select.d.ts +22 -0
- package/dist/profile/select.d.ts.map +1 -0
- package/dist/profile/select.js +21 -0
- package/dist/profile/select.js.map +1 -0
- package/dist/reasons.d.ts +1 -0
- package/dist/reasons.d.ts.map +1 -1
- package/dist/reasons.js +2 -0
- package/dist/reasons.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +24 -3
- package/dist/run.js.map +1 -1
- package/dist/structure/build.d.ts +2 -1
- package/dist/structure/build.d.ts.map +1 -1
- package/dist/structure/build.js +94 -7
- package/dist/structure/build.js.map +1 -1
- package/dist/structure/issues.d.ts.map +1 -1
- package/dist/structure/issues.js +9 -2
- package/dist/structure/issues.js.map +1 -1
- package/dist/structure/of.d.ts +2 -1
- package/dist/structure/of.d.ts.map +1 -1
- package/dist/structure/of.js +7 -1
- package/dist/structure/of.js.map +1 -1
- package/dist/structure/relative-find.d.ts +6 -0
- package/dist/structure/relative-find.d.ts.map +1 -0
- package/dist/structure/relative-find.js +179 -0
- package/dist/structure/relative-find.js.map +1 -0
- package/dist/structure/relative-resolve.d.ts +3 -0
- package/dist/structure/relative-resolve.d.ts.map +1 -0
- package/dist/structure/relative-resolve.js +175 -0
- package/dist/structure/relative-resolve.js.map +1 -0
- package/dist/structure/unread.d.ts +8 -0
- package/dist/structure/unread.d.ts.map +1 -0
- package/dist/structure/unread.js +34 -0
- package/dist/structure/unread.js.map +1 -0
- package/package.json +4 -3
- package/profiles/statute.yaml +62 -0
- package/skills/chaff/SKILL.md +24 -4
- package/src/address-chain.ts +113 -0
- package/src/cli-args.ts +1 -1
- package/src/cli-text.ts +4 -0
- package/src/cli.ts +20 -1
- package/src/commands/eval.ts +2 -1
- package/src/commands/feedback.ts +210 -0
- package/src/commands/test.ts +3 -2
- package/src/commands/tree.ts +3 -1
- package/src/config/by-path.ts +11 -3
- package/src/config/load.ts +5 -1
- package/src/detectors/char-shape.ts +5 -4
- package/src/document.ts +11 -3
- package/src/feedback/draft.ts +102 -0
- package/src/feedback/excerpt.ts +23 -0
- package/src/orthography.ts +15 -0
- package/src/plugin.ts +82 -0
- package/src/profile/for-file.ts +11 -0
- package/src/profile/load.ts +26 -0
- package/src/profile/parse.ts +106 -0
- package/src/profile/select.ts +35 -0
- package/src/reasons.ts +5 -0
- package/src/run.ts +25 -3
- package/src/structure/build.ts +118 -10
- package/src/structure/issues.ts +10 -2
- package/src/structure/of.ts +15 -3
- package/src/structure/relative-find.ts +224 -0
- package/src/structure/relative-resolve.ts +209 -0
- package/src/structure/unread.ts +39 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { DocumentProfile, Mention, RelativeVocabulary, Span } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 前条・次項・同号・本条・前各項・前二項・前条第二項、条を書かない「第一項」を行の中から探す。語は文書の種類(profiles/*.yaml)が決める。
|
|
5
|
+
* ここでは番地を決めず、何をどれだけ指すかだけを attrs に残す。番地は木ができてから relative-resolve.ts が決める。
|
|
6
|
+
*/
|
|
7
|
+
type RelativeWay = "before" | "after" | "same" | "current" | "continue";
|
|
8
|
+
|
|
9
|
+
type NumberReader = (text: string) => number | undefined;
|
|
10
|
+
|
|
11
|
+
const escape = (word: string): string => word.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
12
|
+
|
|
13
|
+
const oneOf = (words: readonly string[]): string => (words.length === 0 ? "(?!)" : words.map(escape).join("|"));
|
|
14
|
+
|
|
15
|
+
type Compiled = {
|
|
16
|
+
readonly named: RegExp;
|
|
17
|
+
readonly bare: RegExp | undefined;
|
|
18
|
+
readonly suffix: RegExp;
|
|
19
|
+
readonly notAfter: RegExp | undefined;
|
|
20
|
+
readonly joins: RegExp;
|
|
21
|
+
readonly ways: ReadonlyMap<string, RelativeWay>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const waysOf = (vocabulary: RelativeVocabulary): Map<string, RelativeWay> =>
|
|
25
|
+
new Map<string, RelativeWay>([
|
|
26
|
+
...vocabulary.before.map((word): [string, RelativeWay] => [word, "before"]),
|
|
27
|
+
...vocabulary.after.map((word): [string, RelativeWay] => [word, "after"]),
|
|
28
|
+
...vocabulary.same.map((word): [string, RelativeWay] => [word, "same"]),
|
|
29
|
+
...vocabulary.current.map((word): [string, RelativeWay] => [word, "current"]),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** 後ろの決まり: 番地の並びと同じく、つなぎの語を挟んで address_end。「前条件」の「前条」を参照にしない。 */
|
|
33
|
+
const endOf = (profile: DocumentProfile): string => {
|
|
34
|
+
const connectives = profile.connectives.length === 0 ? "" : `(?:${oneOf(profile.connectives)})*`;
|
|
35
|
+
return profile.addressEnd === undefined ? "" : `(?=${connectives}(?:${profile.addressEnd}))`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const compile = (profile: DocumentProfile, vocabulary: RelativeVocabulary): Compiled => {
|
|
39
|
+
const units = oneOf(Object.keys(vocabulary.units));
|
|
40
|
+
const suffix = `${escape(vocabulary.suffixPrefix)}(?:${vocabulary.count})(?:${units})`;
|
|
41
|
+
const end = endOf(profile);
|
|
42
|
+
const start = vocabulary.notAfter === undefined ? "" : `(?<!${vocabulary.notAfter})`;
|
|
43
|
+
const way = oneOf([...vocabulary.before, ...vocabulary.after, ...vocabulary.same, ...vocabulary.current]);
|
|
44
|
+
const count = `${oneOf(vocabulary.every)}|${vocabulary.count}`;
|
|
45
|
+
const inside = `${escape(vocabulary.suffixPrefix)}(?:${vocabulary.count})(?:${oneOf(vocabulary.inside)})`;
|
|
46
|
+
return {
|
|
47
|
+
named: new RegExp(`${start}(?<way>${way})(?<count>${count})?(?<unit>${units})(?<suffix>(?:${suffix})*)${end}`, "gu"),
|
|
48
|
+
// 前の文字は見ない。語に続くもの(別表第一第一号)も見つけて、並びの続きを止めるのに使う。
|
|
49
|
+
bare: vocabulary.inside.length === 0 ? undefined : new RegExp(`(?<suffix>${inside}(?:${suffix})*)${end}`, "gu"),
|
|
50
|
+
suffix: new RegExp(`${escape(vocabulary.suffixPrefix)}(?<n>${vocabulary.count})(?<unit>${units})`, "gu"),
|
|
51
|
+
notAfter: vocabulary.notAfter === undefined ? undefined : new RegExp(`(?:${vocabulary.notAfter})$`, "u"),
|
|
52
|
+
joins: new RegExp(`(?:${[...vocabulary.joiners, ...profile.connectives.map(escape)].join("|") || "(?!)"})`, "gu"),
|
|
53
|
+
ways: waysOf(vocabulary),
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const compiled = new WeakMap<DocumentProfile, Compiled>();
|
|
58
|
+
|
|
59
|
+
/** 「第二項第三号」を「2:2,3:3」(深さ:番号)にする。読めない数があれば undefined。 */
|
|
60
|
+
const suffixOf = (text: string, patterns: Compiled, vocabulary: RelativeVocabulary, number: NumberReader): string | undefined => {
|
|
61
|
+
const parts = [...text.matchAll(patterns.suffix)].map((match) => {
|
|
62
|
+
const value = number(match.groups?.["n"] ?? "");
|
|
63
|
+
const depth = vocabulary.units[match.groups?.["unit"] ?? ""];
|
|
64
|
+
return value === undefined || depth === undefined ? undefined : `${String(depth)}:${String(value)}`;
|
|
65
|
+
});
|
|
66
|
+
return parts.includes(undefined) ? undefined : parts.join(",");
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** 「各」は前の全部(0)、数が無ければ 1。 */
|
|
70
|
+
const countOf = (count: string | undefined, vocabulary: RelativeVocabulary, number: NumberReader): number | undefined => {
|
|
71
|
+
if (count === undefined) return 1;
|
|
72
|
+
return vocabulary.every.includes(count) ? 0 : number(count);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type Substitution = NonNullable<RelativeVocabulary["substitution"]>;
|
|
76
|
+
|
|
77
|
+
/** 文字と、その位置(UTF-16 の単位で)。絵文字のような 2 単位の文字も 1 文字として読む。 */
|
|
78
|
+
const characters = (text: string): { readonly char: string; readonly at: number }[] => {
|
|
79
|
+
const found: { char: string; at: number }[] = [];
|
|
80
|
+
let at = 0;
|
|
81
|
+
for (const char of text) {
|
|
82
|
+
found.push({ char, at });
|
|
83
|
+
at += char.length;
|
|
84
|
+
}
|
|
85
|
+
return found;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** 一番外側の括弧の範囲。閉じの無い開きは捨てる。 */
|
|
89
|
+
const outerQuotes = (text: string, quote: Substitution): Span[] => {
|
|
90
|
+
const spans: Span[] = [];
|
|
91
|
+
const opens: number[] = [];
|
|
92
|
+
characters(text).forEach(({ char, at }) => {
|
|
93
|
+
if (char === quote.open) opens.push(at);
|
|
94
|
+
const start = char === quote.close ? opens.pop() : undefined;
|
|
95
|
+
if (start !== undefined && opens.length === 0) spans.push({ start, end: at + char.length });
|
|
96
|
+
});
|
|
97
|
+
return spans;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** 読み替えの括弧(閉じの直後か開きの直前に目印があるもの)の範囲。 */
|
|
101
|
+
const substitutionSpans = (text: string, quote: Substitution | undefined): Span[] =>
|
|
102
|
+
quote === undefined
|
|
103
|
+
? []
|
|
104
|
+
: outerQuotes(text, quote).filter(
|
|
105
|
+
(span) => quote.after.some((marker) => text.startsWith(marker, span.end)) || quote.before.some((marker) => text.slice(0, span.start).endsWith(marker)),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/** 閉じた括弧書きを内側から外す。 */
|
|
109
|
+
const withoutAsides = (gap: string, aside: RelativeVocabulary["aside"]): string => {
|
|
110
|
+
if (aside === undefined) return gap;
|
|
111
|
+
const inner = new RegExp(`${escape(aside.open)}[^${escape(aside.open)}${escape(aside.close)}]*${escape(aside.close)}`, "gu");
|
|
112
|
+
const once = gap.replace(inner, "");
|
|
113
|
+
return once === gap ? gap : withoutAsides(once, aside);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** あいだが、並びをつなぐもの・つなぎの語・閉じた括弧書き・空白だけか。 */
|
|
117
|
+
const onlyJoins = (gap: string, patterns: Compiled, vocabulary: RelativeVocabulary): boolean =>
|
|
118
|
+
withoutAsides(gap, vocabulary.aside).replace(patterns.joins, "").trim() === "";
|
|
119
|
+
|
|
120
|
+
const overlaps = (start: number, end: number, others: readonly Mention[]): boolean => others.some((other) => start < other.end && other.start < end);
|
|
121
|
+
|
|
122
|
+
/** 行き先を決められない番地。並びの続きを止めるためだけに覚え、参照にはしない。 */
|
|
123
|
+
const OPAQUE = "opaque";
|
|
124
|
+
|
|
125
|
+
/** すぐ前が not_after に当たる(「別表第一」の「一」に続く「第一号」)。 */
|
|
126
|
+
const glued = (text: string, start: number, patterns: Compiled): boolean => patterns.notAfter?.test(text.slice(0, start)) === true;
|
|
127
|
+
|
|
128
|
+
type Bare = { readonly start: number; readonly end: number; readonly label: string; readonly suffix: string; readonly first: number };
|
|
129
|
+
|
|
130
|
+
const bareCandidates = (text: string, patterns: Compiled, vocabulary: RelativeVocabulary, number: NumberReader): Bare[] =>
|
|
131
|
+
patterns.bare === undefined
|
|
132
|
+
? []
|
|
133
|
+
: [...text.matchAll(patterns.bare)].flatMap((match) => {
|
|
134
|
+
const suffix = suffixOf(match.groups?.["suffix"] ?? "", patterns, vocabulary, number);
|
|
135
|
+
const first = Number(suffix?.split(":")[0]);
|
|
136
|
+
return suffix === undefined || !Number.isInteger(first)
|
|
137
|
+
? []
|
|
138
|
+
: [{ start: match.index, end: match.index + match[0].length, label: match[0], suffix, first }];
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** 並びの中での扱い: 前の参照の続きか、今いるところの中か、行き先を決められないか。 */
|
|
142
|
+
type BareWay = { readonly way: string; readonly continues?: Mention | undefined };
|
|
143
|
+
|
|
144
|
+
/** 並びの中での扱い: 前の参照の続きか、今いるところの中か、行き先を決められないか。続きなら、どの参照の続きか。 */
|
|
145
|
+
const wayOfBare = (text: string, bare: Bare, known: readonly Mention[], patterns: Compiled, vocabulary: RelativeVocabulary): BareWay => {
|
|
146
|
+
if (glued(text, bare.start, patterns)) return { way: OPAQUE };
|
|
147
|
+
const depth = openAsides(text, bare.start, vocabulary).length;
|
|
148
|
+
const sameDepth = known.filter((other) => other.end <= bare.start && openAsides(text, other.end, vocabulary).length === depth);
|
|
149
|
+
const previous = sameDepth.toSorted((left, right) => left.end - right.end).at(-1);
|
|
150
|
+
const joined = previous !== undefined && onlyJoins(text.slice(previous.end, bare.start), patterns, vocabulary) ? previous : undefined;
|
|
151
|
+
const continues = joined ?? ownerOfAside(text, bare.start, known, vocabulary);
|
|
152
|
+
if (continues === undefined) return { way: "current" };
|
|
153
|
+
return continues.attrs["relative"] === OPAQUE ? { way: OPAQUE } : { way: "continue", continues };
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** at の位置で開いたままの括弧書きの開きの位置。並びは同じ深さの参照どうしでつながる。 */
|
|
157
|
+
const openAsides = (text: string, at: number, vocabulary: RelativeVocabulary): number[] => {
|
|
158
|
+
const aside = vocabulary.aside;
|
|
159
|
+
const opens: number[] = [];
|
|
160
|
+
if (aside === undefined) return opens;
|
|
161
|
+
characters(text.slice(0, at)).forEach(({ char, at: position }) => {
|
|
162
|
+
if (char === aside.open) opens.push(position);
|
|
163
|
+
if (char === aside.close) opens.pop();
|
|
164
|
+
});
|
|
165
|
+
return opens;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 参照の直後に開いた括弧書きの中なら、その参照。「第二十七条(第四項を除き、第五項及び第六項の規定を…)」の番地は、どれも第二十七条の中。
|
|
170
|
+
*/
|
|
171
|
+
const ownerOfAside = (text: string, at: number, known: readonly Mention[], vocabulary: RelativeVocabulary): Mention | undefined => {
|
|
172
|
+
const open = openAsides(text, at, vocabulary).at(-1);
|
|
173
|
+
return open === undefined ? undefined : known.find((other) => other.end === open);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 条を書かない「第一項」。すぐ前の参照とのあいだがつなぎの語だけなら、その参照の続き(continue)。
|
|
178
|
+
* そうでなければ、書いた場所を含む一つ上のまとまり(current)の中。
|
|
179
|
+
*/
|
|
180
|
+
const bareMentions = (text: string, patterns: Compiled, vocabulary: RelativeVocabulary, number: NumberReader, before: readonly Mention[]): Mention[] => {
|
|
181
|
+
const found: Mention[] = [];
|
|
182
|
+
bareCandidates(text, patterns, vocabulary, number).forEach((bare) => {
|
|
183
|
+
const known = [...before, ...found];
|
|
184
|
+
if (overlaps(bare.start, bare.end, known)) return;
|
|
185
|
+
const { way, continues } = wayOfBare(text, bare, known, patterns, vocabulary);
|
|
186
|
+
// 条を書かない番地は、その一つ上(level)ではなく、書いた単位(level + 1)から名指しする。「第一項」は「同条」の行き先を変えない。
|
|
187
|
+
// continues は、続きの元の参照がどれだけ前から始まるか。木の中で、その参照を位置で引く。
|
|
188
|
+
const back = continues === undefined ? {} : { continues: bare.start - continues.start };
|
|
189
|
+
const attrs = { label: bare.label, relative: way, level: bare.first - 1, count: 1, suffix: bare.suffix, names: bare.first, ...back };
|
|
190
|
+
found.push({ start: bare.start, end: bare.end, attrs });
|
|
191
|
+
});
|
|
192
|
+
return found.filter((mention) => mention.attrs["relative"] !== OPAQUE);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const namedMentions = (text: string, patterns: Compiled, vocabulary: RelativeVocabulary, number: NumberReader): Mention[] =>
|
|
196
|
+
[...text.matchAll(patterns.named)].flatMap((match) => {
|
|
197
|
+
const groups = match.groups ?? {};
|
|
198
|
+
const way = patterns.ways.get(groups["way"] ?? "");
|
|
199
|
+
const level = vocabulary.units[groups["unit"] ?? ""];
|
|
200
|
+
const count = countOf(groups["count"], vocabulary, number);
|
|
201
|
+
const suffix = suffixOf(groups["suffix"] ?? "", patterns, vocabulary, number);
|
|
202
|
+
if (way === undefined || level === undefined || count === undefined || suffix === undefined) return [];
|
|
203
|
+
return [{ start: match.index, end: match.index + match[0].length, attrs: { label: match[0], relative: way, level, count, suffix } }];
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/** absolute は同じ行の番地を名指しした参照。条を書かない番地が、その続きかどうかを決めるのに使う。 */
|
|
207
|
+
export const relativeMentions = (
|
|
208
|
+
text: string,
|
|
209
|
+
profile: DocumentProfile | undefined,
|
|
210
|
+
number: NumberReader | undefined,
|
|
211
|
+
absolute: readonly Mention[] = [],
|
|
212
|
+
): Mention[] => {
|
|
213
|
+
const vocabulary = profile?.relative;
|
|
214
|
+
if (profile === undefined || vocabulary === undefined || number === undefined) return [];
|
|
215
|
+
if (!compiled.has(profile)) compiled.set(profile, compile(profile, vocabulary));
|
|
216
|
+
const patterns = compiled.get(profile);
|
|
217
|
+
if (patterns === undefined) return [];
|
|
218
|
+
const substituted = substitutionSpans(text, vocabulary.substitution);
|
|
219
|
+
const named = namedMentions(text, patterns, vocabulary, number);
|
|
220
|
+
const bare = bareMentions(text, patterns, vocabulary, number, [...absolute, ...named]);
|
|
221
|
+
return [...named, ...bare]
|
|
222
|
+
.filter((mention) => !substituted.some((span) => span.start <= mention.start && mention.start < span.end))
|
|
223
|
+
.sort((left, right) => left.start - right.start);
|
|
224
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { StructureNode } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 相対の参照(relative-find.ts が見つけたもの)に番地を与える。木ができてからでないと「次条」は決まらないので、ここは木を読むだけの後段。
|
|
5
|
+
* 前・次は、書いた場所を含む同じ深さのまとまりの前後。条は文書を通して並ぶので文書全体、項と号は同じ親の中で数える。
|
|
6
|
+
* 同は、その前の参照(相対のものも含む)の番地を、その深さまで切ったもの。他の文書を指していれば、それも引き継ぐ。
|
|
7
|
+
* 番地を決められないもの(最初の条の「前条」、何も引いていない「同条」)は木から外す。当て推量の番地で誤りを言わない。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** range は「第四十三条から第五十五条まで 削除」のように、一行で並びをまとめたもの。番地は最初のものしか持たない。 */
|
|
11
|
+
type Place = { readonly address: string; readonly level: number; readonly range?: boolean };
|
|
12
|
+
|
|
13
|
+
type Last = { readonly target: string; readonly document: string | undefined };
|
|
14
|
+
|
|
15
|
+
const NUMBERED = new Set(["chapter", "article", "item"]);
|
|
16
|
+
|
|
17
|
+
const placeOf = (node: StructureNode): Place | undefined =>
|
|
18
|
+
NUMBERED.has(node.kind) && node.level !== undefined ? { address: node.address, level: node.level, range: node.ordinalTo !== undefined } : undefined;
|
|
19
|
+
|
|
20
|
+
/** 番号の付いた子。Markdown の見出し(section)は番号の外なので、その中まで見る。 */
|
|
21
|
+
const numberedChildren = (node: StructureNode, level: number): StructureNode[] =>
|
|
22
|
+
node.children.flatMap((child) => {
|
|
23
|
+
if (NUMBERED.has(child.kind)) return child.level === level ? [child] : [];
|
|
24
|
+
return child.kind === "section" ? numberedChildren(child, level) : [];
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
type Context = {
|
|
28
|
+
readonly articles: readonly Place[];
|
|
29
|
+
/** 条の深さ。同の番地を切るときの起点。 */
|
|
30
|
+
readonly articleLevel: number | undefined;
|
|
31
|
+
/** 最初のものに番号を振らない深さ(法令の項)。 */
|
|
32
|
+
readonly implicitLevel: number | undefined;
|
|
33
|
+
/** 深さごとに、最後に名指しされた番地。「前項」は項だけを名指しするので、「同条」の行き先を変えない。 */
|
|
34
|
+
readonly last: Map<number, Last>;
|
|
35
|
+
/** 参照を、始まる位置で引けるようにする。「若しくは第九項」のような並びの続きが、元の参照を引く。 */
|
|
36
|
+
readonly byStart: Map<number, Last>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** 同じ親の下で同じ深さのもの。番号の無い最初のもの(法令の第 1 項)があるなら、それを先頭に足す。 */
|
|
40
|
+
const siblingsUnder = (parent: StructureNode, level: number, implicitLevel: number | undefined): Place[] => {
|
|
41
|
+
const children = numberedChildren(parent, level);
|
|
42
|
+
const explicit = children.flatMap((child) => {
|
|
43
|
+
const place = placeOf(child);
|
|
44
|
+
return place === undefined ? [] : [place];
|
|
45
|
+
});
|
|
46
|
+
const firstOrdinal = children[0]?.ordinal;
|
|
47
|
+
const implicit = level === implicitLevel && parent.level === level - 1 && firstOrdinal !== 1;
|
|
48
|
+
return implicit ? [{ address: `${parent.address}.1`, level }, ...explicit] : explicit;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Anchor = { readonly place: Place; readonly siblings: readonly Place[] };
|
|
52
|
+
|
|
53
|
+
/** 書いた場所を含む、その深さのまとまり。第 1 項の本文にいて項が開いていなければ、番号の無い第 1 項。 */
|
|
54
|
+
const anchorAt = (path: readonly StructureNode[], level: number, context: Context): Anchor | undefined => {
|
|
55
|
+
const index = path.findLastIndex((node) => NUMBERED.has(node.kind) && node.level === level);
|
|
56
|
+
const node = path[index];
|
|
57
|
+
if (node !== undefined) {
|
|
58
|
+
const place = placeOf(node);
|
|
59
|
+
// 親は番号の付いた一つ上のまとまり。あいだの Markdown の見出しは飛ばす。無ければ文書(条より外のまとまり)。
|
|
60
|
+
const parent = path.slice(0, index).findLast((candidate) => NUMBERED.has(candidate.kind) && (candidate.level ?? 0) < level) ?? path[0];
|
|
61
|
+
if (place === undefined || parent === undefined) return undefined;
|
|
62
|
+
return { place, siblings: node.kind === "article" ? context.articles : siblingsUnder(parent, level, context.implicitLevel) };
|
|
63
|
+
}
|
|
64
|
+
const parent = path.findLast((candidate) => NUMBERED.has(candidate.kind) && candidate.level === level - 1);
|
|
65
|
+
if (level !== context.implicitLevel || parent === undefined) return undefined;
|
|
66
|
+
const siblings = siblingsUnder(parent, level, context.implicitLevel);
|
|
67
|
+
const first = siblings[0];
|
|
68
|
+
return first === undefined ? undefined : { place: first, siblings };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* by だけ前後にずらす。範囲の行(第四十三条から第五十五条まで 削除)は中の条を一つずつ持たないので、数えてまたげば行き先がずれる。
|
|
73
|
+
* 前に数えて範囲をまたぐか範囲に当たる、後ろに数えて範囲をまたぐなら決めない。後ろに数えて範囲に当たるのは、その最初の条なので正しい。
|
|
74
|
+
*/
|
|
75
|
+
const shifted = (anchor: Anchor, by: number): Place | undefined => {
|
|
76
|
+
const index = anchor.siblings.findIndex((place) => place.address === anchor.place.address);
|
|
77
|
+
if (index === -1) return undefined;
|
|
78
|
+
const crossed = by > 0 ? anchor.siblings.slice(index + 1, index + by) : anchor.siblings.slice(Math.max(0, index + by), index);
|
|
79
|
+
return crossed.some((place) => place.range === true) ? undefined : anchor.siblings[index + by];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** 前の全部(前各項)は、前に一つでもあれば最初のもの。 */
|
|
83
|
+
const allBefore = (anchor: Anchor): Place | undefined => (shifted(anchor, -1) === undefined ? undefined : anchor.siblings[0]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 前・次・本で決まる場所。「各」(count 0)は前の全部なので、最初のものを指す。
|
|
87
|
+
*/
|
|
88
|
+
const byPosition = (way: string, count: number, anchor: Anchor): Place | undefined => {
|
|
89
|
+
if (way === "current") return anchor.place;
|
|
90
|
+
if (way === "after") return shifted(anchor, Math.max(count, 1));
|
|
91
|
+
return count === 0 ? allBefore(anchor) : shifted(anchor, -count);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** 同: その前の参照の番地を、この深さまで切る。条より外のまとまりは番地の形が違うので扱わない。 */
|
|
95
|
+
const bySame = (level: number, last: Last | undefined): Place | undefined => (last === undefined ? undefined : { address: last.target, level });
|
|
96
|
+
|
|
97
|
+
/** 並びの続き: 元の参照の番地を、この深さまで切る。元の参照の番地が決まらなければ決めない。 */
|
|
98
|
+
const continued = (level: number, node: StructureNode, context: Context): Last | undefined => {
|
|
99
|
+
const origin = context.byStart.get(node.span.start - Number(node.attrs["continues"]));
|
|
100
|
+
if (origin === undefined || context.articleLevel === undefined || level < context.articleLevel) return undefined;
|
|
101
|
+
const parts = origin.target.split(".");
|
|
102
|
+
const kept = level - context.articleLevel + 1;
|
|
103
|
+
return parts.length < kept ? undefined : { target: parts.slice(0, kept).join("."), document: origin.document };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 参照が名指しした深さを覚える。名指しは、書き出しの深さ(第〇条なら条、前項なら項)から番地の深さまで。
|
|
108
|
+
* 番地を深さで切る起点は条。条より外のまとまりは番地の形が違うので覚えない。
|
|
109
|
+
*/
|
|
110
|
+
const remember = (context: Context, target: string, document: string | undefined, from: number): void => {
|
|
111
|
+
const articleLevel = context.articleLevel;
|
|
112
|
+
// 章・節は番地の形(ch1)が条から始まらないので覚えない。覚えると「同条」が章を指してしまう。
|
|
113
|
+
if (articleLevel === undefined || from < articleLevel) return;
|
|
114
|
+
const parts = target.split(".");
|
|
115
|
+
parts.forEach((_, index) => {
|
|
116
|
+
const level = articleLevel + index;
|
|
117
|
+
if (level >= from) context.last.set(level, { target: parts.slice(0, index + 1).join("."), document });
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** 「第二項第三号」を足す。一つ深いものは続け、二つ深い号は番号の無い第 1 項の中(x.1.n)に置く。 */
|
|
122
|
+
const withSuffix = (place: Place, suffix: string, implicitLevel: number | undefined): Place | undefined =>
|
|
123
|
+
suffix
|
|
124
|
+
.split(",")
|
|
125
|
+
.filter((part) => part !== "")
|
|
126
|
+
.reduce<Place | undefined>((current, part) => {
|
|
127
|
+
const [depth, value] = part.split(":").map(Number);
|
|
128
|
+
if (current === undefined || depth === undefined || value === undefined) return undefined;
|
|
129
|
+
if (depth === current.level + 1) return { address: `${current.address}.${String(value)}`, level: depth };
|
|
130
|
+
if (implicitLevel === current.level + 1 && depth === current.level + 2) return { address: `${current.address}.1.${String(value)}`, level: depth };
|
|
131
|
+
return undefined;
|
|
132
|
+
}, place);
|
|
133
|
+
|
|
134
|
+
/** 番号の無い第 1 項(x.1)は、条そのものでもある。 */
|
|
135
|
+
const fallbackOf = (place: Place, implicitLevel: number | undefined): string | undefined =>
|
|
136
|
+
place.level === implicitLevel && place.address.endsWith(".1") ? place.address.slice(0, -".1".length) : undefined;
|
|
137
|
+
|
|
138
|
+
const baseOf = (way: string, node: StructureNode, anchor: Anchor | undefined, last: Last | undefined, context: Context): Place | undefined => {
|
|
139
|
+
const level = Number(node.attrs["level"]);
|
|
140
|
+
if (way === "same") return bySame(level, last);
|
|
141
|
+
if (way === "continue") {
|
|
142
|
+
const origin = continued(level, node, context);
|
|
143
|
+
return origin === undefined ? undefined : { address: origin.target, level };
|
|
144
|
+
}
|
|
145
|
+
return anchor === undefined ? undefined : byPosition(way, Number(node.attrs["count"]), anchor);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** 同と並びの続きは、元の参照が他の文書を指していれば、それを引き継ぐ。前・次・本はこの文書の中。 */
|
|
149
|
+
const documentOf = (way: string, node: StructureNode, last: Last | undefined, context: Context): string | undefined => {
|
|
150
|
+
if (way === "same") return last?.document;
|
|
151
|
+
return way === "continue" ? continued(Number(node.attrs["level"]), node, context)?.document : undefined;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const resolveOne = (node: StructureNode, path: readonly StructureNode[], context: Context): StructureNode | undefined => {
|
|
155
|
+
const way = String(node.attrs["relative"]);
|
|
156
|
+
const level = Number(node.attrs["level"]);
|
|
157
|
+
const last = context.last.get(level);
|
|
158
|
+
const anchor = way === "same" || way === "continue" ? undefined : anchorAt(path, level, context);
|
|
159
|
+
const base = baseOf(way, node, anchor, last, context);
|
|
160
|
+
const place = base === undefined ? undefined : withSuffix(base, String(node.attrs["suffix"] ?? ""), context.implicitLevel);
|
|
161
|
+
if (place === undefined) return undefined;
|
|
162
|
+
const fallback = fallbackOf(place, context.implicitLevel);
|
|
163
|
+
const document = documentOf(way, node, last, context);
|
|
164
|
+
const attrs = {
|
|
165
|
+
target: place.address,
|
|
166
|
+
label: String(node.attrs["label"]),
|
|
167
|
+
...(fallback === undefined ? {} : { fallback }),
|
|
168
|
+
...(document === undefined ? {} : { document }),
|
|
169
|
+
};
|
|
170
|
+
return { ...node, attrs };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const isRelative = (node: StructureNode): boolean => node.kind === "reference" && node.attrs["relative"] !== undefined;
|
|
174
|
+
|
|
175
|
+
/** 文書の順に読み、参照ごとに「その前の参照」を覚える。 */
|
|
176
|
+
const walk = (node: StructureNode, path: readonly StructureNode[], context: Context): StructureNode | undefined => {
|
|
177
|
+
if (isRelative(node)) {
|
|
178
|
+
const resolved = resolveOne(node, path, context);
|
|
179
|
+
const target = resolved === undefined ? undefined : String(resolved.attrs["target"]);
|
|
180
|
+
const document = optional(resolved?.attrs["document"]);
|
|
181
|
+
if (target !== undefined) context.byStart.set(node.span.start, { target, document });
|
|
182
|
+
if (target !== undefined) remember(context, target, document, Number(node.attrs["names"] ?? node.attrs["level"]));
|
|
183
|
+
return resolved;
|
|
184
|
+
}
|
|
185
|
+
if (node.kind === "reference") {
|
|
186
|
+
const target = String(node.attrs["target"]);
|
|
187
|
+
const document = optional(node.attrs["document"]);
|
|
188
|
+
context.byStart.set(node.span.start, { target, document });
|
|
189
|
+
remember(context, target, document, context.articleLevel ?? 0);
|
|
190
|
+
}
|
|
191
|
+
const inside = [...path, node];
|
|
192
|
+
const children = node.children.flatMap((child) => {
|
|
193
|
+
const kept = walk(child, inside, context);
|
|
194
|
+
return kept === undefined ? [] : [kept];
|
|
195
|
+
});
|
|
196
|
+
return { ...node, children };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const optional = (value: string | number | undefined): string | undefined => (value === undefined ? undefined : String(value));
|
|
200
|
+
|
|
201
|
+
const articlesOf = (node: StructureNode): Place[] => {
|
|
202
|
+
const place = node.kind === "article" ? placeOf(node) : undefined;
|
|
203
|
+
return [...(place === undefined ? [] : [place]), ...node.children.flatMap(articlesOf)];
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const resolveRelative = (tree: StructureNode, implicitLevel: number | undefined): StructureNode => {
|
|
207
|
+
const articles = articlesOf(tree);
|
|
208
|
+
return walk(tree, [], { articles, articleLevel: articles[0]?.level, implicitLevel, last: new Map(), byStart: new Map() }) ?? tree;
|
|
209
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { StructureNode } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 番号の付いた条項が本文にあるのに、木にほとんど入っていない文書。PDF から取り出した契約書は、字下げが深かったり
|
|
5
|
+
* 行が本文につながったりして、番号の行として読めないことが多い。そのとき構造の rule は「0 件」ではなく「読めなかった」と言う。
|
|
6
|
+
*/
|
|
7
|
+
const NUMBERED = new Set(["chapter", "article", "item"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 「11.3. Liability Cap」「2.1 Term」: 二段以上の番号と、大文字で始まる語。行頭か、文の区切り(「. 12.10. Waiver」)の後ろ。
|
|
11
|
+
* 語の後ろの番号(「Version 1.2 Released」「Figure 2.1 Revenue」)は条項ではない。
|
|
12
|
+
* 「1. TERM OF CONTRACT」: 行頭の一段の番号と、大文字だけの語。本文の箇条書き(「1. First」)とは分ける。
|
|
13
|
+
*/
|
|
14
|
+
const CLAUSE_NUMBERS = [/(?:^[ \t]{0,20}|[.;:][ \t]{1,10})\d{1,3}(?:\.\d{1,3}){1,4}\.?[ \t]{1,10}\p{Lu}/gmu, /^[ \t]{0,20}\d{1,3}\.[ \t]{1,10}\p{Lu}{2}/gmu];
|
|
15
|
+
|
|
16
|
+
/** 本文の条項番号がこれ以上あれば、番号で組んだ文書。 */
|
|
17
|
+
const MIN_CLAUSES = 5;
|
|
18
|
+
/** 木に入った番号が本文の条項番号のこの割合に届かなければ、読めていない。 */
|
|
19
|
+
const READ_SHARE = 0.25;
|
|
20
|
+
|
|
21
|
+
export type Unread = { readonly clauses: number; readonly units: number };
|
|
22
|
+
|
|
23
|
+
const numberedUnits = (tree: StructureNode): number => {
|
|
24
|
+
let count = 0;
|
|
25
|
+
const pending: StructureNode[] = [tree];
|
|
26
|
+
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
|
27
|
+
if (NUMBERED.has(node.kind)) count += 1;
|
|
28
|
+
pending.push(...node.children);
|
|
29
|
+
}
|
|
30
|
+
return count;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** 読めていなければ、本文の条項番号の数と木の番号の数。読めていれば undefined。 */
|
|
34
|
+
export const unreadStructure = (source: string, tree: StructureNode): Unread | undefined => {
|
|
35
|
+
const clauses = CLAUSE_NUMBERS.reduce((total, pattern) => total + [...source.matchAll(pattern)].length, 0);
|
|
36
|
+
if (clauses < MIN_CLAUSES) return undefined;
|
|
37
|
+
const units = numberedUnits(tree);
|
|
38
|
+
return units < clauses * READ_SHARE ? { clauses, units } : undefined;
|
|
39
|
+
};
|