chaffjs 0.0.1 → 0.1.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 (49) hide show
  1. package/dist/checks.d.ts +20 -0
  2. package/dist/checks.d.ts.map +1 -0
  3. package/dist/checks.js +58 -0
  4. package/dist/checks.js.map +1 -0
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +63 -0
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config/load.d.ts +4 -0
  9. package/dist/config/load.d.ts.map +1 -1
  10. package/dist/config/load.js +13 -1
  11. package/dist/config/load.js.map +1 -1
  12. package/dist/judge.d.ts +40 -0
  13. package/dist/judge.d.ts.map +1 -0
  14. package/dist/judge.js +0 -0
  15. package/dist/judge.js.map +1 -0
  16. package/dist/plugin.d.ts +2 -0
  17. package/dist/plugin.d.ts.map +1 -1
  18. package/dist/render/semantic.d.ts +7 -0
  19. package/dist/render/semantic.d.ts.map +1 -0
  20. package/dist/render/semantic.js +50 -0
  21. package/dist/render/semantic.js.map +1 -0
  22. package/dist/rule-load.d.ts.map +1 -1
  23. package/dist/rule-load.js +12 -3
  24. package/dist/rule-load.js.map +1 -1
  25. package/dist/run-semantic.d.ts +17 -0
  26. package/dist/run-semantic.d.ts.map +1 -0
  27. package/dist/run-semantic.js +71 -0
  28. package/dist/run-semantic.js.map +1 -0
  29. package/dist/run.d.ts.map +1 -1
  30. package/dist/run.js +4 -0
  31. package/dist/run.js.map +1 -1
  32. package/dist/semantic.d.ts +25 -0
  33. package/dist/semantic.d.ts.map +1 -0
  34. package/dist/semantic.js +55 -0
  35. package/dist/semantic.js.map +1 -0
  36. package/package.json +4 -3
  37. package/rules/empty-conclusion.yaml +37 -0
  38. package/rules/risk-disclosure.yaml +41 -0
  39. package/rules/unsourced-number.yaml +40 -0
  40. package/src/checks.ts +74 -0
  41. package/src/cli.ts +74 -0
  42. package/src/config/load.ts +16 -1
  43. package/src/judge.ts +0 -0
  44. package/src/plugin.ts +2 -0
  45. package/src/render/semantic.ts +60 -0
  46. package/src/rule-load.ts +14 -3
  47. package/src/run-semantic.ts +107 -0
  48. package/src/run.ts +4 -0
  49. package/src/semantic.ts +74 -0
package/src/rule-load.ts CHANGED
@@ -10,15 +10,25 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
10
10
 
11
11
  const isLocalized = (value: unknown): value is Record<string, string> => isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
12
12
 
13
+ const SEVERITY_BY_NAME: Readonly<Record<string, number>> = { info: 1, warning: 2, error: 3 };
14
+
15
+ /** L4 は件数ではなく深刻度を段で持つ。error/warning/info を数に写して同じ器に入れる。 */
16
+ const asNumber = (entry: unknown): number | undefined => {
17
+ if (typeof entry === "number") return entry;
18
+ return typeof entry === "string" ? SEVERITY_BY_NAME[entry] : undefined;
19
+ };
20
+
13
21
  const isLevelTable = (value: unknown): value is LevelTable =>
14
- isRecord(value) && Object.entries(value).every(([key, entry]) => ["strict", "normal", "relaxed"].includes(key) && typeof entry === "number");
22
+ isRecord(value) && Object.entries(value).every(([key, entry]) => ["strict", "normal", "relaxed"].includes(key) && asNumber(entry) !== undefined);
15
23
 
16
24
  /** 言語別の levels を、その言語のぶんだけに平坦化する。単位が言語で違う rule のため。 */
25
+ const normalize = (table: LevelTable): LevelTable => Object.fromEntries(Object.entries(table).map(([key, entry]) => [key, asNumber(entry) ?? 0]));
26
+
17
27
  const flattenLevels = (raw: unknown, language: string): LevelTable | undefined => {
18
- if (isLevelTable(raw)) return raw;
28
+ if (isLevelTable(raw)) return normalize(raw);
19
29
  if (!isRecord(raw)) return undefined;
20
30
  const forLanguage = raw[language] ?? raw["default"];
21
- return isLevelTable(forLanguage) ? forLanguage : undefined;
31
+ return isLevelTable(forLanguage) ? normalize(forLanguage) : undefined;
22
32
  };
23
33
 
24
34
  const SEVERITIES: readonly Severity[] = ["error", "warning", "info"];
@@ -65,6 +75,7 @@ const toRule = (raw: unknown, language: string, file: string): RuleDefinition =>
65
75
  levels,
66
76
  how_to_find: String(raw["how_to_find"]),
67
77
  word_list: typeof raw["word_list"] === "string" ? raw["word_list"] : undefined,
78
+ what_to_check: isLocalized(raw["what_to_check"]) ? raw["what_to_check"] : undefined,
68
79
  where: typeof raw["where"] === "string" ? raw["where"] : undefined,
69
80
  use_for: Array.isArray(raw["use_for"]) ? raw["use_for"].map((entry) => String(entry)) : [],
70
81
  severity: isSeverity(raw["severity"]) ? raw["severity"] : "warning",
@@ -0,0 +1,107 @@
1
+ import { severityOf, type UserCheck } from "./checks.ts";
2
+ import { ask, type JudgeOptions } from "./judge.ts";
3
+ import { FILTERS, type Candidate } from "./semantic.ts";
4
+ import { localized } from "./render/text.ts";
5
+ import type { Finding, Level, ProseDocument, RuleDefinition, Severity } from "./plugin.ts";
6
+ import { lineStarts, placeOf } from "./position.ts";
7
+
8
+ export type SemanticResult = {
9
+ readonly findings: readonly Finding[];
10
+ readonly skipped: readonly { rule: string; why: string }[];
11
+ readonly asked: number;
12
+ readonly sentencesSeen: number;
13
+ };
14
+
15
+ type Job = {
16
+ readonly rule: string;
17
+ readonly name: string;
18
+ readonly rubric: string;
19
+ readonly howToFix: string;
20
+ readonly severity: Severity;
21
+ readonly candidates: readonly Candidate[];
22
+ };
23
+
24
+ const filterFor = (name: string | undefined): (typeof FILTERS)[string] => FILTERS[name ?? "whole-document"] ?? FILTERS["whole-document"] ?? (() => []);
25
+
26
+ const SEVERITY_BY_VALUE: readonly Severity[] = ["info", "info", "warning", "error"];
27
+
28
+ const severityFromLevel = (rule: RuleDefinition, level: Level): Severity => {
29
+ if (level === "off") return "info";
30
+ const value = rule.levels[level] ?? rule.levels.normal ?? 2;
31
+ return SEVERITY_BY_VALUE[Math.min(3, Math.max(1, Math.round(value)))] ?? "warning";
32
+ };
33
+
34
+ const builtInJobs = (doc: ProseDocument, rules: readonly RuleDefinition[], settings: Readonly<Record<string, Level>>, genre: string): Job[] =>
35
+ rules
36
+ .filter((rule) => rule.layer === "L4" && rule.use_for.some((target) => genre.startsWith(target)) && settings[rule.id] !== "off")
37
+ .flatMap((rule) => {
38
+ const rubric = rule.what_to_check === undefined ? undefined : localized(rule.what_to_check, doc.language);
39
+ if (rubric === undefined || rubric.length === 0) return [];
40
+ return [
41
+ {
42
+ rule: rule.id,
43
+ name: localized(rule.name, doc.language),
44
+ rubric,
45
+ howToFix: localized(rule.how_to_fix, doc.language),
46
+ severity: severityFromLevel(rule, settings[rule.id] ?? "normal"),
47
+ candidates: filterFor(rule.how_to_find)(doc),
48
+ },
49
+ ];
50
+ });
51
+
52
+ const userJobs = (doc: ProseDocument, checks: readonly UserCheck[], genre: string): Job[] =>
53
+ checks
54
+ .filter((check) => check.level !== "off" && check.use_for.some((target) => genre.startsWith(target)))
55
+ .map((check) => ({
56
+ rule: check.id,
57
+ name: check.name,
58
+ // look_at は自然文なので、まだ絞り込みに変換できない。文書全体を渡す。spec §26-7。
59
+ rubric: check.look_at === undefined ? check.check : `${check.check}\n\n見るところ: ${check.look_at}`,
60
+ howToFix: check.how_to_fix,
61
+ severity: severityOf(check.level),
62
+ candidates: filterFor("whole-document")(doc),
63
+ }));
64
+
65
+ const findingOf = (job: Job, candidate: Candidate, reason: string, confidence: number, threshold: number, starts: readonly number[]): Finding => {
66
+ const at = placeOf(starts, candidate.offset);
67
+ // 確からしさが低いものは指摘として出すが、重さを下げる。spec §14。
68
+ const severity = confidence < threshold ? "info" : job.severity;
69
+ return {
70
+ rule: job.rule,
71
+ severity,
72
+ line: at.line,
73
+ column: at.column,
74
+ quote: candidate.text.slice(0, 200),
75
+ values: { reason, confidence: confidence.toFixed(2), offset: candidate.offset },
76
+ };
77
+ };
78
+
79
+ export type SemanticOptions = JudgeOptions & { readonly confidenceThreshold: number };
80
+
81
+ export const runSemantic = async (
82
+ doc: ProseDocument,
83
+ rules: readonly RuleDefinition[],
84
+ checks: readonly UserCheck[],
85
+ settings: Readonly<Record<string, Level>>,
86
+ genre: string,
87
+ options: SemanticOptions,
88
+ ): Promise<SemanticResult> => {
89
+ const jobs = [...builtInJobs(doc, rules, settings, genre), ...userJobs(doc, checks, genre)];
90
+ const starts = lineStarts(doc.source);
91
+ const skipped = jobs.filter((job) => job.candidates.length === 0).map((job) => ({ rule: job.rule, why: "見るところが無かったため" }));
92
+ const live = jobs.filter((job) => job.candidates.length > 0);
93
+ const answers = await Promise.all(
94
+ live.flatMap((job) =>
95
+ job.candidates.map(async (candidate) => {
96
+ const verdict = await ask({ rule: job.rule, rubric: job.rubric, candidate: candidate.text, language: doc.language }, options);
97
+ return verdict.violated ? findingOf(job, candidate, verdict.reason, verdict.confidence, options.confidenceThreshold, starts) : undefined;
98
+ }),
99
+ ),
100
+ );
101
+ return {
102
+ findings: answers.filter((finding) => finding !== undefined).sort((left, right) => left.line - right.line),
103
+ skipped,
104
+ asked: live.reduce((sum, job) => sum + job.candidates.length, 0),
105
+ sentencesSeen: doc.sentences.length,
106
+ };
107
+ };
package/src/run.ts CHANGED
@@ -36,6 +36,10 @@ export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], s
36
36
  .map((rule) => rule.id);
37
37
  const outcome = applicable.reduce<{ findings: Finding[]; skipped: Skipped[] }>(
38
38
  (acc, rule) => {
39
+ // L4 は意味を読む検査。chaff test が扱う。ここで「検出器が無い」と言わせない。
40
+ if (rule.layer === "L4") {
41
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: "意味を読む検査のため(npx chaff test で動きます)" }] };
42
+ }
39
43
  const level = levelFor(rule, settings, experimental);
40
44
  if (level === "off") {
41
45
  const why = rule.status === "experimental" && settings[rule.id] === undefined ? "まだ試験中のため" : "設定で止めているため";
@@ -0,0 +1,74 @@
1
+ import type { ProseDocument, Section } from "./plugin.ts";
2
+
3
+ /**
4
+ * 二段構えの 1 段目。文書全体を LLM に投げない。spec §14。
5
+ *
6
+ * 絞り込みを持たない L4 rule は登録できない。持たせないと、1 記事ごとに全文を
7
+ * 読ませることになり、速度もコストも成り立たない。
8
+ */
9
+ export type Candidate = { readonly text: string; readonly offset: number };
10
+
11
+ export type Filter = (doc: ProseDocument) => Candidate[];
12
+
13
+ const heads = (doc: ProseDocument): string => doc.sections.map((section) => section.heading).join(" ");
14
+
15
+ const sectionText = (doc: ProseDocument, section: Section): string => doc.source.slice(section.span.start, section.span.end).trim();
16
+
17
+ /** 見出しに答えが書いてあるなら、本文を読ませる必要はない。 */
18
+ const skipIfHeadingMatches = (doc: ProseDocument, pattern: RegExp): boolean => pattern.test(heads(doc));
19
+
20
+ const RISK_HEADING = /リスク|懸念|デメリット|risk|tradeoff|downside/iu;
21
+
22
+ /** 提案文書にリスクが書かれているか。見出しにあれば見ない。 */
23
+ export const riskDisclosure: Filter = (doc) => {
24
+ if (skipIfHeadingMatches(doc, RISK_HEADING)) return [];
25
+ const body = doc.sentences.map((sentence) => sentence.text.trim()).join("");
26
+ if (body.length === 0) return [];
27
+ return [{ text: body.slice(0, 4000), offset: doc.sentences[0]?.span.start ?? 0 }];
28
+ };
29
+
30
+ const EVIDENCE = /\d|`|https?:\/\//u;
31
+
32
+ /**
33
+ * 結びが本文の要約でしかないか。
34
+ * 最後の節だけを見て、そこに具体物が無いときにだけ問い合わせる。
35
+ */
36
+ export const emptyConclusion: Filter = (doc) => {
37
+ const last = doc.sections.at(-1);
38
+ if (last === undefined || last.sentences.length === 0) return [];
39
+ const text = sectionText(doc, last);
40
+ if (EVIDENCE.test(text)) return [];
41
+ return [{ text: text.slice(0, 2000), offset: last.sentences[0]?.span.start ?? last.span.start }];
42
+ };
43
+
44
+ // \s* を挟むとバックトラックする。単位の前の空白は 1 つまでで足りる。
45
+ const NUMBER = /\d ?(?:%|%|倍|割|件|人|円|ドル|時間|分|秒)/u;
46
+ const EFFECT = /向上|改善|削減|短縮|増加|減少|成長|伸び/u;
47
+ /**
48
+ * 根拠と認めるもの。ここを狭く取ると、根拠つきの文まで LLM に送ってしまう。
49
+ * 二段構えの 1 段目が防ぐべきなのは、まさにその無駄。
50
+ */
51
+ const EVIDENCE_WORDS = ["出典", "調査", "自社調べ", "参考", "期間", "比較", "対比", "前年", "昨年", "同期", "前月", "前四半期", "vs"];
52
+ const EVIDENCE_SHAPE = /n ?=|\[\d+\]|\d{4} ?年|Q[1-4]/iu;
53
+
54
+ /** 語の照合は正規表現にしない。数が増えるほど式が読めなくなり、複雑度も上がる。 */
55
+ const hasEvidence = (text: string): boolean => EVIDENCE_WORDS.some((word) => text.includes(word)) || EVIDENCE_SHAPE.test(text);
56
+
57
+ /** 効果を主張する数値に根拠が無い文だけを渡す。 */
58
+ export const unsourcedNumber: Filter = (doc) =>
59
+ doc.sentences
60
+ .filter((sentence) => NUMBER.test(sentence.text) && EFFECT.test(sentence.text) && !hasEvidence(sentence.text))
61
+ .map((sentence) => ({ text: sentence.text.trim(), offset: sentence.span.start }));
62
+
63
+ /** 利用者が checks.yaml に書いた検査の既定。絞り込みを書かなければ文書全体。 */
64
+ export const wholeDocument: Filter = (doc) => {
65
+ const body = doc.sentences.map((sentence) => sentence.text.trim()).join("");
66
+ return body.length === 0 ? [] : [{ text: body.slice(0, 4000), offset: doc.sentences[0]?.span.start ?? 0 }];
67
+ };
68
+
69
+ export const FILTERS: Readonly<Record<string, Filter>> = {
70
+ "risk-disclosure": riskDisclosure,
71
+ "empty-conclusion": emptyConclusion,
72
+ "unsourced-number": unsourcedNumber,
73
+ "whole-document": wholeDocument,
74
+ };