chaffjs 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/adapter-load.d.ts.map +1 -1
  2. package/dist/adapter-load.js +18 -2
  3. package/dist/adapter-load.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +15 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/cite.d.ts +16 -0
  8. package/dist/commands/cite.d.ts.map +1 -0
  9. package/dist/commands/cite.js +69 -0
  10. package/dist/commands/cite.js.map +1 -0
  11. package/dist/commands/eval.d.ts.map +1 -1
  12. package/dist/commands/eval.js +1 -1
  13. package/dist/commands/eval.js.map +1 -1
  14. package/dist/commands/tree.d.ts +33 -0
  15. package/dist/commands/tree.d.ts.map +1 -0
  16. package/dist/commands/tree.js +76 -0
  17. package/dist/commands/tree.js.map +1 -0
  18. package/dist/config/load.d.ts +9 -0
  19. package/dist/config/load.d.ts.map +1 -1
  20. package/dist/config/load.js +27 -1
  21. package/dist/config/load.js.map +1 -1
  22. package/dist/config/rule-problems.d.ts +10 -0
  23. package/dist/config/rule-problems.d.ts.map +1 -0
  24. package/dist/config/rule-problems.js +20 -0
  25. package/dist/config/rule-problems.js.map +1 -0
  26. package/dist/detectors/index.d.ts.map +1 -1
  27. package/dist/detectors/index.js +7 -0
  28. package/dist/detectors/index.js.map +1 -1
  29. package/dist/detectors/orthography.d.ts +9 -0
  30. package/dist/detectors/orthography.d.ts.map +1 -0
  31. package/dist/detectors/orthography.js +51 -0
  32. package/dist/detectors/orthography.js.map +1 -0
  33. package/dist/detectors/structure-tree.d.ts +5 -0
  34. package/dist/detectors/structure-tree.d.ts.map +1 -0
  35. package/dist/detectors/structure-tree.js +25 -0
  36. package/dist/detectors/structure-tree.js.map +1 -0
  37. package/dist/document.d.ts +12 -0
  38. package/dist/document.d.ts.map +1 -1
  39. package/dist/document.js +34 -3
  40. package/dist/document.js.map +1 -1
  41. package/dist/eval.d.ts +5 -1
  42. package/dist/eval.d.ts.map +1 -1
  43. package/dist/eval.js +23 -7
  44. package/dist/eval.js.map +1 -1
  45. package/dist/heading-text.d.ts +14 -0
  46. package/dist/heading-text.d.ts.map +1 -0
  47. package/dist/heading-text.js +28 -0
  48. package/dist/heading-text.js.map +1 -0
  49. package/dist/orthography.d.ts +21 -0
  50. package/dist/orthography.d.ts.map +1 -0
  51. package/dist/orthography.js +110 -0
  52. package/dist/orthography.js.map +1 -0
  53. package/dist/plugin.d.ts +71 -0
  54. package/dist/plugin.d.ts.map +1 -1
  55. package/dist/position.d.ts.map +1 -1
  56. package/dist/position.js +21 -1
  57. package/dist/position.js.map +1 -1
  58. package/dist/render/rules-json.d.ts.map +1 -1
  59. package/dist/render/rules-json.js +11 -1
  60. package/dist/render/rules-json.js.map +1 -1
  61. package/dist/run.d.ts +3 -1
  62. package/dist/run.d.ts.map +1 -1
  63. package/dist/run.js +11 -5
  64. package/dist/run.js.map +1 -1
  65. package/dist/structure/build.d.ts +20 -0
  66. package/dist/structure/build.d.ts.map +1 -0
  67. package/dist/structure/build.js +165 -0
  68. package/dist/structure/build.js.map +1 -0
  69. package/dist/structure/cite.d.ts +21 -0
  70. package/dist/structure/cite.d.ts.map +1 -0
  71. package/dist/structure/cite.js +91 -0
  72. package/dist/structure/cite.js.map +1 -0
  73. package/dist/structure/issues.d.ts +15 -0
  74. package/dist/structure/issues.d.ts.map +1 -0
  75. package/dist/structure/issues.js +53 -0
  76. package/dist/structure/issues.js.map +1 -0
  77. package/dist/structure/lines.d.ts +15 -0
  78. package/dist/structure/lines.d.ts.map +1 -0
  79. package/dist/structure/lines.js +33 -0
  80. package/dist/structure/lines.js.map +1 -0
  81. package/dist/structure/markdown-path.d.ts +3 -0
  82. package/dist/structure/markdown-path.d.ts.map +1 -0
  83. package/dist/structure/markdown-path.js +5 -0
  84. package/dist/structure/markdown-path.js.map +1 -0
  85. package/dist/structure/numbering.d.ts +9 -0
  86. package/dist/structure/numbering.d.ts.map +1 -0
  87. package/dist/structure/numbering.js +38 -0
  88. package/dist/structure/numbering.js.map +1 -0
  89. package/dist/structure/of.d.ts +13 -0
  90. package/dist/structure/of.d.ts.map +1 -0
  91. package/dist/structure/of.js +8 -0
  92. package/dist/structure/of.js.map +1 -0
  93. package/dist/structure/sexp.d.ts +7 -0
  94. package/dist/structure/sexp.d.ts.map +1 -0
  95. package/dist/structure/sexp.js +25 -0
  96. package/dist/structure/sexp.js.map +1 -0
  97. package/dist/structure/universal.d.ts +7 -0
  98. package/dist/structure/universal.d.ts.map +1 -0
  99. package/dist/structure/universal.js +21 -0
  100. package/dist/structure/universal.js.map +1 -0
  101. package/package.json +3 -3
  102. package/rules/dangling-reference.yaml +27 -0
  103. package/rules/duplicate-definition.yaml +26 -0
  104. package/rules/latin-spacing.yaml +27 -0
  105. package/rules/numbering-gap.yaml +26 -0
  106. package/rules/preferred-term.yaml +27 -0
  107. package/src/adapter-load.ts +19 -2
  108. package/src/cli.ts +16 -1
  109. package/src/commands/cite.ts +74 -0
  110. package/src/commands/eval.ts +1 -0
  111. package/src/commands/tree.ts +94 -0
  112. package/src/config/load.ts +43 -1
  113. package/src/config/rule-problems.ts +24 -0
  114. package/src/detectors/index.ts +7 -0
  115. package/src/detectors/orthography.ts +55 -0
  116. package/src/detectors/structure-tree.ts +32 -0
  117. package/src/document.ts +49 -6
  118. package/src/eval.ts +27 -7
  119. package/src/heading-text.ts +26 -0
  120. package/src/orthography.ts +115 -0
  121. package/src/plugin.ts +75 -0
  122. package/src/position.ts +21 -1
  123. package/src/render/rules-json.ts +10 -1
  124. package/src/run.ts +22 -5
  125. package/src/structure/build.ts +205 -0
  126. package/src/structure/cite.ts +115 -0
  127. package/src/structure/issues.ts +63 -0
  128. package/src/structure/lines.ts +32 -0
  129. package/src/structure/markdown-path.ts +6 -0
  130. package/src/structure/numbering.ts +44 -0
  131. package/src/structure/of.ts +15 -0
  132. package/src/structure/sexp.ts +28 -0
  133. package/src/structure/universal.ts +21 -0
@@ -0,0 +1,7 @@
1
+ import type { NumberedLine, NumberingContext } from "../plugin.ts";
2
+ /**
3
+ * 本文の「1. 」は箇条書きで、見出しの「1. 」は章番号。点を含まない番号は、見出しの行でだけ読む。
4
+ * 「4.2 」のように点を含むものは、.txt の仕様書でも見出しとして書かれるので本文でも読む。
5
+ */
6
+ export declare const dottedNumber: (line: string, context: NumberingContext) => NumberedLine | undefined;
7
+ //# sourceMappingURL=universal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"universal.d.ts","sourceRoot":"","sources":["../../src/structure/universal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAQnE;;;GAGG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,EAAE,SAAS,gBAAgB,KAAG,YAAY,GAAG,SAQrF,CAAC"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 「4.2 設定」「3.1.4 Scope」のような通し番号。数字と点だけで書くので言語を問わない。
3
+ * 各部分を 3 桁・深さを 6 までに抑え、後戻りが爆発しない形にしてある。
4
+ */
5
+ const DOTTED = /^[ \t]{0,3}(?<number>\d{1,3}(?:\.\d{1,3}){0,5})\.?[ \t\u3000]+(?<rest>\S.*)$/u;
6
+ /**
7
+ * 本文の「1. 」は箇条書きで、見出しの「1. 」は章番号。点を含まない番号は、見出しの行でだけ読む。
8
+ * 「4.2 」のように点を含むものは、.txt の仕様書でも見出しとして書かれるので本文でも読む。
9
+ */
10
+ export const dottedNumber = (line, context) => {
11
+ const groups = DOTTED.exec(line)?.groups;
12
+ const number = groups?.["number"];
13
+ if (number === undefined)
14
+ return undefined;
15
+ const depth = number.split(".").length;
16
+ if (depth < 2 && !context.isHeading)
17
+ return undefined;
18
+ const rest = groups?.["rest"]?.trim() ?? "";
19
+ return { kind: "article", depth, number, absolute: true, label: number, heading: rest, rest, ordinal: Number(number.split(".").at(-1)) };
20
+ };
21
+ //# sourceMappingURL=universal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"universal.js","sourceRoot":"","sources":["../../src/structure/universal.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,MAAM,MAAM,GAAG,+EAA+E,CAAC;AAE/F;;;GAGG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,IAAY,EAAE,OAAyB,EAA4B,EAAE;IAChG,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACzC,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,CAAC;IAClC,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC3C,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,IAAI,KAAK,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS;QAAE,OAAO,SAAS,CAAC;IACtD,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IAC5C,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AAC3I,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chaffjs",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Prose validation harness. Finds what is hard to read, and never rewrites it for you.",
5
5
  "keywords": [
6
6
  "chaff",
@@ -52,8 +52,8 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@anthropic-ai/sdk": "^0.125.0",
55
- "@chaffjs/lang-en": "0.5.0",
56
- "@chaffjs/lang-ja": "0.5.0",
55
+ "@chaffjs/lang-en": "0.6.0",
56
+ "@chaffjs/lang-ja": "0.6.0",
57
57
  "mdast-util-from-markdown": "^2.0.3",
58
58
  "mdast-util-frontmatter": "^2.0.1",
59
59
  "mdast-util-gfm-table": "^2.0.0",
@@ -0,0 +1,27 @@
1
+ id: dangling-reference
2
+ layer: L3
3
+ status: experimental
4
+ how_to_find: dangling-reference
5
+ requires: [structure]
6
+ severity: error
7
+
8
+ name:
9
+ ja: 参照先が無い
10
+ en: Reference to a missing provision
11
+
12
+ why:
13
+ ja: 「第12条に定める」のような参照は、その条が文書に無ければ読み手を行き止まりにします。条を消したり番号を振り直したりしたときに、参照だけが古い番号のまま残ることがよくあります。chaff は文書を番地の付いた木にして(chaff tree)、参照の番地が木にあるかを見ます。
14
+ en: A reference such as "as set out in Section 12" leaves the reader at a dead end when that section is not in the document. It usually happens when a provision is deleted or renumbered and the reference keeps the old number. chaff builds the document's tree of addresses (chaff tree) and checks that each reference's address is in it.
15
+
16
+ message:
17
+ ja: 「{label}」(番地 {target})はこの文書にありません
18
+ en: '"{label}" (address {target}) is not in this document'
19
+
20
+ how_to_fix:
21
+ ja: 正しい番号に直すか、参照を消してください。他の文書を指しているなら、「民法第3条」のように文書名を添えると読み手に伝わります。
22
+ en: Fix the number or remove the reference. If it points into another document, name that document so the reader knows where to look.
23
+
24
+ # 何件あれば指摘するか。1 は「1 件でもあれば」。
25
+ levels: { strict: 1, normal: 1, relaxed: 1 }
26
+
27
+ use_for: [business, technical, blog]
@@ -0,0 +1,26 @@
1
+ id: duplicate-definition
2
+ layer: L3
3
+ status: experimental
4
+ how_to_find: duplicate-definition
5
+ requires: [structure]
6
+ severity: warning
7
+
8
+ name:
9
+ ja: 同じ語の二重定義
10
+ en: Term defined twice
11
+
12
+ why:
13
+ ja: 同じ語を二か所で定義すると、二つの定義が食い違っていないかを読み手が確かめなければなりません。長い契約書では、後から足した定義が先の定義と少しずれることがよくあります。中身が食い違っているかは機械では決まらないので、chaff は二度目の定義の場所だけを示します。
14
+ en: A term defined in two places makes the reader check whether the two definitions agree. In long contracts a definition added later often drifts from the first. Whether they conflict is not something a machine can decide, so chaff only points at the second one.
15
+
16
+ message:
17
+ ja: 「{term}」は {first} 行目でも定義されています
18
+ en: '"{term}" is also defined on line {first}'
19
+
20
+ how_to_fix:
21
+ ja: 定義を一か所にまとめ、もう一方は「第2条に定める{term}」のように参照にしてください。意図して意味を変えているなら、別の語にします。
22
+ en: Keep one definition and refer to it from the other place. If the meaning changes on purpose, use a different term.
23
+
24
+ levels: { strict: 1, normal: 1, relaxed: 1 }
25
+
26
+ use_for: [business, technical, blog]
@@ -0,0 +1,27 @@
1
+ id: latin-spacing
2
+ layer: L3
3
+ status: experimental
4
+ how_to_find: latin-spacing
5
+ languages: [ja]
6
+ severity: warning
7
+
8
+ name:
9
+ ja: 英数字の前後の空白がそろっていない
10
+ en: Spacing around Latin text is inconsistent
11
+
12
+ why:
13
+ ja: 日本語と英字・数字のあいだを空けるか詰めるかは、どちらでも読めます。困るのは一つの文書の中で混ざることで、読み手には違いに意味があるように見えます。chaff はどちらが正しいかを決めず、文書の中で少ないほうを指摘します。英字と、数字の前、数字の後ろは別々に数えます。「を 3回」のように、数字の前は空けて助数詞とは詰める書き方がふつうにあるからです。「3GBの」のように数字に続く単位は、英字ではなく数字の側として数えます。
14
+ en: Whether to put a space between Japanese and Latin letters or digits is a choice either way. What reads badly is mixing both in one document, which suggests the difference means something. chaff does not decide which is right; it points at whichever the document uses less. Letters, the space before a digit and the space after one are counted separately, since spacing before a number but not before its counter is common.
15
+
16
+ message:
17
+ ja: "日本語と{kind}のあいだを{style}(この文書はふつう{usual}。{of} 箇所のうち {count} 箇所が違う)"
18
+ en: "Spacing between Japanese and {kind} differs here ({count} of {of})"
19
+
20
+ how_to_fix:
21
+ ja: 文書の多いほうの書き方に合わせてください。チームで決めているなら、STYLE.md に書いておくと迷いません。
22
+ en: Match the way the rest of the document does it. If the team has decided, write it in the style guide.
23
+
24
+ # 何箇所違えば指摘するか。1 は「1 箇所でも混ざっていれば」。
25
+ levels: { strict: 1, normal: 1, relaxed: 3 }
26
+
27
+ use_for: [business, blog, technical]
@@ -0,0 +1,26 @@
1
+ id: numbering-gap
2
+ layer: L3
3
+ status: experimental
4
+ how_to_find: numbering-gap
5
+ requires: [structure]
6
+ severity: error
7
+
8
+ name:
9
+ ja: 番号の抜け・重なり
10
+ en: Skipped or repeated number
11
+
12
+ why:
13
+ ja: 第3条の次が第5条、(a) の次が (c)、同じ第2項が二つ。番号が飛ぶか重なると、読み手は何かが消えたのか、参照がどちらを指すのかを判断できません。同じ親の中で並ぶ番号だけを比べます。枝番号(第3条の2)は並びの外として扱います。
14
+ en: Article 5 right after Article 3, (c) right after (a), two paragraphs numbered 2. A reader cannot tell whether something was removed, or which one a reference means. Only numbers side by side under the same parent are compared.
15
+
16
+ message:
17
+ ja: 「{previous}」の次が「{label}」です({expected} 番目のはず)
18
+ en: '"{label}" follows "{previous}" (expected number {expected})'
19
+
20
+ how_to_fix:
21
+ ja: 番号を振り直してください。削った条を欠番として残すなら、「第4条(削除)」のように番号だけを残すと読み手に伝わります。
22
+ en: Renumber. If a provision was removed on purpose, keep its number with a note such as "Section 4 [Deleted]".
23
+
24
+ levels: { strict: 1, normal: 1, relaxed: 1 }
25
+
26
+ use_for: [business, technical, blog]
@@ -0,0 +1,27 @@
1
+ id: preferred-term
2
+ layer: L2
3
+ status: experimental
4
+ how_to_find: preferred-term
5
+ word_list: preferred-term
6
+ severity: warning
7
+
8
+ name:
9
+ ja: チームの表記と違う
10
+ en: Not the team's spelling
11
+
12
+ why:
13
+ ja: 同じものを「サーバー」とも「サーバ」とも書くと、読み手は別のものかと迷い、検索でも片方を取りこぼします。どちらに寄せるかはチームごとに違うので、chaff.yaml の prefer に「使わない書き方」と「使う書き方」の組を自分で並べてください。並べていなければ何も言いません。
14
+ en: Writing the same thing two ways ("e-mail" and "email") makes a reader wonder whether they differ, and a search misses one of them. Which way wins is the team's choice, so list pairs (the spelling to avoid, then the one to use) under `prefer` in chaff.yaml. With nothing listed, this rule says nothing.
15
+
16
+ message:
17
+ ja: 「{matched}」は「{preferred}」と書きます
18
+ en: 'Write "{preferred}", not "{matched}"'
19
+
20
+ how_to_fix:
21
+ ja: 「{preferred}」に直してください。固有名詞など、そのままにすべき箇所なら stet で理由を添えて黙らせます。
22
+ en: Change it to "{preferred}". Where it must stay (a product's own name, a quotation), silence it with stet and the reason.
23
+
24
+ # チームが並べた書き方なので、1 つでも指摘する。並べた時点で「使わない」と決めている。
25
+ levels: { strict: 1, normal: 1, relaxed: 3 }
26
+
27
+ use_for: [business, blog, technical]
@@ -50,7 +50,24 @@ const pickExport = (module: object): unknown => {
50
50
  return isAdapter(record["adapter"]) ? record["adapter"] : record["default"];
51
51
  };
52
52
 
53
- export const packageFor = (language: string): string | undefined => ADAPTER_PACKAGE[language];
53
+ /**
54
+ * 同梱の言語は表から、それ以外は `@chaffjs/lang-<言語>` を探す。
55
+ * 利用者が lang-zh のようなパッケージを書いて入れれば、core を変えずにその言語で動く。
56
+ */
57
+ const LANGUAGE_ID = /^[a-z]{2,3}$/u;
58
+
59
+ export const packageFor = (language: string): string | undefined =>
60
+ ADAPTER_PACKAGE[language] ?? (LANGUAGE_ID.test(language) ? `@chaffjs/lang-${language}` : undefined);
61
+
62
+ const importAdapter = async (specifier: string, language: string): Promise<unknown> => {
63
+ try {
64
+ return await import(specifier);
65
+ } catch (err) {
66
+ const missing = err instanceof Error && "code" in err && err.code === "ERR_MODULE_NOT_FOUND";
67
+ if (missing) throw new Error(`言語 ${language} のパッケージ ${specifier} が入っていません(npm i -D ${specifier})`, { cause: err });
68
+ throw err;
69
+ }
70
+ };
54
71
 
55
72
  /**
56
73
  * アダプタは「必要になったものだけ」を実行時に解決する。spec §17.2。
@@ -60,7 +77,7 @@ export const packageFor = (language: string): string | undefined => ADAPTER_PACK
60
77
  export const loadAdapter = async (language: string): Promise<LanguageAdapter> => {
61
78
  const specifier = packageFor(language);
62
79
  if (specifier === undefined) throw new Error(`no adapter for language "${language}"`);
63
- const module: unknown = await import(specifier);
80
+ const module = await importAdapter(specifier, language);
64
81
  if (typeof module !== "object" || module === null) throw new Error(`${specifier} did not export a module`);
65
82
  const candidate = pickExport(module);
66
83
  const broken = checkAdapter(candidate);
package/src/cli.ts CHANGED
@@ -23,6 +23,9 @@ import { renderFriendly } from "./render/friendly.ts";
23
23
  import { rulesJson } from "./render/rules-json.ts";
24
24
  import { renderSarif } from "./render/sarif.ts";
25
25
  import { VERSION } from "./version.ts";
26
+ import { runTree, treeTargets } from "./commands/tree.ts";
27
+ import { citeTargets, runCite } from "./commands/cite.ts";
28
+ import { ruleProblems } from "./config/rule-problems.ts";
26
29
  import { renderSummary, type FileOutcome } from "./render/summary.ts";
27
30
  import { neededBy, runRules } from "./run.ts";
28
31
  import type { Level, RuleDefinition } from "./plugin.ts";
@@ -37,6 +40,8 @@ const USAGE = `chaff — 文章の読みにくいところを見つけます。
37
40
  chaff eval <dir> 手元の文書で閾値を測り直す
38
41
  chaff explain <rule> そのルールの意図と根拠を読む
39
42
  chaff genres ジャンルの一覧
43
+ chaff tree <file> [--format sexp|json] 文書を番地の付いた木にする(条・項・定義・参照)
44
+ chaff cite <原文> <引用.json> 回答の引用(番地と引用文)が原文にあるかを確かめる
40
45
  chaff rules --json いまの設定を JSON で出す(AI に渡す用)
41
46
  chaff baseline <dir> いまある指摘を棚上げする(既存の repo に入れるとき)
42
47
  chaff suppressions <dir> stet で黙らせている指摘を数える
@@ -109,7 +114,7 @@ const inspect = async (path: string, config: Config, argv: readonly string[]): P
109
114
  const experimental = config.experimental || argv.includes("--experimental");
110
115
  await adapter.prepare?.(neededBy(rules, config.rules, experimental, genre, language));
111
116
  const doc = buildDocument(path, source, adapter, teamRules(config));
112
- const raw = runRules(doc, rules, config.rules, experimental, genre);
117
+ const raw = runRules(doc, rules, config.rules, experimental, genre, config.limits);
113
118
  // 応答は 3 つ。stet で黙らせたものは、ここで落とす。
114
119
  const applied = applySuppressions(
115
120
  source,
@@ -147,6 +152,11 @@ const writeSarif = (results: readonly Inspected[], argv: readonly string[]): voi
147
152
  console.log(`\n SARIF を書きました: ${path}(${located.length} 件)`);
148
153
  };
149
154
 
155
+ /** 効いていない設定は、結果の前に一度だけ言う。標準エラーに出すので、JSON や SARIF の出力は汚さない。 */
156
+ const warnRuleProblems = (config: Config, language: string): void => {
157
+ ruleProblems(config, loadRules(language)).forEach((problem) => console.error(`chaff: ${problem}`));
158
+ };
159
+
150
160
  const lint = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
151
161
  const paths = collectTargets(targets);
152
162
  if (paths.length === 0) {
@@ -160,6 +170,7 @@ const lint = async (targets: readonly string[], argv: readonly string[]): Promis
160
170
  console.error(`言語 "${language}" のアダプタがありません。`);
161
171
  return 1;
162
172
  }
173
+ warnRuleProblems(config, language);
163
174
  const results = await Promise.all(paths.map((path) => inspect(path, config, argv)));
164
175
  writeSarif(results, argv);
165
176
  results.filter((result) => result.outcome.findings.length > 0 || paths.length === 1).forEach((result) => console.log(result.text));
@@ -178,6 +189,7 @@ const runWatch = async (targets: readonly string[], argv: readonly string[]): Pr
178
189
  return 1;
179
190
  }
180
191
  const config = readConfig();
192
+ warnRuleProblems(config, config.language ?? "ja");
181
193
  const seen = new Map<string, Snapshot>();
182
194
  const once = async (path: string, first: boolean): Promise<void> => {
183
195
  const result = await inspect(path, config, argv);
@@ -264,6 +276,7 @@ const positional = (argv: readonly string[]): string[] => argv.slice(1).filter((
264
276
  const showRules = (): number => {
265
277
  const config = readConfig();
266
278
  const language = config.language ?? "ja";
279
+ warnRuleProblems(config, language);
267
280
  console.log(rulesJson(loadRules(language), config, language, config.genre ?? "blog/tech"));
268
281
  return 0;
269
282
  };
@@ -283,6 +296,8 @@ const HANDLERS: Readonly<Record<string, Handler>> = {
283
296
  rules: showRules,
284
297
  explain: (argv) => explain(argv[1]),
285
298
  eval: (argv) => runEval(positional(argv), argv, { config: readConfig(), resolveGenre, flag }),
299
+ tree: (argv) => runTree(treeTargets(argv), argv, { config: readConfig(), flag }),
300
+ cite: (argv) => runCite(citeTargets(argv), argv, { config: readConfig(), flag }),
286
301
  test: (argv) => runTest(positional(argv), argv, { config: readConfig(), resolveGenre, inspect }),
287
302
  baseline: (argv) => runBaseline(positional(argv), argv),
288
303
  suppressions: (argv) => runSuppressions(positional(argv), argv),
@@ -0,0 +1,74 @@
1
+ import { checkCitations, type Citation, type CitationResult } from "../structure/cite.ts";
2
+ import { readSource, readTree, type TreeContext } from "./tree.ts";
3
+
4
+ const USAGE = "使い方: chaff cite <原文> <引用.json> [--format text|json] [--language ja|en|…]";
5
+ const FORMATS = ["text", "json"];
6
+ const QUOTE_WIDTH = 40;
7
+ const VALUED = ["--format", "--language"];
8
+
9
+ export const citeTargets = (argv: readonly string[]): string[] =>
10
+ argv.slice(1).filter((arg, index, all) => !arg.startsWith("--") && !VALUED.includes(all[index - 1] ?? ""));
11
+
12
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
13
+
14
+ const isCitation = (value: unknown): value is Citation => isRecord(value) && typeof value["address"] === "string" && typeof value["quote"] === "string";
15
+
16
+ export type ParsedCitations = { readonly citations: readonly Citation[] } | { readonly error: string };
17
+
18
+ const parseJson = (text: string): { readonly value: unknown } | { readonly error: string } => {
19
+ try {
20
+ return { value: JSON.parse(text) };
21
+ } catch (err) {
22
+ return { error: `JSON として読めません: ${err instanceof Error ? err.message : String(err)}` };
23
+ }
24
+ };
25
+
26
+ /** 引用の一覧。[{ address, quote }] の配列だけを受け取る。形が違えば理由を返す。 */
27
+ export const parseCitations = (text: string): ParsedCitations => {
28
+ const parsed = parseJson(text);
29
+ if ("error" in parsed) return parsed;
30
+ if (!Array.isArray(parsed.value)) return { error: '引用は配列で渡してください: [{ "address": "3.2", "quote": "…" }]' };
31
+ const entries: readonly unknown[] = parsed.value;
32
+ const bad = entries.findIndex((entry) => !isCitation(entry));
33
+ if (bad !== -1) return { error: `${String(bad + 1)} 件目に address(文字列)と quote(文字列)がありません` };
34
+ return { citations: entries.filter(isCitation).map((entry) => ({ address: entry.address, quote: entry.quote })) };
35
+ };
36
+
37
+ const MESSAGES: Readonly<Record<CitationResult["status"], (result: CitationResult) => string>> = {
38
+ ok: () => "一致",
39
+ "missing-address": (result) => `番地 ${result.citation.address} は原文にありません`,
40
+ "quote-elsewhere": (result) => `引用文は ${result.citation.address} ではなく ${result.foundAt ?? "番地の外"}(${String(result.line ?? "?")} 行目)にあります`,
41
+ "quote-not-found": () => "引用文が原文のどこにもありません",
42
+ };
43
+
44
+ const describe = (result: CitationResult): string => {
45
+ const mark = result.status === "ok" ? "✓" : "✗";
46
+ const oneLine = result.citation.quote.replace(/\s+/gu, " ").trim();
47
+ const quote = oneLine.length > QUOTE_WIDTH ? `${oneLine.slice(0, QUOTE_WIDTH)}…` : oneLine;
48
+ return `${mark} ${result.citation.address}「${quote}」: ${MESSAGES[result.status](result)}`;
49
+ };
50
+
51
+ /**
52
+ * 回答の引用が原文にあるかを確かめる。書き換えはしない。
53
+ * 一つでも外れていれば 1 で終わるので、AI の回答を単体試験のように検査できる。
54
+ */
55
+ export const runCite = async (targets: readonly string[], argv: readonly string[], context: TreeContext): Promise<number> => {
56
+ const [sourcePath, citationsPath] = targets;
57
+ const format = context.flag(argv, "--format") ?? "text";
58
+ if (sourcePath === undefined || citationsPath === undefined || targets.length !== 2 || !FORMATS.includes(format)) {
59
+ console.error(USAGE);
60
+ return 1;
61
+ }
62
+ const citationsText = await readSource(citationsPath);
63
+ if (citationsText === undefined) return 1;
64
+ const parsed = parseCitations(citationsText);
65
+ if ("error" in parsed) {
66
+ console.error(`${citationsPath}: ${parsed.error}`);
67
+ return 1;
68
+ }
69
+ const read = await readTree(sourcePath, argv, context);
70
+ if (read === undefined) return 1;
71
+ const results = checkCitations(read.source, read.tree, parsed.citations);
72
+ console.log(format === "json" ? JSON.stringify(results, null, 2) : results.map(describe).join("\n"));
73
+ return results.every((result) => result.status === "ok") ? 0 : 1;
74
+ };
@@ -58,6 +58,7 @@ export const runEval = async (targets: readonly string[], argv: readonly string[
58
58
  rules,
59
59
  genre,
60
60
  language,
61
+ config.limits,
61
62
  ),
62
63
  docs.length,
63
64
  paths.length,
@@ -0,0 +1,94 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { loadAdapter } from "../adapter-load.ts";
3
+ import { applyByPath } from "../config/by-path.ts";
4
+ import { guessLanguage } from "../detect.ts";
5
+ import { isMarkdownPath } from "../structure/markdown-path.ts";
6
+ import { buildStructure } from "../structure/of.ts";
7
+ import { toSexp } from "../structure/sexp.ts";
8
+ import type { Config } from "../config/load.ts";
9
+ import type { StructureNode } from "../plugin.ts";
10
+
11
+ export type TreeContext = {
12
+ readonly config: Config;
13
+ readonly flag: (argv: readonly string[], name: string) => string | undefined;
14
+ };
15
+
16
+ const FORMATS = ["sexp", "json"];
17
+
18
+ const USAGE = "使い方: chaff tree <file>... [--format sexp|json] [--language ja|en|…]";
19
+
20
+ /** 値を取るフラグ。その次の引数は値で、対象のファイルではない。 */
21
+ const VALUED = ["--format", "--language"];
22
+
23
+ export const treeTargets = (argv: readonly string[]): string[] =>
24
+ argv.slice(1).filter((arg, index, all) => !arg.startsWith("--") && !VALUED.includes(all[index - 1] ?? ""));
25
+
26
+ export const readSource = async (path: string): Promise<string | undefined> => {
27
+ try {
28
+ return await readFile(path, "utf8");
29
+ } catch (err) {
30
+ console.error(`${path} を読めませんでした: ${err instanceof Error ? err.message : String(err)}`);
31
+ return undefined;
32
+ }
33
+ };
34
+
35
+ /**
36
+ * 言語の決め方は lint / test / eval と同じ。そのファイルの by_path、全体の language、中身からの推定の順で、
37
+ * その前に --language を置く。混在するリポジトリで、英語の契約書を日本語として読まないため。
38
+ */
39
+ export const treeLanguage = (path: string, source: string, argv: readonly string[], context: TreeContext): string =>
40
+ context.flag(argv, "--language") ??
41
+ applyByPath(context.config.byPath, context.config.baseDir, path).language ??
42
+ context.config.language ??
43
+ guessLanguage(source).language;
44
+
45
+ export type SourceTree = { readonly source: string; readonly tree: StructureNode };
46
+
47
+ /**
48
+ * 1 ファイルを読んで木にする。読めない・言語パッケージが構造を読めないときは、黙らずに言って undefined を返す。
49
+ * chaff tree と chaff cite が同じ木を見るように、ここを一つにしておく。
50
+ */
51
+ export const readTree = async (path: string, argv: readonly string[], context: TreeContext): Promise<SourceTree | undefined> => {
52
+ const source = await readSource(path);
53
+ if (source === undefined) return undefined;
54
+ const language = treeLanguage(path, source, argv, context);
55
+ const adapter = await loadAdapter(language);
56
+ if (adapter.structure === undefined) {
57
+ console.error(`${path}: 言語 ${language} のパッケージは文書の構造を読めません(structure がありません)`);
58
+ return undefined;
59
+ }
60
+ // 日本語は形態素で数量と日付を読む。解析器が無ければ単位の表で読むので、木は作れる。
61
+ await adapter.prepare?.({ pos: true });
62
+ return { source, tree: buildStructure({ path, source, language, markdown: isMarkdownPath(path) }, adapter.structure) };
63
+ };
64
+
65
+ const printTree = async (path: string, argv: readonly string[], context: TreeContext): Promise<boolean> => {
66
+ const read = await readTree(path, argv, context);
67
+ if (read === undefined) return false;
68
+ console.log(context.flag(argv, "--format") === "json" ? JSON.stringify(read.tree, null, 2) : toSexp(read.tree));
69
+ return true;
70
+ };
71
+
72
+ /**
73
+ * 1 つずつ、渡された順に処理する。前のファイルが終わってから次を始めるので、出力の順が入れ替わらない。
74
+ * 失敗したファイルがあっても残りは処理し、1 つでも失敗していれば false を返す。
75
+ */
76
+ export const inOrder = async (paths: readonly string[], each: (path: string) => Promise<boolean>): Promise<boolean> =>
77
+ paths.reduce<Promise<boolean>>(async (previous, path) => {
78
+ const earlier = await previous;
79
+ return (await each(path)) && earlier;
80
+ }, Promise.resolve(true));
81
+
82
+ /**
83
+ * 文書を番地の付いた木にして出す。S 式は人と AI が読むため、JSON は機械が読むため。
84
+ * 書き換えはしない。Markdown でない .txt(見出しの無い契約書)も、行頭の番号で木にする。
85
+ */
86
+ export const runTree = async (paths: readonly string[], argv: readonly string[], context: TreeContext): Promise<number> => {
87
+ const format = context.flag(argv, "--format") ?? "sexp";
88
+ if (paths.length === 0 || !FORMATS.includes(format)) {
89
+ console.error(USAGE);
90
+ return 1;
91
+ }
92
+ const printed = await inOrder(paths, (path) => printTree(path, argv, context));
93
+ return printed ? 0 : 1;
94
+ };
@@ -14,12 +14,18 @@ export type Config = {
14
14
  readonly aiBackend: BackendName;
15
15
  readonly language: string | undefined;
16
16
  readonly rules: Readonly<Record<string, Level>>;
17
+ /** rules に数値で書いた上限。段階の 4 語では足りないときに、その rule の数値を直接決める。段階は normal として扱う。 */
18
+ readonly limits: Readonly<Record<string, number>>;
19
+ /** rules に書かれていたが読めなかった値。黙って捨てると、書いた設定が効いていないことに気づけない。 */
20
+ readonly unreadableRules: readonly { readonly id: string; readonly value: string }[];
17
21
  readonly experimental: boolean;
18
22
  readonly path: string | undefined;
19
23
  readonly aiModel: string;
20
24
  readonly confidenceThreshold: number;
21
25
  /** チームの言葉。社内でしか通じない語を、チームが自分で並べる。 */
22
26
  readonly jargon: readonly string[];
27
+ /** チームの表記。{ 使わない書き方: 使う書き方 }。「サーバー」ではなく「サーバ」、のように。 */
28
+ readonly prefer: Readonly<Record<string, string>>;
23
29
  /** この種類の文書に無いと困る見出し。チームが自分で決める。 */
24
30
  readonly requiredSections: readonly string[];
25
31
  /** パスごとの上書き。設定ファイルのある場所からの相対で照合する。 */
@@ -35,12 +41,15 @@ export const EMPTY: Config = {
35
41
  genre: undefined,
36
42
  language: undefined,
37
43
  rules: {},
44
+ limits: {},
45
+ unreadableRules: [],
38
46
  experimental: false,
39
47
  path: undefined,
40
48
  aiBackend: DEFAULT_BACKEND,
41
49
  aiModel: defaultModel(DEFAULT_BACKEND),
42
50
  confidenceThreshold: 0.7,
43
51
  jargon: [],
52
+ prefer: {},
44
53
  requiredSections: [],
45
54
  byPath: [],
46
55
  baseDir: process.cwd(),
@@ -48,11 +57,29 @@ export const EMPTY: Config = {
48
57
 
49
58
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
50
59
 
60
+ const isLimit = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value > 0;
61
+
62
+ /** 段階の 4 語はそのまま、数値の上限は normal として読む。上限そのものは limitsOf が持つ。 */
51
63
  const rulesOf = (raw: unknown): Record<string, Level> => {
52
64
  if (!isRecord(raw)) return {};
53
- return Object.entries(raw).reduce<Record<string, Level>>((acc, [id, value]) => (isLevel(value) ? { ...acc, [id]: value } : acc), {});
65
+ return Object.fromEntries(
66
+ Object.entries(raw).flatMap(([id, value]): [string, Level][] => {
67
+ if (isLevel(value)) return [[id, value]];
68
+ return isLimit(value) ? [[id, "normal"]] : [];
69
+ }),
70
+ );
54
71
  };
55
72
 
73
+ const limitsOf = (raw: unknown): Record<string, number> =>
74
+ isRecord(raw) ? Object.fromEntries(Object.entries(raw).flatMap(([id, value]): [string, number][] => (isLimit(value) ? [[id, value]] : []))) : {};
75
+
76
+ const unreadableOf = (raw: unknown): { id: string; value: string }[] =>
77
+ isRecord(raw)
78
+ ? Object.entries(raw)
79
+ .filter(([, value]) => !isLevel(value) && !isLimit(value))
80
+ .map(([id, value]) => ({ id, value: JSON.stringify(value) ?? String(value) }))
81
+ : [];
82
+
56
83
  const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
57
84
 
58
85
  /** files は 1 つの文字列でも配列でも書ける。 */
@@ -72,6 +99,18 @@ const toPathRule = (raw: unknown): PathRule | undefined => {
72
99
  /** 利用者が書く語の並び。空白だけのものは落とす。 */
73
100
  const wordsOf = (raw: unknown): string[] => (Array.isArray(raw) ? raw.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0) : []);
74
101
 
102
+ /** 両側とも空でない文字列の対だけを読む。同じ語どうし(「サーバ: サーバ」)は何も言えないので捨てる。 */
103
+ const preferOf = (raw: unknown): Record<string, string> =>
104
+ isRecord(raw)
105
+ ? Object.fromEntries(
106
+ Object.entries(raw).flatMap(([avoid, use]): [string, string][] => {
107
+ const from = avoid.trim();
108
+ const to = typeof use === "string" ? use.trim() : "";
109
+ return from !== "" && to !== "" && from !== to ? [[from, to]] : [];
110
+ }),
111
+ )
112
+ : {};
113
+
75
114
  const byPathOf = (raw: unknown): PathRule[] => (Array.isArray(raw) ? raw.map(toPathRule).filter((rule) => rule !== undefined) : []);
76
115
 
77
116
  /** 設定ファイルが無くても動く。あっても、既定から変えたものだけが書かれている。spec §18。 */
@@ -84,12 +123,15 @@ export const loadConfig = (path: string): Config => {
84
123
  genre: str(raw["genre"]),
85
124
  language: str(raw["language"]),
86
125
  rules: rulesOf(raw["rules"]),
126
+ limits: limitsOf(raw["rules"]),
127
+ unreadableRules: unreadableOf(raw["rules"]),
87
128
  experimental: raw["experimental"] === true,
88
129
  path,
89
130
  aiBackend: backend,
90
131
  aiModel: str(raw["ai_model"]) ?? defaultModel(backend),
91
132
  confidenceThreshold: typeof raw["confidence_threshold"] === "number" ? raw["confidence_threshold"] : 0.7,
92
133
  jargon: wordsOf(raw["jargon"]),
134
+ prefer: preferOf(raw["prefer"]),
93
135
  requiredSections: wordsOf(raw["required_sections"]),
94
136
  byPath: byPathOf(raw["by_path"]),
95
137
  baseDir: dirname(path),
@@ -0,0 +1,24 @@
1
+ import type { Config } from "./load.ts";
2
+
3
+ /**
4
+ * chaff.yaml の rules のうち、効いていないもの。知らない rule 名と、読めない値。
5
+ * どちらも黙って捨てると、書いた人は設定したつもりで何も変わっていない。打ち間違いがいちばん多い。
6
+ */
7
+ export const ruleProblems = (
8
+ config: Pick<Config, "rules" | "limits" | "unreadableRules" | "path">,
9
+ known: readonly { readonly id: string; readonly layer: string }[],
10
+ ): string[] => {
11
+ const knownIds = new Set(known.map((rule) => rule.id));
12
+ const where = config.path ?? "chaff.yaml";
13
+ const written = [...new Set([...Object.keys(config.rules), ...Object.keys(config.limits), ...config.unreadableRules.map((entry) => entry.id)])];
14
+ const unknown = written.filter((id) => !knownIds.has(id)).map((id) => `${where}: ${id} というルールはありません(npx chaff rules --json で一覧が出ます)`);
15
+ const unreadable = config.unreadableRules
16
+ .filter((entry) => knownIds.has(entry.id))
17
+ .map((entry) => `${where}: ${entry.id} の値 ${entry.value} は読めません(strict / normal / relaxed / off か、正の数)`);
18
+ // 意味を読む検査(L4)に閾値は無い。数を書いても normal として動くので、そう言う。
19
+ const semantic = new Set(known.filter((rule) => rule.layer === "L4").map((rule) => rule.id));
20
+ const numberOnSemantic = Object.keys(config.limits)
21
+ .filter((id) => semantic.has(id))
22
+ .map((id) => `${where}: ${id} は意味を読む検査なので数値の上限はありません。normal として動きます(strict / normal / relaxed / off で書いてください)`);
23
+ return [...unknown, ...unreadable, ...numberOnSemantic];
24
+ };
@@ -14,6 +14,8 @@ import { paragraphLength, paragraphVariance, preambleLength, ruleOfThree, sectio
14
14
  import { concreteEvidence, dashDensity, emojiDensity, ngramRepetition, undefinedAcronym } from "./signals.ts";
15
15
  import { aiTell, contractionMix, cushionDensity, hedgingDensity, repeatedConjunction, unqualifiedSuperlative } from "./lexicon.ts";
16
16
  import { internalJargon, properNounDensity, requiredSections } from "./team.ts";
17
+ import { latinSpacing, preferredTerm } from "./orthography.ts";
18
+ import { danglingReference, duplicateDefinition, numberingGap } from "./structure-tree.ts";
17
19
 
18
20
  /** rule 定義の how_to_find がここを引く。rule 側は実装を知らない。 */
19
21
  export const DETECTORS: Readonly<Record<string, Detector>> = {
@@ -53,4 +55,9 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
53
55
  "internal-jargon": internalJargon,
54
56
  "required-sections": requiredSections,
55
57
  "proper-noun-density": properNounDensity,
58
+ "preferred-term": preferredTerm,
59
+ "latin-spacing": latinSpacing,
60
+ "dangling-reference": danglingReference,
61
+ "numbering-gap": numberingGap,
62
+ "duplicate-definition": duplicateDefinition,
56
63
  };