chaffjs 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/address-chain.d.ts +9 -0
- package/dist/address-chain.d.ts.map +1 -0
- package/dist/address-chain.js +105 -0
- package/dist/address-chain.js.map +1 -0
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +1 -1
- package/dist/cli-args.js.map +1 -1
- package/dist/cli-text.d.ts.map +1 -1
- package/dist/cli-text.js +4 -0
- package/dist/cli-text.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +19 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +2 -1
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/feedback.d.ts +35 -0
- package/dist/commands/feedback.d.ts.map +1 -0
- package/dist/commands/feedback.js +157 -0
- package/dist/commands/feedback.js.map +1 -0
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +3 -2
- package/dist/commands/test.js.map +1 -1
- package/dist/commands/tree.d.ts.map +1 -1
- package/dist/commands/tree.js +3 -1
- package/dist/commands/tree.js.map +1 -1
- package/dist/config/by-path.d.ts +5 -1
- package/dist/config/by-path.d.ts.map +1 -1
- package/dist/config/by-path.js +1 -4
- package/dist/config/by-path.js.map +1 -1
- package/dist/config/load.d.ts +2 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +3 -1
- package/dist/config/load.js.map +1 -1
- package/dist/detectors/char-shape.d.ts.map +1 -1
- package/dist/detectors/char-shape.js +3 -2
- package/dist/detectors/char-shape.js.map +1 -1
- package/dist/document.d.ts +2 -2
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +4 -2
- package/dist/document.js.map +1 -1
- package/dist/feedback/draft.d.ts +28 -0
- package/dist/feedback/draft.d.ts.map +1 -0
- package/dist/feedback/draft.js +66 -0
- package/dist/feedback/draft.js.map +1 -0
- package/dist/feedback/excerpt.d.ts +10 -0
- package/dist/feedback/excerpt.d.ts.map +1 -0
- package/dist/feedback/excerpt.js +20 -0
- package/dist/feedback/excerpt.js.map +1 -0
- package/dist/legal-address.d.ts +2 -0
- package/dist/legal-address.d.ts.map +1 -0
- package/dist/legal-address.js +16 -0
- package/dist/legal-address.js.map +1 -0
- package/dist/orthography.d.ts.map +1 -1
- package/dist/orthography.js +15 -0
- package/dist/orthography.js.map +1 -1
- package/dist/plugin.d.ts +88 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/profile/for-file.d.ts +7 -0
- package/dist/profile/for-file.d.ts.map +1 -0
- package/dist/profile/for-file.js +6 -0
- package/dist/profile/for-file.js.map +1 -0
- package/dist/profile/load.d.ts +4 -0
- package/dist/profile/load.d.ts.map +1 -0
- package/dist/profile/load.js +25 -0
- package/dist/profile/load.js.map +1 -0
- package/dist/profile/parse.d.ts +19 -0
- package/dist/profile/parse.d.ts.map +1 -0
- package/dist/profile/parse.js +82 -0
- package/dist/profile/parse.js.map +1 -0
- package/dist/profile/select.d.ts +22 -0
- package/dist/profile/select.d.ts.map +1 -0
- package/dist/profile/select.js +21 -0
- package/dist/profile/select.js.map +1 -0
- package/dist/reasons.d.ts +1 -0
- package/dist/reasons.d.ts.map +1 -1
- package/dist/reasons.js +2 -0
- package/dist/reasons.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +24 -3
- package/dist/run.js.map +1 -1
- package/dist/structure/build.d.ts +2 -1
- package/dist/structure/build.d.ts.map +1 -1
- package/dist/structure/build.js +94 -7
- package/dist/structure/build.js.map +1 -1
- package/dist/structure/issues.d.ts.map +1 -1
- package/dist/structure/issues.js +9 -2
- package/dist/structure/issues.js.map +1 -1
- package/dist/structure/of.d.ts +2 -1
- package/dist/structure/of.d.ts.map +1 -1
- package/dist/structure/of.js +7 -1
- package/dist/structure/of.js.map +1 -1
- package/dist/structure/relative-find.d.ts +6 -0
- package/dist/structure/relative-find.d.ts.map +1 -0
- package/dist/structure/relative-find.js +179 -0
- package/dist/structure/relative-find.js.map +1 -0
- package/dist/structure/relative-resolve.d.ts +3 -0
- package/dist/structure/relative-resolve.d.ts.map +1 -0
- package/dist/structure/relative-resolve.js +175 -0
- package/dist/structure/relative-resolve.js.map +1 -0
- package/dist/structure/unread.d.ts +8 -0
- package/dist/structure/unread.d.ts.map +1 -0
- package/dist/structure/unread.js +34 -0
- package/dist/structure/unread.js.map +1 -0
- package/package.json +4 -3
- package/profiles/statute.yaml +62 -0
- package/skills/chaff/SKILL.md +24 -4
- package/src/address-chain.ts +113 -0
- package/src/cli-args.ts +1 -1
- package/src/cli-text.ts +4 -0
- package/src/cli.ts +20 -1
- package/src/commands/eval.ts +2 -1
- package/src/commands/feedback.ts +210 -0
- package/src/commands/test.ts +3 -2
- package/src/commands/tree.ts +3 -1
- package/src/config/by-path.ts +11 -3
- package/src/config/load.ts +5 -1
- package/src/detectors/char-shape.ts +5 -4
- package/src/document.ts +11 -3
- package/src/feedback/draft.ts +102 -0
- package/src/feedback/excerpt.ts +23 -0
- package/src/orthography.ts +15 -0
- package/src/plugin.ts +82 -0
- package/src/profile/for-file.ts +11 -0
- package/src/profile/load.ts +26 -0
- package/src/profile/parse.ts +106 -0
- package/src/profile/select.ts +35 -0
- package/src/reasons.ts +5 -0
- package/src/run.ts +25 -3
- package/src/structure/build.ts +118 -10
- package/src/structure/issues.ts +10 -2
- package/src/structure/of.ts +15 -3
- package/src/structure/relative-find.ts +224 -0
- package/src/structure/relative-resolve.ts +209 -0
- package/src/structure/unread.ts +39 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { applyByPath } from "../config/by-path.ts";
|
|
2
|
+
import type { Config } from "../config/load.ts";
|
|
3
|
+
import type { DocumentProfile } from "../plugin.ts";
|
|
4
|
+
import { loadProfiles } from "./load.ts";
|
|
5
|
+
import { chooseProfile } from "./select.ts";
|
|
6
|
+
|
|
7
|
+
type ProfileSettings = Pick<Config, "profile" | "byPath" | "baseDir">;
|
|
8
|
+
|
|
9
|
+
/** 1 ファイルの文書の種類。by_path、chaff.yaml、内容の順に選ぶ。 */
|
|
10
|
+
export const profileFor = (config: ProfileSettings, path: string, source: string, language: string): DocumentProfile | undefined =>
|
|
11
|
+
chooseProfile(loadProfiles(), { byPath: applyByPath(config.byPath, config.baseDir, path).profile, config: config.profile, source, language })?.profile;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { parseProfile, type ProfileDefinition } from "./parse.ts";
|
|
6
|
+
|
|
7
|
+
const PROFILES_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "profiles");
|
|
8
|
+
|
|
9
|
+
const loaded: { value: readonly ProfileDefinition[] | undefined } = { value: undefined };
|
|
10
|
+
|
|
11
|
+
/** 同梱の profiles/*.yaml。一度だけ読む。 */
|
|
12
|
+
export const loadProfiles = (): readonly ProfileDefinition[] => {
|
|
13
|
+
loaded.value ??= readdirSync(PROFILES_DIR)
|
|
14
|
+
.filter((file) => file.endsWith(".yaml"))
|
|
15
|
+
.sort((left, right) => left.localeCompare(right))
|
|
16
|
+
.flatMap((file) => {
|
|
17
|
+
const path = join(PROFILES_DIR, file);
|
|
18
|
+
try {
|
|
19
|
+
const definition = parseProfile(parse(readFileSync(path, "utf8")));
|
|
20
|
+
return definition === undefined ? [] : [definition];
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new Error(`cannot read the profile ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return loaded.value;
|
|
26
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { DocumentProfile, RelativeVocabulary } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/** profiles/*.yaml を一つ読んだもの。言語ごとの中身と、内容から選ぶための形を持つ。 */
|
|
4
|
+
export type ProfileDefinition = {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly name: Readonly<Record<string, string>>;
|
|
7
|
+
readonly detect: Readonly<Record<string, ProfileDetect>>;
|
|
8
|
+
readonly languages: Readonly<Record<string, DocumentProfile>>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** この形の行が minLines 行以上あれば、その種類の文書として読む。 */
|
|
12
|
+
type ProfileDetect = { readonly line: string; readonly minLines: number };
|
|
13
|
+
|
|
14
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
|
|
16
|
+
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string" && entry !== "") : []);
|
|
17
|
+
|
|
18
|
+
const texts = (value: unknown): Record<string, string> =>
|
|
19
|
+
isRecord(value) ? Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")) : {};
|
|
20
|
+
|
|
21
|
+
/** 空でない文字列だけ。空の正規表現は何にでも当たる。 */
|
|
22
|
+
const text = (value: unknown): string | undefined => (typeof value === "string" && value !== "" ? value : undefined);
|
|
23
|
+
|
|
24
|
+
const detectOf = (value: unknown): ProfileDetect | undefined => {
|
|
25
|
+
if (!isRecord(value) || typeof value["line"] !== "string") return undefined;
|
|
26
|
+
const minLines = value["min_lines"];
|
|
27
|
+
return { line: value["line"], minLines: typeof minLines === "number" && minLines > 0 ? minLines : 1 };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const unitsOf = (value: unknown): Record<string, number> =>
|
|
31
|
+
isRecord(value) ? Object.fromEntries(Object.entries(value).filter((entry): entry is [string, number] => entry[0] !== "" && Number.isInteger(entry[1]))) : {};
|
|
32
|
+
|
|
33
|
+
const asideOf = (value: unknown): RelativeVocabulary["aside"] => {
|
|
34
|
+
if (!isRecord(value)) return undefined;
|
|
35
|
+
const [open, close] = [text(value["open"]), text(value["close"])];
|
|
36
|
+
return open === undefined || close === undefined ? undefined : { open, close };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** 括弧の開き・閉じと、目印が一つも無ければ読まない。 */
|
|
40
|
+
const substitutionOf = (value: unknown): RelativeVocabulary["substitution"] => {
|
|
41
|
+
if (!isRecord(value)) return undefined;
|
|
42
|
+
const [open, close] = [text(value["open"]), text(value["close"])];
|
|
43
|
+
const [after, before] = [strings(value["after"]), strings(value["before"])];
|
|
44
|
+
return open === undefined || close === undefined || after.length + before.length === 0 ? undefined : { open, close, after, before };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** 単位が一つも無ければ読まない。何を指すのか決められない。 */
|
|
48
|
+
const relativeOf = (value: unknown): RelativeVocabulary | undefined => {
|
|
49
|
+
if (!isRecord(value)) return undefined;
|
|
50
|
+
const units = unitsOf(value["units"]);
|
|
51
|
+
if (Object.keys(units).length === 0) return undefined;
|
|
52
|
+
return {
|
|
53
|
+
before: strings(value["before"]),
|
|
54
|
+
after: strings(value["after"]),
|
|
55
|
+
same: strings(value["same"]),
|
|
56
|
+
current: strings(value["current"]),
|
|
57
|
+
every: strings(value["every"]),
|
|
58
|
+
count: text(value["count"]) ?? "(?!)",
|
|
59
|
+
units,
|
|
60
|
+
suffixPrefix: text(value["suffix_prefix"]) ?? "",
|
|
61
|
+
implicitFirst: typeof value["implicit_first"] === "string" ? units[value["implicit_first"]] : undefined,
|
|
62
|
+
notAfter: text(value["not_after"]),
|
|
63
|
+
substitution: substitutionOf(value["substitution"]),
|
|
64
|
+
inside: strings(value["inside"]).filter((unit) => units[unit] !== undefined),
|
|
65
|
+
joiners: strings(value["joiners"]),
|
|
66
|
+
aside: asideOf(value["aside"]),
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const LANGUAGE_KEYS = new Set(["id", "name", "why", "detect"]);
|
|
71
|
+
|
|
72
|
+
const languagesOf = (id: string, raw: Record<string, unknown>): Record<string, DocumentProfile> =>
|
|
73
|
+
Object.fromEntries(
|
|
74
|
+
Object.entries(raw)
|
|
75
|
+
.filter(([key, value]) => !LANGUAGE_KEYS.has(key) && isRecord(value))
|
|
76
|
+
.map(([language, value]) => {
|
|
77
|
+
const section = isRecord(value) ? value : {};
|
|
78
|
+
const addressEnd = text(section["address_end"]);
|
|
79
|
+
const caption = text(section["caption"]);
|
|
80
|
+
const relative = relativeOf(section["relative"]);
|
|
81
|
+
return [language, { id, addresses: strings(section["addresses"]), connectives: strings(section["connectives"]), addressEnd, caption, relative }];
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/** id の無いものは読まない。どの設定から選ばれたのか言えなくなるため。 */
|
|
86
|
+
export const parseProfile = (raw: unknown): ProfileDefinition | undefined => {
|
|
87
|
+
if (!isRecord(raw) || typeof raw["id"] !== "string" || raw["id"] === "") return undefined;
|
|
88
|
+
const id = raw["id"];
|
|
89
|
+
const detect = isRecord(raw["detect"])
|
|
90
|
+
? Object.fromEntries(
|
|
91
|
+
Object.entries(raw["detect"]).flatMap(([language, value]) => {
|
|
92
|
+
const found = detectOf(value);
|
|
93
|
+
return found === undefined ? [] : [[language, found] as const];
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
96
|
+
: {};
|
|
97
|
+
return { id, name: texts(raw["name"]), detect, languages: languagesOf(id, raw) };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** 行頭から数える。source 全体に m フラグで当てれば、行ごとに分けずに数えられる。 */
|
|
101
|
+
export const detected = (definition: ProfileDefinition, source: string, language: string): boolean => {
|
|
102
|
+
const detect = definition.detect[language];
|
|
103
|
+
if (detect === undefined) return false;
|
|
104
|
+
const count = [...source.matchAll(new RegExp(detect.line, "gmu"))].length;
|
|
105
|
+
return count >= detect.minLines;
|
|
106
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DocumentProfile } from "../plugin.ts";
|
|
2
|
+
import { detected, type ProfileDefinition } from "./parse.ts";
|
|
3
|
+
|
|
4
|
+
/** 設定で書いた「使わない」。内容から選ぶのも止める。 */
|
|
5
|
+
export const NO_PROFILE = "none";
|
|
6
|
+
|
|
7
|
+
type ProfileSource = "by-path" | "config" | "content";
|
|
8
|
+
|
|
9
|
+
export type ChosenProfile = { readonly profile: DocumentProfile; readonly from: ProfileSource };
|
|
10
|
+
|
|
11
|
+
export type ProfileRequest = {
|
|
12
|
+
readonly byPath: string | undefined;
|
|
13
|
+
readonly config: string | undefined;
|
|
14
|
+
readonly source: string;
|
|
15
|
+
readonly language: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const named = (definitions: readonly ProfileDefinition[], id: string, language: string): DocumentProfile | undefined =>
|
|
19
|
+
definitions.find((definition) => definition.id === id)?.languages[language];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 強いものから: by_path、chaff.yaml の profile、内容。どれかが none と書けばそこで止め、内容からも選ばない。
|
|
23
|
+
* 名前で選んだ種類にその言語の中身が無ければ選ばない。黙って別の種類にしない。
|
|
24
|
+
*/
|
|
25
|
+
export const chooseProfile = (definitions: readonly ProfileDefinition[], request: ProfileRequest): ChosenProfile | undefined => {
|
|
26
|
+
const written = request.byPath ?? request.config;
|
|
27
|
+
if (written === NO_PROFILE) return undefined;
|
|
28
|
+
if (written !== undefined) {
|
|
29
|
+
const profile = named(definitions, written, request.language);
|
|
30
|
+
return profile === undefined ? undefined : { profile, from: request.byPath === undefined ? "config" : "by-path" };
|
|
31
|
+
}
|
|
32
|
+
const found = definitions.find((definition) => detected(definition, request.source, request.language));
|
|
33
|
+
const profile = found?.languages[request.language];
|
|
34
|
+
return profile === undefined ? undefined : { profile, from: "content" };
|
|
35
|
+
};
|
package/src/reasons.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type Reasons = {
|
|
|
9
9
|
readonly experimental: string;
|
|
10
10
|
readonly turnedOff: string;
|
|
11
11
|
readonly noStructure: (language: string) => string;
|
|
12
|
+
readonly unreadStructure: (clauses: number, units: number) => string;
|
|
12
13
|
readonly noDetector: (name: string) => string;
|
|
13
14
|
readonly noLexicon: (language: string, list: string) => string;
|
|
14
15
|
};
|
|
@@ -27,6 +28,8 @@ export const REASONS: Texts<Reasons> = {
|
|
|
27
28
|
experimental: "まだ試験中のため",
|
|
28
29
|
turnedOff: "設定で止めているため",
|
|
29
30
|
noStructure: (language) => `${language} のパッケージは文書の構造を読めないため`,
|
|
31
|
+
unreadStructure: (clauses, units) =>
|
|
32
|
+
`条項の番号が本文に ${String(clauses)} 個あるのに、番号として読めたのは ${String(units)} 個のため(深い字下げや、行が本文につながった文書)`,
|
|
30
33
|
noDetector: (name) => `検出器 ${name} がないため`,
|
|
31
34
|
noLexicon: (language, list) => `${language} の語彙表 ${list} が無いため`,
|
|
32
35
|
},
|
|
@@ -38,6 +41,8 @@ export const REASONS: Texts<Reasons> = {
|
|
|
38
41
|
experimental: "still experimental",
|
|
39
42
|
turnedOff: "turned off in the settings",
|
|
40
43
|
noStructure: (language) => `the ${language} package cannot read a document's structure`,
|
|
44
|
+
unreadStructure: (clauses, units) =>
|
|
45
|
+
`the text has ${String(clauses)} clause numbers but only ${String(units)} were read as numbered lines (deep indents, or lines run into the text)`,
|
|
41
46
|
noDetector: (name) => `no detector named ${name}`,
|
|
42
47
|
noLexicon: (language, list) => `the ${language} package has no word list ${list}`,
|
|
43
48
|
},
|
package/src/run.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { resolve } from "./levels.ts";
|
|
|
3
3
|
import type { AdapterNeeds, Finding, Level, ProseDocument, RuleDefinition } from "./plugin.ts";
|
|
4
4
|
import { lineStarts, placeOf } from "./position.ts";
|
|
5
5
|
import { REASONS, type Reasons } from "./reasons.ts";
|
|
6
|
+
import { unreadStructure, type Unread } from "./structure/unread.ts";
|
|
7
|
+
import { isMarkdownPath } from "./structure/markdown-path.ts";
|
|
6
8
|
import { uiLanguageOf } from "./ui.ts";
|
|
7
9
|
|
|
8
10
|
export type Skipped = { readonly rule: string; readonly why: string };
|
|
@@ -26,6 +28,27 @@ const levelFor = (rule: RuleDefinition, settings: Settings, experimental: boolea
|
|
|
26
28
|
|
|
27
29
|
const reasonsFor = (doc: ProseDocument): Reasons => REASONS[uiLanguageOf(doc.language)];
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* 構造の rule が動けない理由。木を作れない言語か、番号の行を読めなかった文書。
|
|
33
|
+
* 読めなかった文書で「参照先が無い」が 0 件なのは、確かめた結果ではないので、読めなかったと言う。
|
|
34
|
+
*/
|
|
35
|
+
const treeProblem = (doc: ProseDocument): string | undefined => {
|
|
36
|
+
if (doc.structure === undefined) return reasonsFor(doc).noStructure(doc.language);
|
|
37
|
+
const unread = unreadOf(doc);
|
|
38
|
+
return unread === undefined ? undefined : reasonsFor(doc).unreadStructure(unread.clauses, unread.units);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 木が読んだのと同じ本文。Markdown はコードを覆ったもの、.txt はそのまま(.txt の字下げはコードではない)。 */
|
|
42
|
+
const textTheTreeRead = (doc: ProseDocument): string => (isMarkdownPath(doc.path) ? (doc.prose ?? doc.source) : doc.source);
|
|
43
|
+
|
|
44
|
+
const unreadByDocument = new WeakMap<ProseDocument, Unread | undefined>();
|
|
45
|
+
|
|
46
|
+
/** 文書ごとに一度だけ数える。構造の rule が三つあっても、本文を三度なめない。 */
|
|
47
|
+
const unreadOf = (doc: ProseDocument): Unread | undefined => {
|
|
48
|
+
if (!unreadByDocument.has(doc)) unreadByDocument.set(doc, doc.structure === undefined ? undefined : unreadStructure(textTheTreeRead(doc), doc.structure));
|
|
49
|
+
return unreadByDocument.get(doc);
|
|
50
|
+
};
|
|
51
|
+
|
|
29
52
|
/** 知らない要求は満たされていないものとして扱う。黙って無視すると、要求なしで動いてしまう。 */
|
|
30
53
|
const has = (capabilities: ProseDocument["capabilities"], need: string): boolean => {
|
|
31
54
|
if (need === "pos") return capabilities.pos;
|
|
@@ -135,9 +158,8 @@ export const runRules = (
|
|
|
135
158
|
if (noTags !== undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: noTags }] };
|
|
136
159
|
// 木は capability ではなく、adapter が structure を持つかで決まる。持たない言語で動かすと「参照先が無い」が 0 件に見える。
|
|
137
160
|
// 段階を見た後で聞く。doc.structure は触れたときに木を作るので、止めている rule のために作らない。
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
161
|
+
const noTree = rule.requires.includes("structure") ? treeProblem(doc) : undefined;
|
|
162
|
+
if (noTree !== undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: noTree }] };
|
|
141
163
|
// 複合シグナルは二段目で扱う。一段目では「検出器が無い」と言わせない。
|
|
142
164
|
if (rule.from.length > 0) return acc;
|
|
143
165
|
const detector = DETECTORS[rule.how_to_find];
|
package/src/structure/build.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { Heading } from "../document.ts";
|
|
2
2
|
import { atxHeadingText, headingText } from "../heading-text.ts";
|
|
3
|
+
import { addressSpans } from "../address-chain.ts";
|
|
4
|
+
import { relativeMentions } from "./relative-find.ts";
|
|
5
|
+
import { resolveRelative } from "./relative-resolve.ts";
|
|
3
6
|
import { maskSpans } from "../mask.ts";
|
|
4
|
-
import type { Mention, NumberedLine, NumberingContext, Span, StructureKind, StructureNode, StructurePatterns } from "../plugin.ts";
|
|
7
|
+
import type { DocumentProfile, Mention, NumberedLine, NumberingContext, Span, StructureKind, StructureNode, StructurePatterns } from "../plugin.ts";
|
|
5
8
|
import { lineNumberAt, linesOf, type Line } from "./lines.ts";
|
|
6
9
|
import { dottedNumber } from "./universal.ts";
|
|
7
10
|
|
|
@@ -17,6 +20,7 @@ type Draft = {
|
|
|
17
20
|
readonly ordinal?: number | undefined;
|
|
18
21
|
readonly level?: number | undefined;
|
|
19
22
|
readonly ordinalTo?: number | undefined;
|
|
23
|
+
readonly numbering?: string | undefined;
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
/** 開いている節点。rank の大きいものほど内側。 */
|
|
@@ -32,6 +36,13 @@ type State = {
|
|
|
32
36
|
readonly stack: Frame[];
|
|
33
37
|
/** 見出しの深さごとの通し番号。番号の無い見出しの番地 h2.1 を作る。 */
|
|
34
38
|
readonly headingCounts: number[];
|
|
39
|
+
/** opensDefinitionScope の行を含んだまとまり(ふつうは条)。ここに入る定義は範囲の中でだけ比べる。 */
|
|
40
|
+
readonly scopedArticles: Set<Draft>;
|
|
41
|
+
/** 範囲が条より広い(「In this Part—」)条と、その広いまとまり。その条の定義は、広いまとまりの中で比べる。 */
|
|
42
|
+
readonly widerScopes: Map<Draft, Draft>;
|
|
43
|
+
readonly profile: DocumentProfile | undefined;
|
|
44
|
+
/** 見出しだけの行(文書の種類の caption)の行番号と、その言葉。すぐ次の行の条が見出しに使う。 */
|
|
45
|
+
readonly captions: Map<number, string>;
|
|
35
46
|
};
|
|
36
47
|
|
|
37
48
|
const draftOf = (kind: StructureKind, address: string, line: Line, attrs: Readonly<Record<string, string | number>>): Draft => ({
|
|
@@ -95,6 +106,7 @@ const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingD
|
|
|
95
106
|
ordinal: numbered.ordinal,
|
|
96
107
|
level: numbered.depth,
|
|
97
108
|
ordinalTo: numbered.ordinalTo,
|
|
109
|
+
numbering: numbered.numbering,
|
|
98
110
|
};
|
|
99
111
|
open(state, { draft, rank, numbered });
|
|
100
112
|
};
|
|
@@ -104,11 +116,30 @@ const openSection = (state: State, line: Line, heading: Heading): void => {
|
|
|
104
116
|
open(state, { draft: draftOf("section", headingAddress(state, heading.depth), line, { heading: heading.text }), rank: heading.depth });
|
|
105
117
|
};
|
|
106
118
|
|
|
107
|
-
|
|
119
|
+
/** 番地(文書の種類が決める「前二項」など)の中の数は、数量ではない。 */
|
|
120
|
+
const outsideAddresses = (mentions: readonly Mention[], text: string, profile: DocumentProfile | undefined): readonly Mention[] => {
|
|
121
|
+
const spans = addressSpans(text, profile);
|
|
122
|
+
return spans.length === 0 ? mentions : mentions.filter((mention) => !spans.some((span) => span.start <= mention.start && mention.end <= span.end));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** 番地を名指しした参照(第百五十七条第一項)の一部は、相対の参照ではない。 */
|
|
126
|
+
const withoutAbsolute = (relative: readonly Mention[], absolute: readonly Mention[]): readonly Mention[] =>
|
|
127
|
+
relative.filter((mention) => !absolute.some((other) => mention.start < other.end && other.start < mention.end));
|
|
128
|
+
|
|
129
|
+
type LeafFinder = (patterns: StructurePatterns, text: string, profile: DocumentProfile | undefined) => readonly Mention[];
|
|
130
|
+
|
|
131
|
+
const LEAVES: readonly { readonly kind: StructureKind; readonly find: LeafFinder }[] = [
|
|
108
132
|
{ kind: "definition", find: (patterns, text) => patterns.definitions(text) },
|
|
109
133
|
{ kind: "reference", find: (patterns, text) => patterns.references(text) },
|
|
134
|
+
{
|
|
135
|
+
kind: "reference",
|
|
136
|
+
find: (patterns, text, profile) => {
|
|
137
|
+
const absolute = patterns.references(text);
|
|
138
|
+
return withoutAbsolute(relativeMentions(text, profile, patterns.number, absolute), absolute);
|
|
139
|
+
},
|
|
140
|
+
},
|
|
110
141
|
{ kind: "obligation", find: (patterns, text) => patterns.obligations(text) },
|
|
111
|
-
{ kind: "quantity", find: (patterns, text) => patterns.quantities(text) },
|
|
142
|
+
{ kind: "quantity", find: (patterns, text, profile) => outsideAddresses(patterns.quantities(text), text, profile) },
|
|
112
143
|
{ kind: "date", find: (patterns, text) => patterns.dates?.(text) ?? [] },
|
|
113
144
|
];
|
|
114
145
|
|
|
@@ -119,13 +150,54 @@ const universalNumber = (patterns: StructurePatterns, text: string, context: Num
|
|
|
119
150
|
};
|
|
120
151
|
|
|
121
152
|
/** 行の中の定義・参照・義務・数量を、いま開いている最も内側の節点の子にする。 */
|
|
153
|
+
const enclosingArticle = (state: State): Draft | undefined => [...state.stack].reverse().find((frame) => frame.draft.kind === "article")?.draft;
|
|
154
|
+
|
|
155
|
+
/** 条より広い範囲(「In this Part」)なら、その深さの開いているまとまり。開いていなければ(Part の見出しの無い文書)無い。 */
|
|
156
|
+
const widerScope = (state: State, patterns: StructurePatterns, text: string): Draft | undefined => {
|
|
157
|
+
const depth = patterns.definitionScopeDepth?.(text);
|
|
158
|
+
return depth === undefined ? undefined : [...state.stack].reverse().find((frame) => frame.numbered?.depth === depth)?.draft;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** 範囲を宣言した行を持つまとまり。条の中なら条、条の外(Markdown の見出しの下、Part の直下)なら開いている一番内側のもの。 */
|
|
162
|
+
const holderOf = (state: State): Draft => enclosingArticle(state) ?? top(state).draft;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 定義の範囲。範囲を宣言した行(In this section— / In this Part—)を含むまとまりの定義が対象。宣言が Part なら、比べるのは
|
|
166
|
+
* その Part の中(within に番地を残す)。条の外で宣言したものは、そのまとまりの中。宣言の無いところの定義は、文書全体で比べる。
|
|
167
|
+
*/
|
|
168
|
+
const scopeOf = (state: State, patterns: StructurePatterns, text: string): Readonly<Record<string, string>> => {
|
|
169
|
+
const holder = holderOf(state);
|
|
170
|
+
if (patterns.opensDefinitionScope?.(text) === true) {
|
|
171
|
+
state.scopedArticles.add(holder);
|
|
172
|
+
const wider = widerScope(state, patterns, text);
|
|
173
|
+
if (wider !== undefined) state.widerScopes.set(holder, wider);
|
|
174
|
+
}
|
|
175
|
+
if (!state.scopedArticles.has(holder)) return {};
|
|
176
|
+
const within = state.widerScopes.get(holder) ?? (holder.kind === "article" ? undefined : holder);
|
|
177
|
+
return within === undefined ? { scope: "local" } : { scope: "local", within: within.address };
|
|
178
|
+
};
|
|
179
|
+
|
|
122
180
|
const addLeaves = (state: State, patterns: StructurePatterns, line: Line, text: string, offset: number): void => {
|
|
123
181
|
const parent = top(state).draft;
|
|
182
|
+
const scope = scopeOf(state, patterns, text);
|
|
124
183
|
const leaves = LEAVES.flatMap(({ kind, find }) =>
|
|
125
|
-
find(patterns, text).map((mention) => ({
|
|
184
|
+
find(patterns, text, state.profile).map((mention) => ({
|
|
185
|
+
kind,
|
|
186
|
+
start: line.start + offset + mention.start,
|
|
187
|
+
end: line.start + offset + mention.end,
|
|
188
|
+
attrs: mention.attrs,
|
|
189
|
+
})),
|
|
126
190
|
).sort((left, right) => left.start - right.start);
|
|
127
191
|
leaves.forEach((leaf) =>
|
|
128
|
-
parent.children.push({
|
|
192
|
+
parent.children.push({
|
|
193
|
+
kind: leaf.kind,
|
|
194
|
+
address: "",
|
|
195
|
+
start: leaf.start,
|
|
196
|
+
end: leaf.end,
|
|
197
|
+
line: line.number,
|
|
198
|
+
attrs: leaf.kind === "definition" ? { ...leaf.attrs, ...scope } : leaf.attrs,
|
|
199
|
+
children: [],
|
|
200
|
+
}),
|
|
129
201
|
);
|
|
130
202
|
};
|
|
131
203
|
|
|
@@ -145,6 +217,7 @@ const freeze = (draft: Draft): StructureNode => ({
|
|
|
145
217
|
...(draft.ordinal === undefined ? {} : { ordinal: draft.ordinal }),
|
|
146
218
|
...(draft.level === undefined ? {} : { level: draft.level }),
|
|
147
219
|
...(draft.ordinalTo === undefined ? {} : { ordinalTo: draft.ordinalTo }),
|
|
220
|
+
...(draft.numbering === undefined ? {} : { numbering: draft.numbering }),
|
|
148
221
|
});
|
|
149
222
|
|
|
150
223
|
/** Markdown から取った手がかり。見出しと、中を読まない範囲(コード)。.txt はどちらも空。 */
|
|
@@ -152,7 +225,13 @@ export type Outline = { readonly headings: readonly Heading[]; readonly opaque:
|
|
|
152
225
|
|
|
153
226
|
export const NO_OUTLINE: Outline = { headings: [], opaque: [] };
|
|
154
227
|
|
|
155
|
-
export type StructureInput = {
|
|
228
|
+
export type StructureInput = {
|
|
229
|
+
readonly path: string;
|
|
230
|
+
readonly source: string;
|
|
231
|
+
readonly language: string;
|
|
232
|
+
readonly outline: Outline;
|
|
233
|
+
readonly profile?: DocumentProfile | undefined;
|
|
234
|
+
};
|
|
156
235
|
|
|
157
236
|
/** 見出しのある行を、行番号から引けるようにする。見出しごとに全行を探し直さない。 */
|
|
158
237
|
const headingsByLine = (lines: readonly Line[], headings: readonly Heading[]): Map<number, Heading> =>
|
|
@@ -175,11 +254,30 @@ const headingLineText = (text: string): string => {
|
|
|
175
254
|
return atxHeadingText(line.replace(/^#{1,6}(?=[ \t]|$)/u, "").trim());
|
|
176
255
|
};
|
|
177
256
|
|
|
257
|
+
const captionPatterns = new WeakMap<DocumentProfile, RegExp | undefined>();
|
|
258
|
+
|
|
259
|
+
/** 文書の種類が決めた見出しだけの行なら、その言葉(最初の括弧の中、括弧が無ければ行全体)。 */
|
|
260
|
+
const captionOf = (profile: DocumentProfile | undefined, text: string): string | undefined => {
|
|
261
|
+
if (profile === undefined) return undefined;
|
|
262
|
+
if (!captionPatterns.has(profile)) captionPatterns.set(profile, profile.caption === undefined ? undefined : new RegExp(profile.caption, "u"));
|
|
263
|
+
const found = captionPatterns.get(profile)?.exec(text);
|
|
264
|
+
const words = (found?.[1] ?? found?.[0])?.trim();
|
|
265
|
+
return words === "" ? undefined : words;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** 見出しの無い条のすぐ前の行が見出しだけの行なら、それを条の見出しにする。 */
|
|
269
|
+
const withCaption = (state: State, line: Line, numbered: NumberedLine | undefined): NumberedLine | undefined => {
|
|
270
|
+
const caption = numbered?.kind === "article" && numbered.heading === "" ? state.captions.get(line.number - 1) : undefined;
|
|
271
|
+
return numbered === undefined || caption === undefined ? numbered : { ...numbered, heading: caption };
|
|
272
|
+
};
|
|
273
|
+
|
|
178
274
|
const readLine = (state: State, patterns: StructurePatterns, line: Line, heading: Heading | undefined): void => {
|
|
179
275
|
const text = heading === undefined ? line.text : headingLineText(line.text);
|
|
180
276
|
const openNumbers = state.stack.flatMap((frame) => (frame.numbered === undefined ? [] : [frame.numbered]));
|
|
181
277
|
const context = { open: openNumbers, isHeading: heading !== undefined };
|
|
182
|
-
const numbered = patterns.numbered(text, context) ?? universalNumber(patterns, text, context);
|
|
278
|
+
const numbered = withCaption(state, line, patterns.numbered(text, context) ?? universalNumber(patterns, text, context));
|
|
279
|
+
const caption = numbered === undefined && heading === undefined ? captionOf(state.profile, text) : undefined;
|
|
280
|
+
if (caption !== undefined) state.captions.set(line.number, caption);
|
|
183
281
|
// 番号付きの見出しも見出しの通し番号を進める。進めないと、その下の「### 詳細」が前の見出しの番地を名乗る。
|
|
184
282
|
if (numbered !== undefined && heading !== undefined) headingAddress(state, heading.depth);
|
|
185
283
|
if (numbered !== undefined) openNumbered(state, line, numbered, heading?.depth);
|
|
@@ -201,13 +299,23 @@ export const buildTree = (input: StructureInput, patterns: StructurePatterns): S
|
|
|
201
299
|
// 文中の `第99条` は参照として拾われない。位置は元の文書のまま。
|
|
202
300
|
const lines = linesOf(maskSpans(input.source, outline.opaque));
|
|
203
301
|
const headings = headingsByLine(lines, outline.headings);
|
|
204
|
-
const
|
|
302
|
+
const attrs = { path: input.path, language: input.language, ...(input.profile === undefined ? {} : { profile: input.profile.id }) };
|
|
303
|
+
const doc = draftOf("doc", "", { text: "", start: 0, number: 1 }, attrs);
|
|
205
304
|
doc.end = input.source.length;
|
|
206
|
-
const state: State = {
|
|
305
|
+
const state: State = {
|
|
306
|
+
stack: [{ draft: doc, rank: 0 }],
|
|
307
|
+
headingCounts: [],
|
|
308
|
+
scopedArticles: new Set(),
|
|
309
|
+
widerScopes: new Map(),
|
|
310
|
+
profile: input.profile,
|
|
311
|
+
captions: new Map(),
|
|
312
|
+
};
|
|
207
313
|
lines.forEach((line) => {
|
|
208
314
|
if (line.text.trim() !== "") readLine(state, patterns, line, headings.get(line.number));
|
|
209
315
|
// コードの行は覆って読まないが、開いている節の中身ではある。節の最後にコードブロックがあっても、範囲をそこまで伸ばす。
|
|
210
316
|
else if (input.source.slice(line.start, line.start + line.text.length).trim() !== "") extend(state, line.start + line.text.length);
|
|
211
317
|
});
|
|
212
|
-
|
|
318
|
+
const tree = freeze(doc);
|
|
319
|
+
const relative = input.profile?.relative;
|
|
320
|
+
return relative === undefined ? tree : resolveRelative(tree, relative.implicitFirst);
|
|
213
321
|
};
|
package/src/structure/issues.ts
CHANGED
|
@@ -41,8 +41,15 @@ export const danglingReferences = (tree: StructureNode): StructureIssue[] => {
|
|
|
41
41
|
// 参照は条を指す。条を一つも持たない文書(契約書に付ける承諾書のひな形など)の「契約書第6条」は、別の文書の条。
|
|
42
42
|
if (!nodes.some((node) => node.kind === "article")) return [];
|
|
43
43
|
const addresses = new Set(nodes.filter((node) => NUMBERED.includes(node.kind)).map((node) => node.address));
|
|
44
|
+
// Section で組んだ法令が "Articles 13 to 21 of the UK GDPR" と別の文書の Article を名指ししていれば、名の無い "Article 6(3)" もそちら。
|
|
45
|
+
// 名指しがあれば、名の無い "Article 9" が書き間違いか向こうの条かは区別できないので黙る。名指しが無い文書では報告する。
|
|
46
|
+
const numbering = (node: StructureNode): unknown => node.attrs["numbering"];
|
|
47
|
+
const numberings = new Set(nodes.flatMap((node) => (node.kind === "article" && node.numbering !== undefined ? [node.numbering] : [])));
|
|
48
|
+
const citedElsewhere = new Set(nodes.filter((node) => node.kind === "reference" && node.attrs["document"] !== undefined).map(numbering));
|
|
49
|
+
const otherNumbering = (node: StructureNode): boolean =>
|
|
50
|
+
numberings.size > 0 && typeof numbering(node) === "string" && !numberings.has(String(numbering(node))) && citedElsewhere.has(numbering(node));
|
|
44
51
|
return nodes
|
|
45
|
-
.filter((node) => node.kind === "reference" && node.attrs["document"] === undefined)
|
|
52
|
+
.filter((node) => node.kind === "reference" && node.attrs["document"] === undefined && !otherNumbering(node))
|
|
46
53
|
.filter((node) => !resolves(node, addresses))
|
|
47
54
|
.map((node) => ({ offset: node.span.start, values: { label: textOf(node, "label"), target: textOf(node, "target") } }));
|
|
48
55
|
};
|
|
@@ -71,7 +78,8 @@ export const duplicateDefinitions = (tree: StructureNode): StructureIssue[] => {
|
|
|
71
78
|
const first = new Map<string, StructureNode>();
|
|
72
79
|
return definitionsInOrder(tree).flatMap(({ node, article }) => {
|
|
73
80
|
const term = textOf(node, "term");
|
|
74
|
-
const
|
|
81
|
+
const within = node.attrs["within"] === undefined ? article : textOf(node, "within");
|
|
82
|
+
const key = node.attrs["scope"] === "local" ? `${within}\u0000${term}` : term;
|
|
75
83
|
const earlier = first.get(key);
|
|
76
84
|
if (earlier === undefined) {
|
|
77
85
|
first.set(key, node);
|
package/src/structure/of.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { markdownOutline } from "../document.ts";
|
|
2
|
-
import type { StructureNode, StructurePatterns } from "../plugin.ts";
|
|
2
|
+
import type { DocumentProfile, StructureNode, StructurePatterns } from "../plugin.ts";
|
|
3
3
|
import { buildTree, NO_OUTLINE } from "./build.ts";
|
|
4
4
|
|
|
5
|
-
export type SourceInput = {
|
|
5
|
+
export type SourceInput = {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly source: string;
|
|
8
|
+
readonly language: string;
|
|
9
|
+
readonly markdown: boolean;
|
|
10
|
+
readonly profile?: DocumentProfile | undefined;
|
|
11
|
+
};
|
|
6
12
|
|
|
7
13
|
/**
|
|
8
14
|
* 文字列から木を作る。Markdown なら見出しとコードの範囲を先に取る。
|
|
@@ -10,6 +16,12 @@ export type SourceInput = { readonly path: string; readonly source: string; read
|
|
|
10
16
|
*/
|
|
11
17
|
export const buildStructure = (input: SourceInput, patterns: StructurePatterns): StructureNode =>
|
|
12
18
|
buildTree(
|
|
13
|
-
{
|
|
19
|
+
{
|
|
20
|
+
path: input.path,
|
|
21
|
+
source: input.source,
|
|
22
|
+
language: input.language,
|
|
23
|
+
outline: input.markdown ? markdownOutline(input.source) : NO_OUTLINE,
|
|
24
|
+
profile: input.profile,
|
|
25
|
+
},
|
|
14
26
|
patterns,
|
|
15
27
|
);
|