chaffjs 0.2.0 → 0.3.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/backends/anthropic.d.ts +24 -0
- package/dist/backends/anthropic.d.ts.map +1 -0
- package/dist/backends/anthropic.js +44 -0
- package/dist/backends/anthropic.js.map +1 -0
- package/dist/backends/openai.d.ts +29 -0
- package/dist/backends/openai.d.ts.map +1 -0
- package/dist/backends/openai.js +33 -0
- package/dist/backends/openai.js.map +1 -0
- package/dist/backends/types.d.ts +32 -0
- package/dist/backends/types.d.ts.map +1 -0
- package/dist/backends/types.js +18 -0
- package/dist/backends/types.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +4 -1
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +60 -14
- package/dist/commands/test.js.map +1 -1
- package/dist/config/load.d.ts +4 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +10 -3
- package/dist/config/load.js.map +1 -1
- package/dist/detectors/agentless-passive.d.ts +3 -0
- package/dist/detectors/agentless-passive.d.ts.map +1 -0
- package/dist/detectors/agentless-passive.js +31 -0
- package/dist/detectors/agentless-passive.js.map +1 -0
- package/dist/detectors/char-shape.d.ts +12 -0
- package/dist/detectors/char-shape.d.ts.map +1 -0
- package/dist/detectors/char-shape.js +39 -0
- package/dist/detectors/char-shape.js.map +1 -0
- package/dist/detectors/en-shape.d.ts +19 -0
- package/dist/detectors/en-shape.d.ts.map +1 -0
- package/dist/detectors/en-shape.js +176 -0
- package/dist/detectors/en-shape.js.map +1 -0
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +33 -0
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/lexicon.d.ts +15 -0
- package/dist/detectors/lexicon.d.ts.map +1 -0
- package/dist/detectors/lexicon.js +109 -0
- package/dist/detectors/lexicon.js.map +1 -0
- package/dist/detectors/repeated-head.d.ts.map +1 -1
- package/dist/detectors/repeated-head.js +17 -5
- package/dist/detectors/repeated-head.js.map +1 -1
- package/dist/detectors/sentence-ending.d.ts +3 -0
- package/dist/detectors/sentence-ending.d.ts.map +1 -0
- package/dist/detectors/sentence-ending.js +34 -0
- package/dist/detectors/sentence-ending.js.map +1 -0
- package/dist/detectors/signals.d.ts +6 -0
- package/dist/detectors/signals.d.ts.map +1 -0
- package/dist/detectors/signals.js +183 -0
- package/dist/detectors/signals.js.map +1 -0
- package/dist/detectors/structure.d.ts +24 -0
- package/dist/detectors/structure.d.ts.map +1 -0
- package/dist/detectors/structure.js +111 -0
- package/dist/detectors/structure.js.map +1 -0
- package/dist/detectors/token-shape.d.ts +4 -0
- package/dist/detectors/token-shape.d.ts.map +1 -0
- package/dist/detectors/token-shape.js +62 -0
- package/dist/detectors/token-shape.js.map +1 -0
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +76 -8
- package/dist/document.js.map +1 -1
- package/dist/env.d.ts +12 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +20 -0
- package/dist/env.js.map +1 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +9 -6
- package/dist/init.js.map +1 -1
- package/dist/judge.d.ts +14 -16
- package/dist/judge.d.ts.map +1 -1
- package/dist/judge.js +0 -0
- package/dist/judge.js.map +1 -1
- package/dist/levels.d.ts +1 -9
- package/dist/levels.d.ts.map +1 -1
- package/dist/levels.js +15 -3
- package/dist/levels.js.map +1 -1
- package/dist/look-at.d.ts +18 -0
- package/dist/look-at.d.ts.map +1 -0
- package/dist/look-at.js +43 -0
- package/dist/look-at.js.map +1 -0
- package/dist/plugin.d.ts +44 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/render/explain.d.ts +1 -1
- package/dist/render/explain.d.ts.map +1 -1
- package/dist/render/explain.js +12 -4
- package/dist/render/explain.js.map +1 -1
- package/dist/render/rules-json.d.ts.map +1 -1
- package/dist/render/rules-json.js +13 -5
- package/dist/render/rules-json.js.map +1 -1
- package/dist/render/semantic.d.ts +2 -1
- package/dist/render/semantic.d.ts.map +1 -1
- package/dist/render/semantic.js +35 -0
- package/dist/render/semantic.js.map +1 -1
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +27 -1
- package/dist/rule-load.js.map +1 -1
- package/dist/run-semantic.d.ts +22 -1
- package/dist/run-semantic.d.ts.map +1 -1
- package/dist/run-semantic.js +21 -10
- package/dist/run-semantic.js.map +1 -1
- package/dist/run.d.ts +6 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +34 -2
- package/dist/run.js.map +1 -1
- package/dist/sentence-shape.d.ts +6 -0
- package/dist/sentence-shape.d.ts.map +1 -0
- package/dist/sentence-shape.js +15 -0
- package/dist/sentence-shape.js.map +1 -0
- package/package.json +4 -3
- package/rules/adverb-overuse.yaml +28 -0
- package/rules/agentless-passive.yaml +28 -0
- package/rules/ai-tell.yaml +27 -0
- package/rules/concrete-evidence-density.yaml +25 -0
- package/rules/cushion-phrase-density.yaml +26 -0
- package/rules/double-keigo.yaml +27 -0
- package/rules/emoji-density.yaml +26 -0
- package/rules/excessive-hedging.yaml +26 -0
- package/rules/expletive-construction.yaml +27 -0
- package/rules/hiragana-fukushi.yaml +27 -0
- package/rules/max-kanji-continuous.yaml +26 -0
- package/rules/max-paragraph-length.yaml +25 -0
- package/rules/max-sentence-length.yaml +22 -0
- package/rules/ngram-repetition.yaml +25 -0
- package/rules/no-doubled-joshi.yaml +28 -0
- package/rules/no-mixed-desumasu.yaml +29 -0
- package/rules/no-nakaguro-parallel.yaml +27 -0
- package/rules/oxford-comma-consistency.yaml +26 -0
- package/rules/paragraph-length-variance.yaml +25 -0
- package/rules/preamble-length.yaml +25 -0
- package/rules/repeated-conjunction.yaml +26 -0
- package/rules/rule-of-three.yaml +25 -0
- package/rules/sasete-itadaku.yaml +27 -0
- package/rules/section-length-uniformity.yaml +25 -0
- package/rules/sentence-initial-conjunction-run.yaml +27 -0
- package/rules/taigen-dome-in-prose.yaml +27 -0
- package/rules/title-case-consistency.yaml +26 -0
- package/rules/undefined-acronym.yaml +25 -0
- package/rules/unqualified-superlative.yaml +26 -0
- package/src/backends/anthropic.ts +61 -0
- package/src/backends/openai.ts +46 -0
- package/src/backends/types.ts +38 -0
- package/src/cli.ts +8 -4
- package/src/commands/eval.ts +4 -1
- package/src/commands/test.ts +72 -14
- package/src/config/load.ts +11 -3
- package/src/detectors/agentless-passive.ts +34 -0
- package/src/detectors/char-shape.ts +47 -0
- package/src/detectors/en-shape.ts +187 -0
- package/src/detectors/index.ts +33 -0
- package/src/detectors/lexicon.ts +124 -0
- package/src/detectors/repeated-head.ts +17 -6
- package/src/detectors/sentence-ending.ts +37 -0
- package/src/detectors/signals.ts +199 -0
- package/src/detectors/structure.ts +119 -0
- package/src/detectors/token-shape.ts +81 -0
- package/src/document.ts +81 -9
- package/src/env.ts +20 -0
- package/src/init.ts +9 -6
- package/src/judge.ts +0 -0
- package/src/levels.ts +16 -4
- package/src/look-at.ts +53 -0
- package/src/plugin.ts +45 -3
- package/src/render/explain.ts +12 -4
- package/src/render/rules-json.ts +16 -5
- package/src/render/semantic.ts +38 -1
- package/src/rule-load.ts +28 -1
- package/src/run-semantic.ts +32 -11
- package/src/run.ts +36 -3
- package/src/sentence-shape.ts +22 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
id: unqualified-superlative
|
|
2
|
+
layer: L2
|
|
3
|
+
status: experimental
|
|
4
|
+
how_to_find: unqualified-superlative
|
|
5
|
+
word_list: superlative
|
|
6
|
+
severity: warning
|
|
7
|
+
|
|
8
|
+
name:
|
|
9
|
+
ja: 比べる相手のない最上級
|
|
10
|
+
en: Superlative with nothing to compare
|
|
11
|
+
|
|
12
|
+
why:
|
|
13
|
+
ja: 「最も速い」だけでは、何と比べて最もなのかがありません。比較対象も条件も無い最上級は、読み手に確かめようがありません。
|
|
14
|
+
en: '"The fastest" without saying than what leaves the reader nothing to check.'
|
|
15
|
+
|
|
16
|
+
message:
|
|
17
|
+
ja: 「{matched}」に比べる相手がありません(そういう言い方が {count} 箇所)
|
|
18
|
+
en: '"{matched}" has nothing to compare against ({count} such claims)'
|
|
19
|
+
|
|
20
|
+
how_to_fix:
|
|
21
|
+
ja: 比較対象か条件を添えてください。「社内の 3 案のうち最も速い」のように。
|
|
22
|
+
en: Name the comparison. "The fastest of the three options we tried."
|
|
23
|
+
|
|
24
|
+
levels: { strict: 1, normal: 2, relaxed: 4 }
|
|
25
|
+
|
|
26
|
+
use_for: [business, blog]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
5
|
+
import { toFailure } from "./types.ts";
|
|
6
|
+
import type { Failure, Judge, Prompt } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export type AnthropicResponse = { readonly content: readonly { readonly type: string; readonly text?: string | undefined }[] };
|
|
9
|
+
|
|
10
|
+
/** judge が使うのは messages.create だけ。最小の面だけを要求する。テストで差し替えられる。 */
|
|
11
|
+
export type AnthropicClient = { readonly messages: { readonly create: (params: Anthropic.MessageCreateParamsNonStreaming) => Promise<AnthropicResponse> } };
|
|
12
|
+
|
|
13
|
+
const isTextBlock = (block: unknown): block is { readonly text: string } =>
|
|
14
|
+
typeof block === "object" && block !== null && "text" in block && typeof block.text === "string";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_MODEL = "claude-opus-5";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 認証情報があるか。
|
|
20
|
+
*
|
|
21
|
+
* env var を見るだけでは足りない。SDK は ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN /
|
|
22
|
+
* `ant auth login` のプロファイル / Workload Identity の順に解決するので、env が
|
|
23
|
+
* 無くても通ることがある。逆に、無いまま呼ぶと SDK は型の無い素の Error を投げる
|
|
24
|
+
* (AnthropicError でも APIError でもない)ので、あとから種類で判別できない。
|
|
25
|
+
* だから先に確かめる。
|
|
26
|
+
*/
|
|
27
|
+
const ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_IDENTITY_TOKEN", "ANTHROPIC_IDENTITY_TOKEN_FILE"];
|
|
28
|
+
|
|
29
|
+
export const configDir = (): string => process.env["ANTHROPIC_CONFIG_DIR"] ?? join(homedir(), ".config", "anthropic");
|
|
30
|
+
|
|
31
|
+
export const hasCredentials = (profileDir: string = configDir()): boolean =>
|
|
32
|
+
ENV_KEYS.some((name) => (process.env[name] ?? "").length > 0) || existsSync(profileDir);
|
|
33
|
+
|
|
34
|
+
export const isAuthFailure = (error: unknown): boolean =>
|
|
35
|
+
error instanceof Anthropic.AuthenticationError || (error instanceof Anthropic.APIError && error.status === 401);
|
|
36
|
+
|
|
37
|
+
/** API が返した失敗だけを拾う。ネットワーク断やコードの誤りは、握りつぶさずそのまま投げる。 */
|
|
38
|
+
export const describeFailure = (error: unknown): Failure | undefined => {
|
|
39
|
+
if (!(error instanceof Anthropic.APIError)) return undefined;
|
|
40
|
+
const raw: { status: unknown; message: unknown } = error;
|
|
41
|
+
return toFailure(raw);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const judge =
|
|
45
|
+
(client?: AnthropicClient): Judge =>
|
|
46
|
+
async (prompt: Prompt): Promise<string> => {
|
|
47
|
+
const anthropic: AnthropicClient = client ?? new Anthropic();
|
|
48
|
+
const response = await anthropic.messages.create({
|
|
49
|
+
model: prompt.model,
|
|
50
|
+
max_tokens: 1024,
|
|
51
|
+
system: prompt.system,
|
|
52
|
+
output_config: { format: { type: "json_schema", schema: prompt.schema } },
|
|
53
|
+
messages: [{ role: "user", content: prompt.user }],
|
|
54
|
+
});
|
|
55
|
+
const body = response.content.map((block) => (isTextBlock(block) ? block.text : undefined)).find((text) => text !== undefined);
|
|
56
|
+
if (body === undefined) throw new Error("判定が返りませんでした");
|
|
57
|
+
return body;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** 認証が無いときに人へ見せる案内。 */
|
|
61
|
+
export const SETUP_HINT = "ANTHROPIC_API_KEY を設定するか、ant auth login を実行してください。";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { toFailure } from "./types.ts";
|
|
3
|
+
import type { Failure, Judge, Prompt } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type OpenAIResponse = { readonly choices: readonly { readonly message: { readonly content: string | null } }[] };
|
|
6
|
+
|
|
7
|
+
/** judge が使うのは chat.completions.create だけ。最小の面だけを要求する。 */
|
|
8
|
+
export type OpenAIClient = {
|
|
9
|
+
readonly chat: { readonly completions: { readonly create: (params: OpenAI.ChatCompletionCreateParamsNonStreaming) => Promise<OpenAIResponse> } };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MODEL = "gpt-5";
|
|
13
|
+
|
|
14
|
+
export const hasCredentials = (): boolean => (process.env["OPENAI_API_KEY"] ?? "").length > 0;
|
|
15
|
+
|
|
16
|
+
export const isAuthFailure = (error: unknown): boolean => error instanceof OpenAI.APIError && error.status === 401;
|
|
17
|
+
|
|
18
|
+
/** API が返した失敗だけを拾う。ネットワーク断やコードの誤りは、握りつぶさずそのまま投げる。 */
|
|
19
|
+
export const describeFailure = (error: unknown): Failure | undefined => {
|
|
20
|
+
if (!(error instanceof OpenAI.APIError)) return undefined;
|
|
21
|
+
const raw: { status: unknown; message: unknown } = error;
|
|
22
|
+
return toFailure(raw);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 同じ JSON schema をそのまま使う。包みかたが違うだけで、返させる形は provider で変えない。
|
|
27
|
+
* 変えると、provider を替えたときに判定の意味まで変わる。
|
|
28
|
+
*/
|
|
29
|
+
export const judge =
|
|
30
|
+
(client?: OpenAIClient): Judge =>
|
|
31
|
+
async (prompt: Prompt): Promise<string> => {
|
|
32
|
+
const openai: OpenAIClient = client ?? new OpenAI();
|
|
33
|
+
const response = await openai.chat.completions.create({
|
|
34
|
+
model: prompt.model,
|
|
35
|
+
messages: [
|
|
36
|
+
{ role: "system", content: prompt.system },
|
|
37
|
+
{ role: "user", content: prompt.user },
|
|
38
|
+
],
|
|
39
|
+
response_format: { type: "json_schema", json_schema: { name: "verdict", schema: prompt.schema, strict: true } },
|
|
40
|
+
});
|
|
41
|
+
const body = response.choices[0]?.message.content;
|
|
42
|
+
if (body === null || body === undefined) throw new Error("判定が返りませんでした");
|
|
43
|
+
return body;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const SETUP_HINT = "OPENAI_API_KEY を設定してください。";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 判定役の面。ここまでが provider ごとに違い、ここから先は共通。
|
|
3
|
+
*
|
|
4
|
+
* 返すのは JSON の文字列で、Verdict への変換と検証は judge.ts が 1 箇所で行う。
|
|
5
|
+
* provider が増えても「返ってきた形の確かめかた」は増やさない。
|
|
6
|
+
*/
|
|
7
|
+
export type JsonSchema = Readonly<Record<string, unknown>>;
|
|
8
|
+
|
|
9
|
+
export type Prompt = { readonly system: string; readonly user: string; readonly schema: JsonSchema; readonly model: string };
|
|
10
|
+
|
|
11
|
+
export type Judge = (prompt: Prompt) => Promise<string>;
|
|
12
|
+
|
|
13
|
+
export type BackendName = "anthropic" | "openai";
|
|
14
|
+
|
|
15
|
+
export const BACKENDS: readonly BackendName[] = ["anthropic", "openai"];
|
|
16
|
+
|
|
17
|
+
export const isBackend = (value: unknown): value is BackendName => BACKENDS.some((name) => name === value);
|
|
18
|
+
|
|
19
|
+
/** API が返した失敗。生のスタックトレースを人に見せないための、最小の分類。 */
|
|
20
|
+
export type Failure = { readonly kind: "auth" | "quota" | "api"; readonly status: number | undefined; readonly message: string };
|
|
21
|
+
|
|
22
|
+
const statusOf = (value: unknown): number | undefined => (typeof value === "number" ? value : undefined);
|
|
23
|
+
|
|
24
|
+
const messageOf = (value: unknown): string => (typeof value === "string" && value.length > 0 ? value : "原因は返ってきませんでした");
|
|
25
|
+
|
|
26
|
+
const kindOf = (status: number | undefined): Failure["kind"] => {
|
|
27
|
+
if (status === 401 || status === 403) return "auth";
|
|
28
|
+
return status === 429 ? "quota" : "api";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* SDK の APIError から Failure を作る。分類は provider で変えない。
|
|
33
|
+
* status と message は SDK 上 any なので、ここで 1 度だけ形を確かめる。
|
|
34
|
+
*/
|
|
35
|
+
export const toFailure = (error: { readonly status: unknown; readonly message: unknown }): Failure => {
|
|
36
|
+
const status = statusOf(error.status);
|
|
37
|
+
return { kind: kindOf(status), status, message: messageOf(error.message) };
|
|
38
|
+
};
|
package/src/cli.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { renderExplain } from "./render/explain.ts";
|
|
|
22
22
|
import { renderFriendly } from "./render/friendly.ts";
|
|
23
23
|
import { rulesJson } from "./render/rules-json.ts";
|
|
24
24
|
import { renderSummary, type FileOutcome } from "./render/summary.ts";
|
|
25
|
-
import { runRules } from "./run.ts";
|
|
25
|
+
import { neededBy, runRules } from "./run.ts";
|
|
26
26
|
import type { Level, RuleDefinition } from "./plugin.ts";
|
|
27
27
|
|
|
28
28
|
const USAGE = `chaff — 文章の読みにくいところを見つけます。文章は書き換えません。
|
|
@@ -30,6 +30,7 @@ const USAGE = `chaff — 文章の読みにくいところを見つけます。
|
|
|
30
30
|
chaff <file|dir|glob>... 検査する(設定も API key も要りません)
|
|
31
31
|
chaff . この場所の Markdown を全部
|
|
32
32
|
chaff test <file|dir>... 意味を読む検査も動かす(API key が要ります)
|
|
33
|
+
判定役は chaff.yaml の ai_backend で選びます
|
|
33
34
|
chaff init chaff.yaml を作る
|
|
34
35
|
chaff eval <dir> 手元の文書で閾値を測り直す
|
|
35
36
|
chaff explain <rule> そのルールの意図と根拠を読む
|
|
@@ -43,6 +44,7 @@ const USAGE = `chaff — 文章の読みにくいところを見つけます。
|
|
|
43
44
|
--experimental 試験中の rule も動かす
|
|
44
45
|
--show-baseline 棚上げした分も含めて全部見る
|
|
45
46
|
--watch 保存のたびに見直し、変わったところだけ出す
|
|
47
|
+
--dry-run test で、何を AI に送るかだけを見る(API を呼びません)
|
|
46
48
|
|
|
47
49
|
この箇所だけ黙らせる: <!-- stet: rule-id — 理由 -->
|
|
48
50
|
|
|
@@ -92,9 +94,11 @@ const inspect = async (path: string, config: Config, argv: readonly string[]): P
|
|
|
92
94
|
const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
|
|
93
95
|
const adapter = await loadAdapter(language);
|
|
94
96
|
const { genre, from } = resolveGenre(path, source, config);
|
|
95
|
-
const doc = buildDocument(path, source, adapter);
|
|
96
97
|
const rules = loadRules(language);
|
|
97
|
-
const
|
|
98
|
+
const experimental = config.experimental || argv.includes("--experimental");
|
|
99
|
+
await adapter.prepare?.(neededBy(rules, config.rules, experimental, genre, language));
|
|
100
|
+
const doc = buildDocument(path, source, adapter);
|
|
101
|
+
const raw = runRules(doc, rules, config.rules, experimental, genre);
|
|
98
102
|
// 応答は 3 つ。stet で黙らせたものは、ここで落とす。
|
|
99
103
|
const applied = applySuppressions(
|
|
100
104
|
source,
|
|
@@ -177,7 +181,7 @@ const explain = (ruleId: string | undefined): number => {
|
|
|
177
181
|
return 1;
|
|
178
182
|
}
|
|
179
183
|
const current = config.rules[rule.id] ?? (rule.status === "experimental" && !config.experimental ? "off" : "normal");
|
|
180
|
-
console.log(renderExplain(rule, current, language, unitOf(rule.id, language)));
|
|
184
|
+
console.log(renderExplain(rule, current, language, unitOf(rule.id, language), config.genre));
|
|
181
185
|
return 0;
|
|
182
186
|
};
|
|
183
187
|
|
package/src/commands/eval.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { collectTargets } from "../files.ts";
|
|
|
7
7
|
import { loadRules } from "../rule-load.ts";
|
|
8
8
|
import { evaluate } from "../eval.ts";
|
|
9
9
|
import { renderEval } from "../render/eval.ts";
|
|
10
|
+
import { neededBy } from "../run.ts";
|
|
10
11
|
import type { Config } from "../config/load.ts";
|
|
11
12
|
|
|
12
13
|
export type Context = {
|
|
@@ -32,7 +33,9 @@ export const runEval = async (targets: readonly string[], argv: readonly string[
|
|
|
32
33
|
const source = await readFile(path, "utf8");
|
|
33
34
|
const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
|
|
34
35
|
const adapter = await loadAdapter(language);
|
|
35
|
-
|
|
36
|
+
const { genre } = resolveGenre(path, source, config);
|
|
37
|
+
await adapter.prepare?.(neededBy(loadRules(language), config.rules, config.experimental, genre, language));
|
|
38
|
+
return { doc: buildDocument(path, source, adapter), language, genre };
|
|
36
39
|
}),
|
|
37
40
|
);
|
|
38
41
|
const language = docs[0]?.language ?? "ja";
|
package/src/commands/test.ts
CHANGED
|
@@ -7,11 +7,13 @@ import { guessLanguage } from "../detect.ts";
|
|
|
7
7
|
import { collectTargets } from "../files.ts";
|
|
8
8
|
import { loadRules } from "../rule-load.ts";
|
|
9
9
|
import { CHECKS_FILE, loadChecks, type UserCheck } from "../checks.ts";
|
|
10
|
-
import { CACHE_DIR,
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { CACHE_DIR, credentialHint, describeFailure, hasCredentials } from "../judge.ts";
|
|
11
|
+
import { ENV_FILE, loadEnvFile } from "../env.ts";
|
|
12
|
+
import { planSemantic, runSemantic } from "../run-semantic.ts";
|
|
13
|
+
import { MACHINE_BANNER, renderPlan, renderSemantic } from "../render/semantic.ts";
|
|
13
14
|
import type { Config } from "../config/load.ts";
|
|
14
15
|
import type { Finding, RuleDefinition } from "../plugin.ts";
|
|
16
|
+
import type { Failure } from "../backends/types.ts";
|
|
15
17
|
|
|
16
18
|
export type TestContext = {
|
|
17
19
|
readonly config: Config;
|
|
@@ -40,6 +42,41 @@ const judgeAll = async (
|
|
|
40
42
|
}),
|
|
41
43
|
);
|
|
42
44
|
|
|
45
|
+
/** API を呼ばずに、何が送られるかだけを出す。鍵が無くても二段構えの 1 段目を確かめられる。 */
|
|
46
|
+
const dryRun = async (
|
|
47
|
+
paths: readonly string[],
|
|
48
|
+
config: Config,
|
|
49
|
+
checks: readonly UserCheck[],
|
|
50
|
+
resolveGenre: TestContext["resolveGenre"],
|
|
51
|
+
envFile: string | undefined,
|
|
52
|
+
): Promise<void> => {
|
|
53
|
+
const plans = await Promise.all(
|
|
54
|
+
paths.map(async (path) => {
|
|
55
|
+
const source = await readFile(path, "utf8");
|
|
56
|
+
const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
|
|
57
|
+
const adapter = await loadAdapter(language);
|
|
58
|
+
const doc = buildDocument(path, source, adapter);
|
|
59
|
+
const { genre } = resolveGenre(path, source, config);
|
|
60
|
+
return { path, jobs: planSemantic(doc, loadRules(language), checks, config.rules, genre), sentences: doc.sentences.length };
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
plans.forEach(({ path, jobs, sentences }) => console.log(renderPlan(path, jobs, sentences).join("\n")));
|
|
64
|
+
const total = plans.reduce((sum, plan) => sum + plan.jobs.reduce((count, job) => count + job.candidates.length, 0), 0);
|
|
65
|
+
const from = envFile === undefined ? "" : ` ${envFile} を読みました。`;
|
|
66
|
+
const where = `${config.aiBackend} / ${config.aiModel}(認証${hasCredentials(config.aiBackend) ? "あり" : "なし"})`;
|
|
67
|
+
console.log(
|
|
68
|
+
[
|
|
69
|
+
"",
|
|
70
|
+
"─".repeat(60),
|
|
71
|
+
"",
|
|
72
|
+
` 合計 ${total} 箇所を送ります。--dry-run なので API は呼んでいません。`,
|
|
73
|
+
` 送り先: ${where}`,
|
|
74
|
+
...(from === "" ? [] : [from]),
|
|
75
|
+
"",
|
|
76
|
+
].join("\n"),
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
43
80
|
/**
|
|
44
81
|
* 機械と AI を見出しで分けて出す。読む人が「これは揺れる判定か」を知らないと、
|
|
45
82
|
* AI の誤検知に振り回される。workflow spec §7、samples/README の軸 2。
|
|
@@ -51,28 +88,49 @@ export const runTest = async (targets: readonly string[], argv: readonly string[
|
|
|
51
88
|
return 1;
|
|
52
89
|
}
|
|
53
90
|
const { config, resolveGenre, inspect } = context;
|
|
91
|
+
// 鍵の置き場所で「設定したのに効かない」を起こさせない。シェルの環境変数のほうが勝つ。
|
|
92
|
+
const envFile = loadEnvFile(process.cwd());
|
|
54
93
|
const checks = loadChecks(join(process.cwd(), CHECKS_FILE));
|
|
55
94
|
const results = await Promise.all(paths.map((path) => inspect(path, config, argv)));
|
|
56
95
|
results.forEach((result) => {
|
|
57
96
|
console.log(result.text.replace(/\n/u, `\n${MACHINE_BANNER.join("\n")}`));
|
|
58
97
|
});
|
|
59
98
|
const machineOnly = results.some((result) => result.outcome.findings.some((finding) => finding.severity === "error")) ? 1 : 0;
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
99
|
+
const where = envFile === undefined ? `${ENV_FILE} に書いても読みます(いまは ${ENV_FILE} がありません)` : `${envFile} は読みました`;
|
|
100
|
+
const notRun = (why: string, what: string): string =>
|
|
101
|
+
["", ` 意味を読む検査は動かしていません。${why}`, ` ${what}`, ` ${where}。`, " 機械による判定はすべて動いています。", ""].join("\n");
|
|
102
|
+
const noCredentials = notRun(`${config.aiBackend} の認証情報がありません。`, credentialHint(config.aiBackend));
|
|
103
|
+
/**
|
|
104
|
+
* API が返した失敗を人の言葉にする。生のスタックトレースを人に見せない。
|
|
105
|
+
* 鍵はあるのに弾かれたときに「ありません」と言うと、設定済みの鍵をもう一度設定しに行かせる。
|
|
106
|
+
* 残高切れも同じで、「鍵を確かめてください」と言われても鍵は正しい。
|
|
107
|
+
*/
|
|
108
|
+
const failed = (failure: Failure): string => {
|
|
109
|
+
if (failure.kind === "auth")
|
|
110
|
+
return notRun(`${config.aiBackend} が鍵を受け付けませんでした(${failure.status ?? "?"})。`, "鍵が正しいか、その provider のものかを確かめてください。");
|
|
111
|
+
if (failure.kind === "quota") return notRun(`${config.aiBackend} の残高か上限に達しました(${failure.status ?? "?"})。`, failure.message);
|
|
112
|
+
return notRun(`${config.aiBackend} が ${failure.status ?? "?"} を返しました。`, failure.message);
|
|
113
|
+
};
|
|
114
|
+
if (argv.includes("--dry-run")) {
|
|
115
|
+
await dryRun(paths, config, checks, resolveGenre, envFile);
|
|
116
|
+
return machineOnly;
|
|
117
|
+
}
|
|
67
118
|
// 呼んでから落ちるのを待たない。認証が無いときの SDK の例外は型で判別できない。
|
|
68
|
-
if (!hasCredentials()) {
|
|
119
|
+
if (!hasCredentials(config.aiBackend)) {
|
|
69
120
|
console.log(noCredentials);
|
|
70
121
|
return machineOnly;
|
|
71
122
|
}
|
|
72
|
-
const options = {
|
|
123
|
+
const options = {
|
|
124
|
+
model: config.aiModel,
|
|
125
|
+
backend: config.aiBackend,
|
|
126
|
+
cacheDir: join(process.cwd(), CACHE_DIR),
|
|
127
|
+
confidenceThreshold: config.confidenceThreshold,
|
|
128
|
+
};
|
|
73
129
|
const semantic = await judgeAll(paths, config, checks, options, resolveGenre).catch((error: unknown) => {
|
|
74
|
-
|
|
75
|
-
|
|
130
|
+
// API が返した失敗だけを受け止める。ネットワーク断やコードの誤りは握りつぶさない。
|
|
131
|
+
const failure = describeFailure(config.aiBackend, error);
|
|
132
|
+
if (failure === undefined) throw error;
|
|
133
|
+
console.log(failed(failure));
|
|
76
134
|
return undefined;
|
|
77
135
|
});
|
|
78
136
|
if (semantic === undefined) return machineOnly;
|
package/src/config/load.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { parse } from "yaml";
|
|
4
4
|
import { isLevel } from "../levels.ts";
|
|
5
|
+
import { defaultModel } from "../judge.ts";
|
|
6
|
+
import { isBackend, type BackendName } from "../backends/types.ts";
|
|
5
7
|
import type { Level } from "../plugin.ts";
|
|
6
8
|
import type { PathRule } from "./by-path.ts";
|
|
7
9
|
|
|
@@ -9,6 +11,7 @@ export const CONFIG_FILE = "chaff.yaml";
|
|
|
9
11
|
|
|
10
12
|
export type Config = {
|
|
11
13
|
readonly genre: string | undefined;
|
|
14
|
+
readonly aiBackend: BackendName;
|
|
12
15
|
readonly language: string | undefined;
|
|
13
16
|
readonly rules: Readonly<Record<string, Level>>;
|
|
14
17
|
readonly experimental: boolean;
|
|
@@ -21,7 +24,8 @@ export type Config = {
|
|
|
21
24
|
};
|
|
22
25
|
|
|
23
26
|
/** 判定の質が誤検知に直結するので、既定は最上位のモデル。cost は絞り込みで削る。spec §14。 */
|
|
24
|
-
|
|
27
|
+
/** 既定は Anthropic。openai にすると判定役だけが替わり、rule も判定の形も変わらない。 */
|
|
28
|
+
export const DEFAULT_BACKEND: BackendName = "anthropic";
|
|
25
29
|
|
|
26
30
|
export const EMPTY: Config = {
|
|
27
31
|
genre: undefined,
|
|
@@ -29,7 +33,8 @@ export const EMPTY: Config = {
|
|
|
29
33
|
rules: {},
|
|
30
34
|
experimental: false,
|
|
31
35
|
path: undefined,
|
|
32
|
-
|
|
36
|
+
aiBackend: DEFAULT_BACKEND,
|
|
37
|
+
aiModel: defaultModel(DEFAULT_BACKEND),
|
|
33
38
|
confidenceThreshold: 0.7,
|
|
34
39
|
byPath: [],
|
|
35
40
|
baseDir: process.cwd(),
|
|
@@ -64,13 +69,16 @@ const byPathOf = (raw: unknown): PathRule[] => (Array.isArray(raw) ? raw.map(toP
|
|
|
64
69
|
export const loadConfig = (path: string): Config => {
|
|
65
70
|
const raw: unknown = parse(readFileSync(path, "utf8"));
|
|
66
71
|
if (!isRecord(raw)) return { ...EMPTY, path };
|
|
72
|
+
const declared: unknown = raw["ai_backend"];
|
|
73
|
+
const backend: BackendName = isBackend(declared) ? declared : DEFAULT_BACKEND;
|
|
67
74
|
return {
|
|
68
75
|
genre: str(raw["genre"]),
|
|
69
76
|
language: str(raw["language"]),
|
|
70
77
|
rules: rulesOf(raw["rules"]),
|
|
71
78
|
experimental: raw["experimental"] === true,
|
|
72
79
|
path,
|
|
73
|
-
|
|
80
|
+
aiBackend: backend,
|
|
81
|
+
aiModel: str(raw["ai_model"]) ?? defaultModel(backend),
|
|
74
82
|
confidenceThreshold: typeof raw["confidence_threshold"] === "number" ? raw["confidence_threshold"] : 0.7,
|
|
75
83
|
byPath: byPathOf(raw["by_path"]),
|
|
76
84
|
baseDir: dirname(path),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Detector, Finding, Sentence, Token } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 受動のまま動作主を書かないと、誰がしたのかが文から消える。
|
|
5
|
+
* 「決定されました」は、決めた人を名指さずに決定を伝える。
|
|
6
|
+
*
|
|
7
|
+
* 受動かどうかはアダプタが Voice=Pass で印を付ける。日本語の「れる/られる」と
|
|
8
|
+
* 英語の be + 過去分詞は形が全く違うので、その判断は言語の側に置く。spec §12.1。
|
|
9
|
+
* 「名詞を修飾しているだけか」の判断も、語順が言語で逆になるため同じくアダプタ側。
|
|
10
|
+
* 動作主の語(によって / by)も言語ごとの語彙表から引く。detector は言語を知らない。
|
|
11
|
+
*/
|
|
12
|
+
const isPassive = (token: Token): boolean => token.features?.["Voice"] === "Pass";
|
|
13
|
+
|
|
14
|
+
const hasAgent = (sentence: Sentence, markers: readonly string[]): boolean => markers.some((marker) => sentence.text.includes(marker));
|
|
15
|
+
|
|
16
|
+
export const agentlessPassive: Detector = (doc, options): Finding[] => {
|
|
17
|
+
const markers = (options.lexicon ?? []).map((entry) => entry.pattern);
|
|
18
|
+
return doc.sentences
|
|
19
|
+
.map((sentence) => ({ sentence, passives: (sentence.tokens ?? []).filter(isPassive) }))
|
|
20
|
+
.filter(({ sentence, passives }) => passives.length >= options.limit && !hasAgent(sentence, markers))
|
|
21
|
+
.map(({ sentence, passives }) => ({
|
|
22
|
+
rule: "agentless-passive",
|
|
23
|
+
severity: "warning",
|
|
24
|
+
line: 0,
|
|
25
|
+
column: 0,
|
|
26
|
+
quote: sentence.text.trim(),
|
|
27
|
+
values: {
|
|
28
|
+
count: passives.length,
|
|
29
|
+
limit: options.limit,
|
|
30
|
+
word: passives.map((token) => token.surface).join(" "),
|
|
31
|
+
offset: passives[0]?.span.start ?? sentence.span.start,
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Detector, Finding, Sentence } from "../plugin.ts";
|
|
2
|
+
import { proseText } from "../measure.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 文字の並びだけを見る検出。品詞も語彙表も要らない。
|
|
6
|
+
* 覆った箇所は空白になっているので、proseText で潰してから数える。
|
|
7
|
+
*/
|
|
8
|
+
const KANJI_RUN = /[一-鿿]+/gu;
|
|
9
|
+
|
|
10
|
+
const longestKanji = (sentence: Sentence): string =>
|
|
11
|
+
[...proseText(sentence).matchAll(KANJI_RUN)].reduce((longest, match) => (match[0].length > longest.length ? match[0] : longest), "");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 漢字が続くと、どこで語が切れるのか読み手が探すことになる。
|
|
15
|
+
* 「情報処理推進機構認定試験」は 12 字。ひらがなを 1 つ挟むだけで読める。
|
|
16
|
+
*/
|
|
17
|
+
export const kanjiRun: Detector = (doc, options): Finding[] =>
|
|
18
|
+
doc.sentences
|
|
19
|
+
.map((sentence) => ({ sentence, run: longestKanji(sentence) }))
|
|
20
|
+
.filter(({ run }) => run.length > options.limit)
|
|
21
|
+
.map(({ sentence, run }) => ({
|
|
22
|
+
rule: "max-kanji-continuous",
|
|
23
|
+
severity: "warning",
|
|
24
|
+
line: 0,
|
|
25
|
+
column: 0,
|
|
26
|
+
quote: sentence.text.trim(),
|
|
27
|
+
values: { word: run, count: run.length, limit: options.limit, offset: sentence.span.start },
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const MIDDLE_DOT = /・/gu;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 中黒で並べると、どこまでが 1 つの項目か分からなくなる。
|
|
34
|
+
* 「企画・開発・運用・保守体制」は、保守体制が 1 つなのか保守と体制なのか読めない。
|
|
35
|
+
*/
|
|
36
|
+
export const middleDot: Detector = (doc, options): Finding[] =>
|
|
37
|
+
doc.sentences
|
|
38
|
+
.map((sentence) => ({ sentence, count: [...proseText(sentence).matchAll(MIDDLE_DOT)].length }))
|
|
39
|
+
.filter(({ count }) => count > options.limit)
|
|
40
|
+
.map(({ sentence, count }) => ({
|
|
41
|
+
rule: "no-nakaguro-parallel",
|
|
42
|
+
severity: "info",
|
|
43
|
+
line: 0,
|
|
44
|
+
column: 0,
|
|
45
|
+
quote: sentence.text.trim(),
|
|
46
|
+
values: { count, limit: options.limit, offset: sentence.span.start },
|
|
47
|
+
}));
|