chaffjs 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/adapter-load.d.ts.map +1 -1
  2. package/dist/adapter-load.js +18 -2
  3. package/dist/adapter-load.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +6 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/cite.d.ts +16 -0
  8. package/dist/commands/cite.d.ts.map +1 -0
  9. package/dist/commands/cite.js +69 -0
  10. package/dist/commands/cite.js.map +1 -0
  11. package/dist/commands/tree.d.ts +33 -0
  12. package/dist/commands/tree.d.ts.map +1 -0
  13. package/dist/commands/tree.js +76 -0
  14. package/dist/commands/tree.js.map +1 -0
  15. package/dist/detectors/index.d.ts.map +1 -1
  16. package/dist/detectors/index.js +4 -0
  17. package/dist/detectors/index.js.map +1 -1
  18. package/dist/detectors/structure-tree.d.ts +5 -0
  19. package/dist/detectors/structure-tree.d.ts.map +1 -0
  20. package/dist/detectors/structure-tree.js +25 -0
  21. package/dist/detectors/structure-tree.js.map +1 -0
  22. package/dist/document.d.ts +9 -0
  23. package/dist/document.d.ts.map +1 -1
  24. package/dist/document.js +21 -0
  25. package/dist/document.js.map +1 -1
  26. package/dist/eval.d.ts.map +1 -1
  27. package/dist/eval.js +13 -2
  28. package/dist/eval.js.map +1 -1
  29. package/dist/plugin.d.ts +71 -0
  30. package/dist/plugin.d.ts.map +1 -1
  31. package/dist/position.d.ts.map +1 -1
  32. package/dist/position.js +21 -1
  33. package/dist/position.js.map +1 -1
  34. package/dist/run.d.ts.map +1 -1
  35. package/dist/run.js +7 -2
  36. package/dist/run.js.map +1 -1
  37. package/dist/structure/build.d.ts +20 -0
  38. package/dist/structure/build.d.ts.map +1 -0
  39. package/dist/structure/build.js +162 -0
  40. package/dist/structure/build.js.map +1 -0
  41. package/dist/structure/cite.d.ts +21 -0
  42. package/dist/structure/cite.d.ts.map +1 -0
  43. package/dist/structure/cite.js +91 -0
  44. package/dist/structure/cite.js.map +1 -0
  45. package/dist/structure/issues.d.ts +15 -0
  46. package/dist/structure/issues.d.ts.map +1 -0
  47. package/dist/structure/issues.js +53 -0
  48. package/dist/structure/issues.js.map +1 -0
  49. package/dist/structure/lines.d.ts +15 -0
  50. package/dist/structure/lines.d.ts.map +1 -0
  51. package/dist/structure/lines.js +33 -0
  52. package/dist/structure/lines.js.map +1 -0
  53. package/dist/structure/markdown-path.d.ts +3 -0
  54. package/dist/structure/markdown-path.d.ts.map +1 -0
  55. package/dist/structure/markdown-path.js +5 -0
  56. package/dist/structure/markdown-path.js.map +1 -0
  57. package/dist/structure/numbering.d.ts +9 -0
  58. package/dist/structure/numbering.d.ts.map +1 -0
  59. package/dist/structure/numbering.js +38 -0
  60. package/dist/structure/numbering.js.map +1 -0
  61. package/dist/structure/of.d.ts +13 -0
  62. package/dist/structure/of.d.ts.map +1 -0
  63. package/dist/structure/of.js +8 -0
  64. package/dist/structure/of.js.map +1 -0
  65. package/dist/structure/sexp.d.ts +7 -0
  66. package/dist/structure/sexp.d.ts.map +1 -0
  67. package/dist/structure/sexp.js +25 -0
  68. package/dist/structure/sexp.js.map +1 -0
  69. package/dist/structure/universal.d.ts +7 -0
  70. package/dist/structure/universal.d.ts.map +1 -0
  71. package/dist/structure/universal.js +21 -0
  72. package/dist/structure/universal.js.map +1 -0
  73. package/package.json +3 -3
  74. package/rules/dangling-reference.yaml +27 -0
  75. package/rules/duplicate-definition.yaml +26 -0
  76. package/rules/numbering-gap.yaml +26 -0
  77. package/src/adapter-load.ts +19 -2
  78. package/src/cli.ts +6 -0
  79. package/src/commands/cite.ts +74 -0
  80. package/src/commands/tree.ts +94 -0
  81. package/src/detectors/index.ts +4 -0
  82. package/src/detectors/structure-tree.ts +32 -0
  83. package/src/document.ts +26 -2
  84. package/src/eval.ts +11 -2
  85. package/src/plugin.ts +75 -0
  86. package/src/position.ts +21 -1
  87. package/src/run.ts +7 -2
  88. package/src/structure/build.ts +203 -0
  89. package/src/structure/cite.ts +115 -0
  90. package/src/structure/issues.ts +63 -0
  91. package/src/structure/lines.ts +32 -0
  92. package/src/structure/markdown-path.ts +6 -0
  93. package/src/structure/numbering.ts +44 -0
  94. package/src/structure/of.ts +15 -0
  95. package/src/structure/sexp.ts +28 -0
  96. package/src/structure/universal.ts +21 -0
@@ -0,0 +1,115 @@
1
+ import type { StructureNode } from "../plugin.ts";
2
+ import { lineStarts, placeOf } from "../position.ts";
3
+
4
+ // 回答の引用が原文にあるか。番地が木にあり、引用した文がその番地の範囲に書かれていれば一致。
5
+ // 意味が合っているかは見ない。書いてあるかどうかだけを、機械で決める。
6
+
7
+ export type Citation = { readonly address: string; readonly quote: string };
8
+
9
+ export type CitationStatus = "ok" | "missing-address" | "quote-elsewhere" | "quote-not-found";
10
+
11
+ export type CitationResult = {
12
+ readonly citation: Citation;
13
+ readonly status: CitationStatus;
14
+ /** 引用文が原文で見つかった位置の、いちばん内側の番地。quote-elsewhere のとき、本当はどこに書いてあるか。 */
15
+ readonly foundAt?: string;
16
+ /** 引用文が見つかった行。引用文が空なら、番地の行。 */
17
+ readonly line?: number;
18
+ };
19
+
20
+ /** 比べる形。空白と改行を除き、全角と半角を NFKC で揃える。index は各文字の元の位置。 */
21
+ type Normalized = { readonly text: string; readonly index: readonly number[] };
22
+
23
+ const WHITESPACE = /^\s+$/u;
24
+ const GRAPHEMES = new Intl.Segmenter("und", { granularity: "grapheme" });
25
+
26
+ /**
27
+ * 書記素(見た目の一文字)ごとに NFKC をかける。1 符号単位ずつでは「ガ」と「ガ」、「ハ」+濁点と「バ」が揃わない。
28
+ * NFKC は 1 文字を複数にすることがある(「㈱」→「(株)」)。どれも元の書記素の先頭を指す。
29
+ */
30
+ const normalize = (source: string): Normalized => {
31
+ const text: string[] = [];
32
+ const index: number[] = [];
33
+ [...GRAPHEMES.segment(source)].forEach(({ segment, index: at }) => {
34
+ if (WHITESPACE.test(segment)) return;
35
+ // 位置の表は UTF-16 の単位で持つ。検索(indexOf)がその単位で数えるので、絵文字のような 2 単位の文字でもずれない。
36
+ const normalized = segment.normalize("NFKC");
37
+ text.push(normalized);
38
+ index.push(...Array.from({ length: normalized.length }, () => at));
39
+ });
40
+ return { text: text.join(""), index };
41
+ };
42
+
43
+ type Occurrence = { readonly start: number; readonly last: number };
44
+
45
+ const within = (node: StructureNode, occurrence: Occurrence): boolean => node.span.start <= occurrence.start && occurrence.last < node.span.end;
46
+
47
+ type Located = { readonly holding?: Occurrence; readonly earliest?: Occurrence };
48
+
49
+ /**
50
+ * key が現れる位置を上から順に見て、候補の番地に収まるものが見つかった時点で止める。見つからなければ最初の位置を返す。
51
+ * 全部を配列に溜めない。「の」のような短い引用が長い原文に何百万回現れても、覚えるのは二つだけ。
52
+ * 次は 1 文字先から探す。重なって現れる引用(「ああ」の中の「ああ」)も、どの位置も見落とさない。
53
+ */
54
+ const locate = (whole: Normalized, key: string, candidates: readonly StructureNode[]): Located => {
55
+ // 空の key は indexOf がどこまでも位置を返し、終わらなくなる。
56
+ if (key === "") return {};
57
+ let earliest: Occurrence | undefined;
58
+ for (let at = whole.text.indexOf(key); at !== -1; at = whole.text.indexOf(key, at + 1)) {
59
+ const occurrence = { start: whole.index[at] ?? 0, last: whole.index[at + key.length - 1] ?? 0 };
60
+ earliest ??= occurrence;
61
+ if (candidates.some((node) => within(node, occurrence))) return { holding: occurrence, earliest };
62
+ }
63
+ return earliest === undefined ? {} : { earliest };
64
+ };
65
+
66
+ type Addressed = { readonly node: StructureNode; readonly depth: number };
67
+
68
+ /** 番地を持つ節点と、その深さ。深いほど内側。 */
69
+ const addressedNodes = (tree: StructureNode): Addressed[] => {
70
+ const found: Addressed[] = [];
71
+ const pending: Addressed[] = [{ node: tree, depth: 0 }];
72
+ while (pending.length > 0) {
73
+ const current = pending.pop();
74
+ if (current === undefined) break;
75
+ if (current.node.address !== "") found.push(current);
76
+ [...current.node.children].reverse().forEach((child) => pending.push({ node: child, depth: current.depth + 1 }));
77
+ }
78
+ return found;
79
+ };
80
+
81
+ /** 引用の全体を含む、いちばん内側の番地。二つの項にまたがる引用なら、両方を含む条。どの番地にも入らなければ undefined。 */
82
+ const innermostHolding = (nodes: readonly Addressed[], occurrence: Occurrence): string | undefined =>
83
+ nodes
84
+ .filter(({ node }) => within(node, occurrence))
85
+ .reduce<Addressed | undefined>((best, current) => (best === undefined || current.depth > best.depth ? current : best), undefined)?.node.address;
86
+
87
+ /**
88
+ * 引用を一件ずつ確かめる。source は木を作った原文そのもの。
89
+ * 引用文が空(空白だけ)なら番地だけを確かめる。同じ番地が二つある(附則が第1条から振り直す)ときは、どちらかにあれば一致。
90
+ * 原文は一度だけ揃え、引用ごとには番地の範囲を揃え直さない。長い契約書に引用が何百とあっても、引用ごとの代金は原文の長さに比例するだけ。
91
+ */
92
+ export const checkCitations = (source: string, tree: StructureNode, citations: readonly Citation[]): CitationResult[] => {
93
+ const nodes = addressedNodes(tree);
94
+ const byAddress = new Map<string, StructureNode[]>();
95
+ nodes.forEach(({ node }) => {
96
+ const same = byAddress.get(node.address) ?? [];
97
+ same.push(node);
98
+ byAddress.set(node.address, same);
99
+ });
100
+ const whole = normalize(source);
101
+ const starts = lineStarts(source);
102
+ return citations.map((citation): CitationResult => {
103
+ const candidates = byAddress.get(citation.address) ?? [];
104
+ const first = candidates[0];
105
+ if (first === undefined) return { citation, status: "missing-address" };
106
+ const key = normalize(citation.quote).text;
107
+ if (key === "") return { citation, status: "ok", line: first.line };
108
+ const { holding, earliest } = locate(whole, key, candidates);
109
+ if (holding !== undefined) return { citation, status: "ok", line: placeOf(starts, holding.start).line };
110
+ if (earliest === undefined) return { citation, status: "quote-not-found" };
111
+ const foundAt = innermostHolding(nodes, earliest);
112
+ const line = placeOf(starts, earliest.start).line;
113
+ return { citation, status: "quote-elsewhere", line, ...(foundAt === undefined ? {} : { foundAt }) };
114
+ });
115
+ };
@@ -0,0 +1,63 @@
1
+ import type { StructureNode } from "../plugin.ts";
2
+
3
+ // 木だけで決まる誤り。参照先が無い、番号が飛ぶ・重なる、同じ語を二度定義する。
4
+ // 意味の食い違いは判定しない。書いてあることから機械で決まるものだけを出す。
5
+
6
+ export type StructureIssue = { readonly offset: number; readonly values: Readonly<Record<string, string | number>> };
7
+
8
+ const NUMBERED: readonly string[] = ["chapter", "article", "item"];
9
+
10
+ /** 木を上から順に平らにする。文書の中での順番と同じになる。再帰にしないのは、深い木でスタックを使い切らないため。 */
11
+ export const inDocumentOrder = (tree: StructureNode): StructureNode[] => {
12
+ const order: StructureNode[] = [];
13
+ const pending: StructureNode[] = [tree];
14
+ while (pending.length > 0) {
15
+ const node = pending.pop();
16
+ if (node === undefined) break;
17
+ order.push(node);
18
+ pending.push(...[...node.children].reverse());
19
+ }
20
+ return order;
21
+ };
22
+
23
+ const textOf = (node: StructureNode, key: string): string => {
24
+ const value = node.attrs[key];
25
+ return value === undefined ? "" : String(value);
26
+ };
27
+
28
+ /**
29
+ * 参照がこの文書のどこかを指しているか。fallback は言語パッケージが付ける別の行き先で、
30
+ * 日本語の「第4条第1項」は、番号の無い第 1 項を持つ第4条を指しうる。
31
+ */
32
+ const resolves = (node: StructureNode, addresses: ReadonlySet<string>): boolean =>
33
+ addresses.has(textOf(node, "target")) || (node.attrs["fallback"] !== undefined && addresses.has(textOf(node, "fallback")));
34
+
35
+ /**
36
+ * 参照の番地が木に無い。他の文書の名前が付いた参照(民法第709条、Section 9 of the Master Agreement)は引かない。
37
+ * 番号付きのまとまりを 1 つも持たない文書も見ない。他の文書を指しているだけかもしれない。
38
+ */
39
+ export const danglingReferences = (tree: StructureNode): StructureIssue[] => {
40
+ const nodes = inDocumentOrder(tree);
41
+ const addresses = new Set(nodes.filter((node) => NUMBERED.includes(node.kind)).map((node) => node.address));
42
+ if (addresses.size === 0) return [];
43
+ return nodes
44
+ .filter((node) => node.kind === "reference" && node.attrs["document"] === undefined)
45
+ .filter((node) => !resolves(node, addresses))
46
+ .map((node) => ({ offset: node.span.start, values: { label: textOf(node, "label"), target: textOf(node, "target") } }));
47
+ };
48
+
49
+ /** 同じ語の二度目以降の定義。どちらが正しいかは決めず、両方の場所を示す。 */
50
+ export const duplicateDefinitions = (tree: StructureNode): StructureIssue[] => {
51
+ const first = new Map<string, StructureNode>();
52
+ return inDocumentOrder(tree)
53
+ .filter((node) => node.kind === "definition")
54
+ .flatMap((node) => {
55
+ const term = textOf(node, "term");
56
+ const earlier = first.get(term);
57
+ if (earlier === undefined) {
58
+ first.set(term, node);
59
+ return [];
60
+ }
61
+ return [{ offset: node.span.start, values: { term, first: earlier.line } }];
62
+ });
63
+ };
@@ -0,0 +1,32 @@
1
+ /** 1 行。text は改行を含まず、\r\n の \r も落としてある。start は元の文書での位置。 */
2
+ export type Line = { readonly text: string; readonly start: number; readonly number: number };
3
+
4
+ /**
5
+ * 行に分ける。位置は元の文字列のまま数える。
6
+ * Windows で書かれた契約書は \r\n で届くので、\r を本文に残すと行末の判定がずれる。
7
+ * 行ごとに配列を作り直さない。何万行もある契約書で二乗に遅くなるため。
8
+ */
9
+ export const linesOf = (source: string): Line[] => {
10
+ const lines: Line[] = [];
11
+ let at = 0;
12
+ source.split("\n").forEach((raw, index) => {
13
+ lines.push({ text: raw.endsWith("\r") ? raw.slice(0, -1) : raw, start: at, number: index + 1 });
14
+ at += raw.length + 1;
15
+ });
16
+ return lines;
17
+ };
18
+
19
+ /** offset を含む行の番号(1 始まり)。lines は start の昇順。二分探索で、行数に対して対数で引く。 */
20
+ export const lineNumberAt = (lines: readonly Line[], offset: number): number | undefined => {
21
+ let low = 0;
22
+ let high = lines.length - 1;
23
+ while (low <= high) {
24
+ const middle = (low + high) >> 1;
25
+ const line = lines[middle];
26
+ if (line === undefined) return undefined;
27
+ if (offset < line.start) high = middle - 1;
28
+ else if (offset > line.start + line.text.length) low = middle + 1;
29
+ else return line.number;
30
+ }
31
+ return undefined;
32
+ };
@@ -0,0 +1,6 @@
1
+ import { extname } from "node:path";
2
+
3
+ const MARKDOWN = [".md", ".markdown", ".mdx"];
4
+
5
+ /** 見出しとコードの範囲を Markdown として読むか。.txt の契約書は行頭の番号だけで木にする。 */
6
+ export const isMarkdownPath = (path: string): boolean => MARKDOWN.includes(extname(path).toLowerCase());
@@ -0,0 +1,44 @@
1
+ import type { StructureNode } from "../plugin.ts";
2
+ import { inDocumentOrder, type StructureIssue } from "./issues.ts";
3
+
4
+ const labelOf = (node: StructureNode): string => String(node.attrs["label"] ?? node.address);
5
+
6
+ type Ordered = { readonly node: StructureNode; readonly ordinal: number };
7
+
8
+ /** 同じ親の子のうち、番号の付いたものを種類ごとに分ける。条と項は別の並び。 */
9
+ const sequencesOf = (parent: StructureNode): Ordered[][] => {
10
+ const groups = new Map<string, Ordered[]>();
11
+ parent.children.forEach((node) => {
12
+ if (node.ordinal === undefined) return;
13
+ // 配列を作り直さずに足す。条が何千もある法令で二乗にしない。
14
+ const group = groups.get(node.kind) ?? [];
15
+ group.push({ node, ordinal: node.ordinal });
16
+ groups.set(node.kind, group);
17
+ });
18
+ return [...groups.values()];
19
+ };
20
+
21
+ /** 隣り合う二つの番号が、1 つ進んでいなければ指摘する。同じ番号なら重なり、飛べば抜け。 */
22
+ const breaksIn = (sequence: readonly Ordered[]): StructureIssue[] =>
23
+ sequence.flatMap((current, index) => {
24
+ const previous = sequence[index - 1];
25
+ // 1 に戻ったら新しい並び。一つの条に (a)(b) の箇条書きが二つある、附則が第1条から振り直す、のどちらも誤りではない。
26
+ if (previous === undefined || current.ordinal === previous.ordinal + 1 || current.ordinal === 1) return [];
27
+ return [
28
+ {
29
+ offset: current.node.span.start,
30
+ values: { previous: labelOf(previous.node), label: labelOf(current.node), expected: previous.ordinal + 1, found: current.ordinal },
31
+ },
32
+ ];
33
+ });
34
+
35
+ /**
36
+ * 番号の抜けと重なり。比べるのは同じ親の子どうしだけ。英米の法令は章ごとに Section 101、201 と百の位を変えるので、
37
+ * 文書全体の通し番号として比べると抜けに見える。最初の番号は見ない。日本語の項は 2 から番号を振る。
38
+ * 1 に戻った番号は新しい並びの始まりとして扱うので、「(a) の後の (a)」の重なりは見逃す。
39
+ */
40
+ export const numberingBreaks = (tree: StructureNode): StructureIssue[] =>
41
+ inDocumentOrder(tree)
42
+ .flatMap(sequencesOf)
43
+ .flatMap(breaksIn)
44
+ .sort((left, right) => left.offset - right.offset);
@@ -0,0 +1,15 @@
1
+ import { markdownOutline } from "../document.ts";
2
+ import type { StructureNode, StructurePatterns } from "../plugin.ts";
3
+ import { buildTree, NO_OUTLINE } from "./build.ts";
4
+
5
+ export type SourceInput = { readonly path: string; readonly source: string; readonly language: string; readonly markdown: boolean };
6
+
7
+ /**
8
+ * 文字列から木を作る。Markdown なら見出しとコードの範囲を先に取る。
9
+ * lint は文書モデルを作るときの解析を使い回すので、ここを通らずに buildTree を呼ぶ。
10
+ */
11
+ export const buildStructure = (input: SourceInput, patterns: StructurePatterns): StructureNode =>
12
+ buildTree(
13
+ { path: input.path, source: input.source, language: input.language, outline: input.markdown ? markdownOutline(input.source) : NO_OUTLINE },
14
+ patterns,
15
+ );
@@ -0,0 +1,28 @@
1
+ import type { StructureNode } from "../plugin.ts";
2
+
3
+ /** 文字列は二重引用符で囲み、中の \ と " だけを逃がす。改行は \n にして 1 行に保つ。 */
4
+ const quote = (text: string): string => `"${text.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"').replace(/\r?\n/gu, "\\n")}"`;
5
+
6
+ const atom = (value: string | number): string => (typeof value === "number" ? String(value) : quote(value));
7
+
8
+ /** 属性はキーの順に並べる。同じ木から毎回同じ文字列を出すため。 */
9
+ const keywords = (node: StructureNode): string[] => [
10
+ ...Object.keys(node.attrs)
11
+ .sort((left, right) => left.localeCompare(right, "en"))
12
+ .flatMap((key) => {
13
+ const value = node.attrs[key];
14
+ return value === undefined ? [] : [`:${key} ${atom(value)}`];
15
+ }),
16
+ `:line ${String(node.line)}`,
17
+ ];
18
+
19
+ /**
20
+ * 木を S 式にする。人と AI が読むための形で、JSON と同じ木から作る。
21
+ * `(kind "番地" :key value ... 子)`。番地を持たない葉は番地を書かない。
22
+ */
23
+ export const toSexp = (node: StructureNode, indent = ""): string => {
24
+ const head = [node.kind, ...(node.address === "" ? [] : [quote(node.address)]), ...keywords(node)].join(" ");
25
+ if (node.children.length === 0) return `${indent}(${head})`;
26
+ const inner = node.children.map((child) => toSexp(child, `${indent} `)).join("\n");
27
+ return `${indent}(${head}\n${inner})`;
28
+ };
@@ -0,0 +1,21 @@
1
+ import type { NumberedLine, NumberingContext } from "../plugin.ts";
2
+
3
+ /**
4
+ * 「4.2 設定」「3.1.4 Scope」のような通し番号。数字と点だけで書くので言語を問わない。
5
+ * 各部分を 3 桁・深さを 6 までに抑え、後戻りが爆発しない形にしてある。
6
+ */
7
+ const DOTTED = /^[ \t]{0,3}(?<number>\d{1,3}(?:\.\d{1,3}){0,5})\.?[ \t\u3000]+(?<rest>\S.*)$/u;
8
+
9
+ /**
10
+ * 本文の「1. 」は箇条書きで、見出しの「1. 」は章番号。点を含まない番号は、見出しの行でだけ読む。
11
+ * 「4.2 」のように点を含むものは、.txt の仕様書でも見出しとして書かれるので本文でも読む。
12
+ */
13
+ export const dottedNumber = (line: string, context: NumberingContext): NumberedLine | undefined => {
14
+ const groups = DOTTED.exec(line)?.groups;
15
+ const number = groups?.["number"];
16
+ if (number === undefined) return undefined;
17
+ const depth = number.split(".").length;
18
+ if (depth < 2 && !context.isHeading) return undefined;
19
+ const rest = groups?.["rest"]?.trim() ?? "";
20
+ return { kind: "article", depth, number, absolute: true, label: number, heading: rest, rest, ordinal: Number(number.split(".").at(-1)) };
21
+ };