chaffjs 0.15.0 → 0.16.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/README.md +27 -2
- package/dist/address-chain.js +1 -1
- package/dist/address-chain.js.map +1 -1
- package/dist/backends/anthropic.d.ts +2 -1
- package/dist/backends/anthropic.d.ts.map +1 -1
- package/dist/backends/anthropic.js +4 -1
- package/dist/backends/anthropic.js.map +1 -1
- package/dist/backends/openai.d.ts +2 -1
- package/dist/backends/openai.d.ts.map +1 -1
- package/dist/backends/openai.js +1 -1
- package/dist/backends/openai.js.map +1 -1
- package/dist/backends/types.d.ts +2 -1
- package/dist/backends/types.d.ts.map +1 -1
- package/dist/backends/types.js +1 -1
- package/dist/backends/types.js.map +1 -1
- package/dist/baseline.js +1 -1
- package/dist/baseline.js.map +1 -1
- package/dist/calendar-number.d.ts +26 -0
- package/dist/calendar-number.d.ts.map +1 -0
- package/dist/calendar-number.js +51 -0
- package/dist/calendar-number.js.map +1 -0
- package/dist/cli-args.d.ts +0 -5
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +2 -2
- package/dist/cli-args.js.map +1 -1
- package/dist/cli-text.d.ts +6 -2
- package/dist/cli-text.d.ts.map +1 -1
- package/dist/cli-text.js +19 -12
- package/dist/cli-text.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +43 -37
- package/dist/cli.js.map +1 -1
- package/dist/commands/cite.d.ts.map +1 -1
- package/dist/commands/cite.js +4 -4
- package/dist/commands/cite.js.map +1 -1
- package/dist/commands/eval.js +1 -1
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/init-ask.d.ts +19 -0
- package/dist/commands/init-ask.d.ts.map +1 -0
- package/dist/commands/init-ask.js +43 -0
- package/dist/commands/init-ask.js.map +1 -0
- package/dist/commands/test.d.ts +8 -0
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +70 -38
- package/dist/commands/test.js.map +1 -1
- package/dist/commands/tree.d.ts +1 -8
- package/dist/commands/tree.d.ts.map +1 -1
- package/dist/commands/tree.js +7 -6
- package/dist/commands/tree.js.map +1 -1
- package/dist/config/by-path.d.ts.map +1 -1
- package/dist/config/by-path.js +10 -9
- package/dist/config/by-path.js.map +1 -1
- package/dist/config/load.d.ts +4 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +19 -0
- package/dist/config/load.js.map +1 -1
- package/dist/config/name-problems.d.ts +5 -0
- package/dist/config/name-problems.d.ts.map +1 -0
- package/dist/config/name-problems.js +11 -0
- package/dist/config/name-problems.js.map +1 -0
- package/dist/date-stamp.js +1 -1
- package/dist/date-stamp.js.map +1 -1
- package/dist/detectors/char-shape.d.ts.map +1 -1
- package/dist/detectors/char-shape.js +16 -6
- package/dist/detectors/char-shape.js.map +1 -1
- package/dist/detectors/en-shape.d.ts.map +1 -1
- package/dist/detectors/en-shape.js +101 -7
- package/dist/detectors/en-shape.js.map +1 -1
- package/dist/detectors/enumerated-runs.d.ts.map +1 -1
- package/dist/detectors/enumerated-runs.js +4 -2
- package/dist/detectors/enumerated-runs.js.map +1 -1
- package/dist/detectors/gram-place.js +1 -1
- package/dist/detectors/gram-place.js.map +1 -1
- package/dist/detectors/heading-case.d.ts.map +1 -1
- package/dist/detectors/heading-case.js +3 -1
- package/dist/detectors/heading-case.js.map +1 -1
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +4 -3
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/lettered-index.d.ts +9 -0
- package/dist/detectors/lettered-index.d.ts.map +1 -0
- package/dist/detectors/lettered-index.js +54 -0
- package/dist/detectors/lettered-index.js.map +1 -0
- package/dist/detectors/lexicon.d.ts +5 -1
- package/dist/detectors/lexicon.d.ts.map +1 -1
- package/dist/detectors/lexicon.js +46 -3
- package/dist/detectors/lexicon.js.map +1 -1
- package/dist/detectors/orthography.d.ts.map +1 -1
- package/dist/detectors/orthography.js +24 -6
- package/dist/detectors/orthography.js.map +1 -1
- package/dist/detectors/phrase-match.d.ts +3 -1
- package/dist/detectors/phrase-match.d.ts.map +1 -1
- package/dist/detectors/phrase-match.js +20 -15
- package/dist/detectors/phrase-match.js.map +1 -1
- package/dist/detectors/repeated-head.d.ts.map +1 -1
- package/dist/detectors/repeated-head.js +5 -4
- package/dist/detectors/repeated-head.js.map +1 -1
- package/dist/detectors/signals.d.ts.map +1 -1
- package/dist/detectors/signals.js +16 -6
- package/dist/detectors/signals.js.map +1 -1
- package/dist/detectors/stacked-hedge.d.ts +23 -0
- package/dist/detectors/stacked-hedge.d.ts.map +1 -0
- package/dist/detectors/stacked-hedge.js +64 -0
- package/dist/detectors/stacked-hedge.js.map +1 -0
- package/dist/detectors/superlative-amount.d.ts +9 -0
- package/dist/detectors/superlative-amount.d.ts.map +1 -0
- package/dist/detectors/superlative-amount.js +20 -0
- package/dist/detectors/superlative-amount.js.map +1 -0
- package/dist/detectors/superlative-clause.d.ts +18 -0
- package/dist/detectors/superlative-clause.d.ts.map +1 -0
- package/dist/detectors/superlative-clause.js +61 -0
- package/dist/detectors/superlative-clause.js.map +1 -0
- package/dist/detectors/superlative-scope.d.ts +6 -0
- package/dist/detectors/superlative-scope.d.ts.map +1 -1
- package/dist/detectors/superlative-scope.js +3 -3
- package/dist/detectors/superlative-scope.js.map +1 -1
- package/dist/detectors/team.d.ts +0 -6
- package/dist/detectors/team.d.ts.map +1 -1
- package/dist/detectors/team.js +17 -1
- package/dist/detectors/team.js.map +1 -1
- package/dist/detectors/token-shape.d.ts.map +1 -1
- package/dist/detectors/token-shape.js +8 -6
- package/dist/detectors/token-shape.js.map +1 -1
- package/dist/document.d.ts +3 -0
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +18 -3
- package/dist/document.js.map +1 -1
- package/dist/eval.js +1 -1
- package/dist/eval.js.map +1 -1
- package/dist/feedback/draft.js +1 -1
- package/dist/feedback/draft.js.map +1 -1
- package/dist/feedback/excerpt.d.ts +0 -1
- package/dist/feedback/excerpt.d.ts.map +1 -1
- package/dist/feedback/excerpt.js +2 -2
- package/dist/feedback/excerpt.js.map +1 -1
- package/dist/file-header.d.ts +20 -0
- package/dist/file-header.d.ts.map +1 -0
- package/dist/file-header.js +21 -0
- package/dist/file-header.js.map +1 -0
- package/dist/files.js +3 -3
- package/dist/files.js.map +1 -1
- package/dist/genre-check.js +1 -1
- package/dist/genre-check.js.map +1 -1
- package/dist/genre-load.d.ts +6 -0
- package/dist/genre-load.d.ts.map +1 -0
- package/dist/genre-load.js +22 -0
- package/dist/genre-load.js.map +1 -0
- package/dist/genre-parse.d.ts +40 -0
- package/dist/genre-parse.d.ts.map +1 -0
- package/dist/genre-parse.js +104 -0
- package/dist/genre-parse.js.map +1 -0
- package/dist/genre-suggest.d.ts +15 -0
- package/dist/genre-suggest.d.ts.map +1 -0
- package/dist/genre-suggest.js +26 -0
- package/dist/genre-suggest.js.map +1 -0
- package/dist/genre.d.ts +1 -1
- package/dist/genre.d.ts.map +1 -1
- package/dist/genre.js +3 -13
- package/dist/genre.js.map +1 -1
- package/dist/init-choice.d.ts +12 -0
- package/dist/init-choice.d.ts.map +1 -0
- package/dist/init-choice.js +41 -0
- package/dist/init-choice.js.map +1 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +16 -5
- package/dist/init.js.map +1 -1
- package/dist/judge.d.ts +2 -1
- package/dist/judge.d.ts.map +1 -1
- package/dist/judge.js +1 -1
- package/dist/judge.js.map +1 -1
- package/dist/line-paragraphs.d.ts +4 -0
- package/dist/line-paragraphs.d.ts.map +1 -0
- package/dist/line-paragraphs.js +69 -0
- package/dist/line-paragraphs.js.map +1 -0
- package/dist/mask.d.ts.map +1 -1
- package/dist/mask.js +7 -6
- package/dist/mask.js.map +1 -1
- package/dist/plugin.d.ts +6 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/profile/check.d.ts +28 -0
- package/dist/profile/check.d.ts.map +1 -0
- package/dist/profile/check.js +12 -0
- package/dist/profile/check.js.map +1 -0
- package/dist/profile/for-file.d.ts +2 -2
- package/dist/profile/for-file.d.ts.map +1 -1
- package/dist/profile/for-file.js +9 -2
- package/dist/profile/for-file.js.map +1 -1
- package/dist/profile/load.js +1 -1
- package/dist/profile/load.js.map +1 -1
- package/dist/profile/select.d.ts +4 -2
- package/dist/profile/select.d.ts.map +1 -1
- package/dist/profile/select.js +8 -3
- package/dist/profile/select.js.map +1 -1
- package/dist/quoted-span.d.ts +15 -3
- package/dist/quoted-span.d.ts.map +1 -1
- package/dist/quoted-span.js +16 -11
- package/dist/quoted-span.js.map +1 -1
- package/dist/reasons.d.ts +1 -0
- package/dist/reasons.d.ts.map +1 -1
- package/dist/reasons.js +4 -1
- package/dist/reasons.js.map +1 -1
- package/dist/render/compact.d.ts.map +1 -1
- package/dist/render/compact.js +1 -2
- package/dist/render/compact.js.map +1 -1
- package/dist/render/eval.d.ts.map +1 -1
- package/dist/render/eval.js +4 -3
- package/dist/render/eval.js.map +1 -1
- package/dist/render/friendly.d.ts +2 -1
- package/dist/render/friendly.d.ts.map +1 -1
- package/dist/render/friendly.js +14 -6
- package/dist/render/friendly.js.map +1 -1
- package/dist/render/genres.d.ts +5 -0
- package/dist/render/genres.d.ts.map +1 -0
- package/dist/render/genres.js +28 -0
- package/dist/render/genres.js.map +1 -0
- package/dist/render/plural.d.ts +3 -0
- package/dist/render/plural.d.ts.map +1 -0
- package/dist/render/plural.js +5 -0
- package/dist/render/plural.js.map +1 -0
- package/dist/render/rules-json.d.ts.map +1 -1
- package/dist/render/rules-json.js +9 -3
- package/dist/render/rules-json.js.map +1 -1
- package/dist/render/semantic.d.ts +3 -2
- package/dist/render/semantic.d.ts.map +1 -1
- package/dist/render/semantic.js +94 -34
- package/dist/render/semantic.js.map +1 -1
- package/dist/render/summary.d.ts.map +1 -1
- package/dist/render/summary.js +4 -3
- package/dist/render/summary.js.map +1 -1
- package/dist/render/suppressions.d.ts.map +1 -1
- package/dist/render/suppressions.js +3 -2
- package/dist/render/suppressions.js.map +1 -1
- package/dist/render/text.d.ts +2 -0
- package/dist/render/text.d.ts.map +1 -1
- package/dist/render/text.js +11 -3
- package/dist/render/text.js.map +1 -1
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +4 -1
- package/dist/rule-load.js.map +1 -1
- package/dist/run-semantic.js +1 -1
- package/dist/run-semantic.js.map +1 -1
- package/dist/run.d.ts +2 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +25 -15
- package/dist/run.js.map +1 -1
- package/dist/sentence-shape.d.ts +0 -1
- package/dist/sentence-shape.d.ts.map +1 -1
- package/dist/sentence-shape.js +3 -5
- package/dist/sentence-shape.js.map +1 -1
- package/dist/setting-problems.d.ts +5 -0
- package/dist/setting-problems.d.ts.map +1 -0
- package/dist/setting-problems.js +15 -0
- package/dist/setting-problems.js.map +1 -0
- package/dist/structure/build.js +5 -5
- package/dist/structure/build.js.map +1 -1
- package/dist/structure/cite.js +1 -1
- package/dist/structure/cite.js.map +1 -1
- package/dist/structure/issues.d.ts.map +1 -1
- package/dist/structure/issues.js +5 -5
- package/dist/structure/issues.js.map +1 -1
- package/dist/structure/markdown-path.d.ts.map +1 -1
- package/dist/structure/markdown-path.js +2 -2
- package/dist/structure/markdown-path.js.map +1 -1
- package/dist/structure/numbering.d.ts.map +1 -1
- package/dist/structure/numbering.js +1 -1
- package/dist/structure/numbering.js.map +1 -1
- package/dist/structure/relative-find.js +1 -1
- package/dist/structure/relative-find.js.map +1 -1
- package/dist/structure/sexp.js +1 -1
- package/dist/structure/sexp.js.map +1 -1
- package/dist/structure/unnumbered.js +2 -2
- package/dist/structure/unnumbered.js.map +1 -1
- package/dist/team-names.d.ts +9 -0
- package/dist/team-names.d.ts.map +1 -0
- package/dist/team-names.js +34 -0
- package/dist/team-names.js.map +1 -0
- package/dist/ui.d.ts +2 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +6 -0
- package/dist/ui.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/watch.d.ts +2 -2
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +5 -5
- package/dist/watch.js.map +1 -1
- package/genres.yaml +363 -0
- package/package.json +4 -3
- package/rules/adverb-overuse.yaml +2 -2
- package/rules/agentless-passive.yaml +1 -1
- package/rules/ai-generated-composite.yaml +2 -2
- package/rules/ai-tell.yaml +2 -2
- package/rules/bold-density.yaml +2 -2
- package/rules/closing-cliche.yaml +1 -1
- package/rules/concrete-evidence-density.yaml +2 -2
- package/rules/contraction-consistency.yaml +1 -1
- package/rules/cushion-phrase-density.yaml +1 -1
- package/rules/dangling-reference.yaml +1 -1
- package/rules/date-order.yaml +1 -1
- package/rules/date-weekday-mismatch.yaml +1 -1
- package/rules/double-keigo.yaml +1 -1
- package/rules/doubled-word.yaml +1 -1
- package/rules/duplicate-definition.yaml +1 -1
- package/rules/emoji-density.yaml +1 -1
- package/rules/empty-conclusion.yaml +2 -1
- package/rules/empty-intensifier.yaml +1 -1
- package/rules/excessive-hedging.yaml +16 -6
- package/rules/expletive-construction.yaml +2 -2
- package/rules/heading-echo.yaml +1 -1
- package/rules/hiragana-fukushi.yaml +1 -1
- package/rules/internal-jargon.yaml +2 -2
- package/rules/latin-spacing.yaml +5 -4
- package/rules/max-kanji-continuous.yaml +4 -3
- package/rules/max-paragraph-length.yaml +9 -2
- package/rules/max-sentence-length.yaml +14 -2
- package/rules/ngram-repetition.yaml +4 -4
- package/rules/no-doubled-joshi.yaml +2 -2
- package/rules/no-em-dash.yaml +2 -2
- package/rules/no-mixed-desumasu.yaml +1 -1
- package/rules/no-nakaguro-parallel.yaml +2 -2
- package/rules/numbering-gap.yaml +1 -1
- package/rules/oxford-comma-consistency.yaml +2 -1
- package/rules/padded-intro.yaml +2 -1
- package/rules/paragraph-length-variance.yaml +1 -1
- package/rules/preamble-length.yaml +2 -2
- package/rules/preferred-term.yaml +1 -1
- package/rules/proper-noun-density.yaml +3 -2
- package/rules/repeated-conjunction.yaml +2 -2
- package/rules/repeated-sentence-head.yaml +2 -2
- package/rules/required-sections.yaml +1 -1
- package/rules/rule-of-three.yaml +2 -2
- package/rules/sasete-itadaku.yaml +3 -3
- package/rules/section-length-uniformity.yaml +1 -1
- package/rules/sentence-initial-conjunction-run.yaml +2 -2
- package/rules/sentence-rhythm.yaml +1 -1
- package/rules/taigen-dome-in-prose.yaml +2 -2
- package/rules/title-case-consistency.yaml +1 -1
- package/rules/total-mismatch.yaml +1 -1
- package/rules/undefined-acronym.yaml +4 -4
- package/rules/unqualified-superlative.yaml +2 -2
- package/skills/chaff/SKILL.md +28 -9
- package/src/address-chain.ts +1 -1
- package/src/backends/anthropic.ts +5 -1
- package/src/backends/openai.ts +2 -1
- package/src/backends/types.ts +3 -2
- package/src/baseline.ts +1 -1
- package/src/calendar-number.ts +75 -0
- package/src/cli-args.ts +2 -2
- package/src/cli-text.ts +29 -15
- package/src/cli.ts +42 -41
- package/src/commands/cite.ts +4 -4
- package/src/commands/eval.ts +1 -1
- package/src/commands/init-ask.ts +53 -0
- package/src/commands/test.ts +102 -45
- package/src/commands/tree.ts +7 -6
- package/src/config/by-path.ts +8 -9
- package/src/config/load.ts +24 -0
- package/src/config/name-problems.ts +15 -0
- package/src/date-stamp.ts +1 -1
- package/src/detectors/char-shape.ts +21 -9
- package/src/detectors/en-shape.ts +106 -8
- package/src/detectors/enumerated-runs.ts +3 -2
- package/src/detectors/gram-place.ts +1 -1
- package/src/detectors/heading-case.ts +4 -1
- package/src/detectors/index.ts +4 -3
- package/src/detectors/lettered-index.ts +78 -0
- package/src/detectors/lexicon.ts +63 -6
- package/src/detectors/orthography.ts +31 -7
- package/src/detectors/phrase-match.ts +23 -12
- package/src/detectors/repeated-head.ts +3 -4
- package/src/detectors/signals.ts +15 -6
- package/src/detectors/stacked-hedge.ts +93 -0
- package/src/detectors/superlative-amount.ts +24 -0
- package/src/detectors/superlative-clause.ts +82 -0
- package/src/detectors/superlative-scope.ts +3 -3
- package/src/detectors/team.ts +18 -2
- package/src/detectors/token-shape.ts +8 -10
- package/src/document.ts +29 -3
- package/src/eval.ts +1 -1
- package/src/feedback/draft.ts +1 -1
- package/src/feedback/excerpt.ts +2 -2
- package/src/file-header.ts +31 -0
- package/src/files.ts +3 -3
- package/src/genre-check.ts +1 -1
- package/src/genre-load.ts +24 -0
- package/src/genre-parse.ts +126 -0
- package/src/genre-suggest.ts +37 -0
- package/src/genre.ts +4 -13
- package/src/init-choice.ts +47 -0
- package/src/init.ts +20 -6
- package/src/judge.ts +3 -1
- package/src/line-paragraphs.ts +83 -0
- package/src/mask.ts +5 -6
- package/src/plugin.ts +6 -0
- package/src/profile/check.ts +34 -0
- package/src/profile/for-file.ts +10 -3
- package/src/profile/load.ts +1 -1
- package/src/profile/select.ts +11 -4
- package/src/quoted-span.ts +26 -12
- package/src/reasons.ts +5 -1
- package/src/render/compact.ts +1 -3
- package/src/render/eval.ts +4 -3
- package/src/render/friendly.ts +21 -6
- package/src/render/genres.ts +31 -0
- package/src/render/plural.ts +6 -0
- package/src/render/rules-json.ts +10 -3
- package/src/render/semantic.ts +117 -34
- package/src/render/summary.ts +4 -3
- package/src/render/suppressions.ts +3 -2
- package/src/render/text.ts +18 -3
- package/src/rule-load.ts +6 -1
- package/src/run-semantic.ts +1 -1
- package/src/run.ts +28 -15
- package/src/sentence-shape.ts +3 -7
- package/src/setting-problems.ts +24 -0
- package/src/structure/build.ts +5 -5
- package/src/structure/cite.ts +1 -1
- package/src/structure/issues.ts +6 -5
- package/src/structure/markdown-path.ts +2 -2
- package/src/structure/numbering.ts +1 -1
- package/src/structure/relative-find.ts +1 -1
- package/src/structure/sexp.ts +1 -1
- package/src/structure/unnumbered.ts +2 -2
- package/src/team-names.ts +39 -0
- package/src/ui.ts +7 -0
- package/src/version.ts +1 -1
- package/src/watch.ts +5 -5
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Lexicon, Token } from "../plugin.ts";
|
|
2
|
+
import type { TokenRange } from "./lexicon-match.ts";
|
|
3
|
+
import { PHRASE_END, PHRASE_START, phraseEnd } from "./superlative-scope.ts";
|
|
4
|
+
|
|
5
|
+
/** 最上級の名詞句を後ろから限る語。どちらも言語パッケージの語彙表が持つ。 */
|
|
6
|
+
export type Restrictors = {
|
|
7
|
+
/** 名詞の後ろで関係節を始める語(that・which)。空の言語は、名詞の後ろに節を置かない。 */
|
|
8
|
+
readonly relatives: Lexicon;
|
|
9
|
+
/** 最上級を手に入る範囲に限る形容詞(possible・available)。 */
|
|
10
|
+
readonly bounds: Lexicon;
|
|
11
|
+
/** 節の主語になる代名詞(we・they)。themselves のような再帰の代名詞は主語にならない。 */
|
|
12
|
+
readonly subjects: Lexicon;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const VERBAL = new Set(["VERB", "AUX"]);
|
|
16
|
+
|
|
17
|
+
const isWord = (token: Token | undefined, words: Lexicon): boolean =>
|
|
18
|
+
token !== undefined && words.some((entry) => entry.pattern.toLowerCase() === token.surface.toLowerCase());
|
|
19
|
+
|
|
20
|
+
const isVerbal = (token: Token | undefined): boolean => token !== undefined && VERBAL.has(token.pos);
|
|
21
|
+
|
|
22
|
+
/** 節の主語(we・managers・the team・this)を読み飛ばした先に動詞があるか。主語の無い節(that has defined)は動詞で始まる。 */
|
|
23
|
+
const verbAfterSubject = (tokens: readonly Token[], at: number, subjects: Lexicon): boolean => {
|
|
24
|
+
if (isVerbal(tokens[at])) return true;
|
|
25
|
+
if (isWord(tokens[at], subjects)) return isVerbal(tokens[at + 1]);
|
|
26
|
+
if (tokens[at]?.pos === "DET") return isVerbal(tokens[at + 1]) || isVerbal(tokens[phraseEnd(tokens, at + 1)]);
|
|
27
|
+
return isVerbal(tokens[phraseEnd(tokens, at)]);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const isArticle = (token: Token | undefined): boolean => token?.features?.["PronType"] === "Art";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 関係詞を省いた節の主語は、代名詞(the best we have measured)か冠詞で始まる名詞句(the best option the team selected)。
|
|
34
|
+
* each・all のような限定詞(The best teams all adapt)や、冠詞の無い名詞(The best teams adapt)は節の始まりと読まない。
|
|
35
|
+
*/
|
|
36
|
+
const contactClause = (tokens: readonly Token[], at: number, subjects: Lexicon): boolean => {
|
|
37
|
+
if (isWord(tokens[at], subjects)) return isVerbal(tokens[at + 1]);
|
|
38
|
+
return isArticle(tokens[at]) && isVerbal(tokens[phraseEnd(tokens, at + 1)]);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 関係節(the threats that we have seen・the option which we chose)と、関係詞を省いた節。 */
|
|
42
|
+
const clause = (tokens: readonly Token[], at: number, restrictors: Restrictors): boolean =>
|
|
43
|
+
isWord(tokens[at], restrictors.relatives) ? verbAfterSubject(tokens, at + 1, restrictors.subjects) : contactClause(tokens, at, restrictors.subjects);
|
|
44
|
+
|
|
45
|
+
/** 分詞の後ろに文の動詞があるか。and を挟んだ動詞(The best option changed quickly and won)は分詞と並ぶ述語。 */
|
|
46
|
+
const mainVerbFollows = (tokens: readonly Token[], at: number): boolean => {
|
|
47
|
+
const rest = tokens.slice(at + 1);
|
|
48
|
+
const verb = rest.findIndex(isVerbal);
|
|
49
|
+
return verb !== -1 && !rest.slice(0, verb).some((token) => token.pos === "CCONJ");
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 名詞の後ろの過去分詞(the most scalable option discussed)。名詞句が動詞の目的語か補語で(We chose the best option discussed)、
|
|
54
|
+
* または分詞の後ろに文の動詞がある(The best results obtained were poor)ときだけ。目的語を取る分詞(The best solution
|
|
55
|
+
* proposed a fix)と、ほかに述語の無い分詞(The best option changed.)は文の動詞。
|
|
56
|
+
*/
|
|
57
|
+
const participle = (tokens: readonly Token[], range: TokenRange, at: number): boolean => {
|
|
58
|
+
const next = tokens[at + 1];
|
|
59
|
+
if (tokens[at]?.features?.["VerbForm"] !== "Part" || (next !== undefined && PHRASE_START.has(next.pos))) return false;
|
|
60
|
+
return isVerbal(tokens[range.start - 1]) || mainVerbFollows(tokens, at);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** 最上級のすぐ後ろ(the best possible outcome)か、名詞句の後ろ(the best education possible)。 */
|
|
64
|
+
const bounded = (tokens: readonly Token[], range: TokenRange, at: number, bounds: Lexicon): boolean =>
|
|
65
|
+
isWord(tokens[range.end], bounds) || isWord(tokens[at], bounds);
|
|
66
|
+
|
|
67
|
+
/** 修飾の語は名詞の前に来るので、句の後ろで止まった分詞は名詞の後ろにある。 */
|
|
68
|
+
const clauseOrParticiple = (tokens: readonly Token[], range: TokenRange, at: number, restrictors: Restrictors): boolean => {
|
|
69
|
+
const last = tokens[at - 1];
|
|
70
|
+
const endsPhrase = at === range.end || (last !== undefined && PHRASE_END.has(last.pos));
|
|
71
|
+
return (endsPhrase && clause(tokens, at, restrictors)) || participle(tokens, range, at);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 最上級の名詞句を後ろの節か分詞か形容詞が限っているか(the best we have measured・the most scalable option discussed・
|
|
76
|
+
* the best education possible)。何の中で最もなのかを言っているので、限定の無い最上級ではない。
|
|
77
|
+
* 名詞の後ろに節を置かない言語は関係詞の語彙表が空で、節も分詞も読まない。
|
|
78
|
+
*/
|
|
79
|
+
export const restricted = (tokens: readonly Token[], range: TokenRange, restrictors: Restrictors): boolean => {
|
|
80
|
+
const at = phraseEnd(tokens, range.end);
|
|
81
|
+
return bounded(tokens, range, at, restrictors.bounds) || (restrictors.relatives.length > 0 && clauseOrParticiple(tokens, range, at, restrictors));
|
|
82
|
+
};
|
|
@@ -21,14 +21,14 @@ export const scopeMarkersOf = (lexicon: Lexicon): ScopeMarkers => ({
|
|
|
21
21
|
const NOMINAL = new Set(["NOUN", "PROPN"]);
|
|
22
22
|
|
|
23
23
|
/** 名詞句の始まり。the world・Chicago・our products・the three・all。 */
|
|
24
|
-
const PHRASE_START = new Set(["DET", "NOUN", "PROPN", "PRON", "NUM", "ADJ"]);
|
|
24
|
+
export const PHRASE_START = new Set(["DET", "NOUN", "PROPN", "PRON", "NUM", "ADJ"]);
|
|
25
25
|
|
|
26
26
|
/** 名詞の前に来る修飾(most skilled・widely used・stuffed)。名詞が出た後は名詞の連なりだけが続く。 */
|
|
27
27
|
const PREMODIFIER = new Set(["ADJ", "ADV", "VERB", "NOUN", "PROPN", "NUM"]);
|
|
28
28
|
const NOUN_RUN = new Set(["NOUN", "PROPN", "NUM"]);
|
|
29
29
|
|
|
30
30
|
/** 範囲の前に置ける語。修飾だけで終わった句(the best is in …)は範囲ではなく述語。 */
|
|
31
|
-
const PHRASE_END = new Set(["NOUN", "PROPN", "NUM", "ADJ"]);
|
|
31
|
+
export const PHRASE_END = new Set(["NOUN", "PROPN", "NUM", "ADJ"]);
|
|
32
32
|
|
|
33
33
|
const isMarker = (token: Token | undefined, markers: Lexicon): boolean =>
|
|
34
34
|
token !== undefined && markers.some((entry) => entry.pattern.toLowerCase() === token.surface.toLowerCase());
|
|
@@ -47,7 +47,7 @@ const namedBefore = (tokens: readonly Token[], range: TokenRange, markers: Lexic
|
|
|
47
47
|
isMarker(tokens[range.start - 1], markers) && isNamed(tokens[range.start - 2]);
|
|
48
48
|
|
|
49
49
|
/** 最上級に続く名詞句の終わり。名詞が出るまでは修飾を、出た後は名詞の連なりを読む。 */
|
|
50
|
-
const phraseEnd = (tokens: readonly Token[], at: number, nounSeen = false): number => {
|
|
50
|
+
export const phraseEnd = (tokens: readonly Token[], at: number, nounSeen = false): number => {
|
|
51
51
|
const token = tokens[at];
|
|
52
52
|
if (token === undefined || !(nounSeen ? NOUN_RUN : PREMODIFIER).has(token.pos)) return at;
|
|
53
53
|
return phraseEnd(tokens, at + 1, nounSeen || NOMINAL.has(token.pos));
|
package/src/detectors/team.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { proseText } from "../measure.ts";
|
|
2
2
|
import { joinWords } from "./word-list.ts";
|
|
3
3
|
import { wordsOf } from "./structure.ts";
|
|
4
|
-
import
|
|
4
|
+
import { nameSpans } from "../team-names.ts";
|
|
5
|
+
import type { Detector, Finding, Sentence, Span, Token } from "../plugin.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* チームの言葉。社内でしか通じない語を、チームが chaff.yaml に自分で並べる。
|
|
@@ -76,8 +77,23 @@ const isProperNoun = (token: Token): boolean => token.pos === "PROPN";
|
|
|
76
77
|
* spec は未知語率としていたが、**品詞解析があれば PROPN を数えるだけで足りる**。
|
|
77
78
|
* 辞書を別に持つ必要はない。
|
|
78
79
|
*/
|
|
80
|
+
const within = (spans: readonly Span[], token: Token): Span | undefined => spans.find((span) => token.span.start >= span.start && token.span.end <= span.end);
|
|
81
|
+
|
|
82
|
+
/** 並べた名前は、何語に割れても 1 つの固有名詞(Bank of England は 2 つでなく 1 つ)。名前の中の 2 語目からを落とす。 */
|
|
83
|
+
const properNounsOf = (sentence: Sentence, names: readonly string[]): Token[] => {
|
|
84
|
+
const named = nameSpans(sentence.text, names).map((span) => ({ start: sentence.span.start + span.start, end: sentence.span.start + span.end }));
|
|
85
|
+
const counted = new Set<Span>();
|
|
86
|
+
return (sentence.tokens ?? []).filter(isProperNoun).filter((token) => {
|
|
87
|
+
const name = within(named, token);
|
|
88
|
+
if (name === undefined) return true;
|
|
89
|
+
if (counted.has(name)) return false;
|
|
90
|
+
counted.add(name);
|
|
91
|
+
return true;
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
79
95
|
export const properNounDensity: Detector = (doc, options): Finding[] => {
|
|
80
|
-
const hits = doc.sentences.flatMap((sentence) => (sentence.
|
|
96
|
+
const hits = doc.sentences.flatMap((sentence) => properNounsOf(sentence, doc.names ?? []).map((token) => ({ sentence, token })));
|
|
81
97
|
const length = wordsOf(doc);
|
|
82
98
|
const rate = length === 0 ? 0 : Math.round((hits.length / length) * PER);
|
|
83
99
|
const first = hits[0];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hasParticle, isClosed, lastContent } from "../sentence-shape.ts";
|
|
2
|
-
import { isWithinAny,
|
|
3
|
-
import type { Detector, Finding,
|
|
2
|
+
import { isWithinAny, quotedIn } from "../quoted-span.ts";
|
|
3
|
+
import type { Detector, Finding, Span, Token } from "../plugin.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* 文末が名詞で終わる(体言止め)。箇条書きでは普通だが、本文では文が途中で切れて読める。
|
|
@@ -45,10 +45,11 @@ export const nounEnding: Detector = (doc, options): Finding[] => {
|
|
|
45
45
|
*/
|
|
46
46
|
type Run = { readonly surface: string; readonly tokens: readonly Token[] };
|
|
47
47
|
|
|
48
|
-
const extend = (runs:
|
|
48
|
+
const extend = (runs: Run[], token: Token): Run[] => {
|
|
49
49
|
const last = runs.at(-1);
|
|
50
|
-
if (last !== undefined && last.surface === token.surface)
|
|
51
|
-
|
|
50
|
+
if (last !== undefined && last.surface === token.surface) runs[runs.length - 1] = { surface: last.surface, tokens: [...last.tokens, token] };
|
|
51
|
+
else runs.push({ surface: token.surface, tokens: [token] });
|
|
52
|
+
return runs;
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
const PARTICLE = new Set(["ADP", "SCONJ", "PART", "CCONJ"]);
|
|
@@ -66,13 +67,10 @@ const runsOf = (tokens: readonly Token[], nesting: readonly string[], quoted: re
|
|
|
66
67
|
tokens.reduce<Run[]>((acc, token) => {
|
|
67
68
|
if (isWithinAny(quoted, token.span)) return acc;
|
|
68
69
|
if (nesting.includes(token.surface)) return extend(acc, token);
|
|
69
|
-
|
|
70
|
+
if (token.pos === "PUNCT" || PARTICLE.has(token.pos)) acc.push(BREAK);
|
|
71
|
+
return acc;
|
|
70
72
|
}, []);
|
|
71
73
|
|
|
72
|
-
/** 文の中の、鉤括弧で引いたものを括弧ごと、文書全体の座標で。 */
|
|
73
|
-
const quotedIn = (sentence: Sentence): Span[] =>
|
|
74
|
-
quotedSpans(sentence.text).map((span) => ({ start: sentence.span.start + span.start - 1, end: sentence.span.start + span.end + 1 }));
|
|
75
|
-
|
|
76
74
|
export const doubledParticle: Detector = (doc, options): Finding[] => {
|
|
77
75
|
const nesting = (options.lexicon ?? []).map((entry) => entry.pattern);
|
|
78
76
|
return doc.sentences.flatMap((sentence) =>
|
package/src/document.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { hasTitle } from "./heading-title.ts";
|
|
|
8
8
|
import { maskSpans } from "./mask.ts";
|
|
9
9
|
import { spansWithin, unmaskedSoftBreaks } from "./soft-break.ts";
|
|
10
10
|
import { segmentJoined } from "./joined-view.ts";
|
|
11
|
+
import { lineParagraphs } from "./line-paragraphs.ts";
|
|
11
12
|
import { buildTree, type Outline } from "./structure/build.ts";
|
|
12
13
|
import { isMarkdownPath } from "./structure/markdown-path.ts";
|
|
13
14
|
import { pageFurniture, textOutline } from "./page-furniture.ts";
|
|
@@ -221,12 +222,32 @@ const sectionsOf = (headings: readonly Heading[], sentences: readonly Sentence[]
|
|
|
221
222
|
});
|
|
222
223
|
};
|
|
223
224
|
|
|
224
|
-
|
|
225
|
+
/** sentences(並び順)の中で、start が offset 以上の最初の添字。1 行 1 段落の何万行でも、段落ごとに全部をなめない。 */
|
|
226
|
+
const firstStartingAt = (sentences: readonly Sentence[], offset: number): number => {
|
|
227
|
+
const search = (low: number, high: number): number => {
|
|
228
|
+
if (low >= high) return low;
|
|
229
|
+
const middle = (low + high) >> 1;
|
|
230
|
+
return (sentences[middle]?.span.start ?? Number.POSITIVE_INFINITY) >= offset ? search(low, middle) : search(middle + 1, high);
|
|
231
|
+
};
|
|
232
|
+
return search(0, sentences.length);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const sentencesWithin = (sentences: readonly Sentence[], span: Span): Sentence[] =>
|
|
236
|
+
sentences.slice(firstStartingAt(sentences, span.start), firstStartingAt(sentences, span.end));
|
|
237
|
+
|
|
238
|
+
const paragraphsOf = (prose: string, spans: readonly Span[], sentences: readonly Sentence[], listSpans: readonly Span[]): Paragraph[] =>
|
|
225
239
|
spans
|
|
226
240
|
// 箇条書きの中の段落は「段落」として数えない。項目 1 つを 1 段落と読むと、
|
|
227
241
|
// 段落あたりの文数も長さのばらつきも、箇条書きの多い文書で壊れる。
|
|
228
242
|
.filter((span) => !listSpans.some((list) => span.start >= list.start && span.start < list.end))
|
|
229
|
-
.
|
|
243
|
+
.flatMap((span) =>
|
|
244
|
+
lineParagraphs(
|
|
245
|
+
prose,
|
|
246
|
+
span,
|
|
247
|
+
sentencesWithin(sentences, span).map((sentence) => sentence.span),
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
.map((span) => ({ span, sentences: sentencesWithin(sentences, span) }));
|
|
230
251
|
|
|
231
252
|
/** 箇条書きは list ノードの直下の項目を数える。入れ子の項目は内側の list のものとして数える。目次のような案内だけの箇条書きは数えない。 */
|
|
232
253
|
const listsOf = (root: Node, source: string, anchors: InPageAnchors): BulletList[] => {
|
|
@@ -250,6 +271,8 @@ export type TeamRules = {
|
|
|
250
271
|
readonly requiredSections: readonly string[];
|
|
251
272
|
/** { 使わない書き方: 使う書き方 }。preferred-term が語彙表として読む。 */
|
|
252
273
|
readonly prefer?: Readonly<Record<string, string>>;
|
|
274
|
+
/** チームの固有名詞。1 つの名前として読む rule が見る。 */
|
|
275
|
+
readonly names?: readonly string[];
|
|
253
276
|
};
|
|
254
277
|
|
|
255
278
|
const EMPTY_TEAM: TeamRules = { jargon: [], requiredSections: [] };
|
|
@@ -259,10 +282,12 @@ export const teamRules = (config: {
|
|
|
259
282
|
readonly jargon: readonly string[];
|
|
260
283
|
readonly requiredSections: readonly string[];
|
|
261
284
|
readonly prefer?: Readonly<Record<string, string>>;
|
|
285
|
+
readonly names?: readonly string[];
|
|
262
286
|
}): TeamRules => ({
|
|
263
287
|
jargon: config.jargon,
|
|
264
288
|
prefer: config.prefer ?? {},
|
|
265
289
|
requiredSections: config.requiredSections,
|
|
290
|
+
names: config.names ?? [],
|
|
266
291
|
});
|
|
267
292
|
|
|
268
293
|
export const buildDocument = (
|
|
@@ -305,11 +330,12 @@ export const buildDocument = (
|
|
|
305
330
|
sections: sectionsOf(headingsOf(root, source), sentences, strongSpans(root, blocks), source.length),
|
|
306
331
|
sentences,
|
|
307
332
|
listSpans: listItems,
|
|
308
|
-
paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
|
|
333
|
+
paragraphs: paragraphsOf(prose, paragraphSpans, sentences, listItems),
|
|
309
334
|
lists: listsOf(root, source, anchors),
|
|
310
335
|
links: [...spansOfType(root, "link"), ...spansOfType(root, "linkReference")],
|
|
311
336
|
lexicons: tagged ? tokenizedLexicons(lexicons, adapter) : lexicons,
|
|
312
337
|
requiredSections: team.requiredSections,
|
|
338
|
+
names: team.names ?? [],
|
|
313
339
|
// 構造の rule(参照先が無い・番号の抜け)が読む木。どの rule も読まなければ作らない。何万行の契約書で、他の rule の lint に代金を払わせない。
|
|
314
340
|
get structure(): StructureNode | undefined {
|
|
315
341
|
tree.value ??=
|
package/src/eval.ts
CHANGED
|
@@ -41,7 +41,7 @@ const candidates = (rule: RuleDefinition, current: number): number[] => {
|
|
|
41
41
|
const fromLevels = [rule.levels.strict, rule.levels.normal, rule.levels.relaxed].filter((value) => value !== undefined);
|
|
42
42
|
const scaled = MULTIPLIERS.map((factor) => Math.max(1, Math.round(base * factor)));
|
|
43
43
|
// 数値で決めた上限は、段の表に無くても掃引に入れる。入れないと「現在」の行が出ない。
|
|
44
|
-
return [...new Set([...fromLevels, ...scaled, current])].
|
|
44
|
+
return [...new Set([...fromLevels, ...scaled, current])].toSorted((left, right) => left - right);
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
const countAt = (docs: readonly ProseDocument[], rule: RuleDefinition, limit: number): Point => {
|
package/src/feedback/draft.ts
CHANGED
|
@@ -61,7 +61,7 @@ const TEXT: Texts<{
|
|
|
61
61
|
"false-positive": "<!-- Why it is wrong, and how the text should be read -->",
|
|
62
62
|
missed: "<!-- What is wrong in the text, and which rule should have said so -->",
|
|
63
63
|
},
|
|
64
|
-
lines: (from, to) => `lines ${String(from)}-${String(to)}
|
|
64
|
+
lines: (from, to) => (from === to ? `line ${String(from)}` : `lines ${String(from)}-${String(to)}`),
|
|
65
65
|
},
|
|
66
66
|
};
|
|
67
67
|
|
package/src/feedback/excerpt.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/** Lines around a line of a document, 1-based and inclusive. Only these lines go into a report. */
|
|
2
2
|
export type Excerpt = { readonly from: number; readonly to: number; readonly lines: readonly string[] };
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
const EXCERPT_RADIUS = 2;
|
|
5
5
|
|
|
6
6
|
/** Pure: the excerpts around each line, overlapping ones merged, in document order. Lines outside the document are dropped. */
|
|
7
7
|
export const excerptsAround = (source: string, lines: readonly number[], radius: number = EXCERPT_RADIUS): Excerpt[] => {
|
|
8
8
|
const all = source.split("\n");
|
|
9
9
|
const ranges = [...new Set(lines)]
|
|
10
10
|
.filter((line) => line >= 1 && line <= all.length)
|
|
11
|
-
.
|
|
11
|
+
.toSorted((left, right) => left - right)
|
|
12
12
|
.map((line) => ({ from: Math.max(1, line - radius), to: Math.min(all.length, line + radius) }));
|
|
13
13
|
const merged = ranges.reduce<{ from: number; to: number }[]>((acc, range) => {
|
|
14
14
|
const last = acc.at(-1);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { CLI_TEXT, type GenreSource } from "./cli-text.ts";
|
|
2
|
+
import { loadGenres } from "./genre-load.ts";
|
|
3
|
+
import type { GenreDefinition } from "./genre-parse.ts";
|
|
4
|
+
import { suggestGenre } from "./genre-suggest.ts";
|
|
5
|
+
import { loadProfiles } from "./profile/load.ts";
|
|
6
|
+
import { uiLanguageOf } from "./ui.ts";
|
|
7
|
+
|
|
8
|
+
/** The file's first lines, and the notes after its not-run list: the genre it looks like, when none was set. */
|
|
9
|
+
type FileHeader = { readonly header: string; readonly notes: readonly string[] };
|
|
10
|
+
|
|
11
|
+
/** unread: a genre the front matter wrote that chaff does not know (already reported on stderr). */
|
|
12
|
+
type Placed = { readonly genre: string; readonly from: GenreSource; readonly unread?: string | undefined };
|
|
13
|
+
|
|
14
|
+
type Counts = { readonly shelved: number; readonly hushed: number };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The genre the file looks like, only when nothing set or guessed one: suggested on screen, never used for the run.
|
|
18
|
+
* A front matter genre chaff could not read is a genre the writer did set, so the note's "no genre was set" would be untrue.
|
|
19
|
+
*/
|
|
20
|
+
const suggestionFor = (path: string, source: string, language: string, placed: Placed): GenreDefinition | undefined =>
|
|
21
|
+
placed.from === "default" && placed.unread === undefined ? suggestGenre({ path, source, language }, loadGenres(), loadProfiles()) : undefined;
|
|
22
|
+
|
|
23
|
+
/** In the file's language. The note names the genre the file was checked as. */
|
|
24
|
+
export const fileHeader = (path: string, source: string, language: string, placed: Placed, counts: Counts): FileHeader => {
|
|
25
|
+
const text = CLI_TEXT[uiLanguageOf(language)];
|
|
26
|
+
const first = text.header(path, placed.genre, text.languageName(language), text.genreSource[placed.from], counts.shelved, counts.hushed);
|
|
27
|
+
const suggestion = suggestionFor(path, source, language, placed);
|
|
28
|
+
if (suggestion === undefined) return { header: first, notes: [] };
|
|
29
|
+
const name = suggestion.name[uiLanguageOf(language)] ?? suggestion.id;
|
|
30
|
+
return { header: [first, text.suggested(name, suggestion.id)].join("\n"), notes: [text.suggestedNote(name, suggestion.id, placed.genre)] };
|
|
31
|
+
};
|
package/src/files.ts
CHANGED
|
@@ -4,11 +4,11 @@ import { join, relative, sep } from "node:path";
|
|
|
4
4
|
const MARKDOWN = [".md", ".markdown", ".mdx"];
|
|
5
5
|
|
|
6
6
|
/** 走査から外す。ここを通すと node_modules の README を延々と検査することになる。 */
|
|
7
|
-
const SKIP = ["node_modules", "dist", "build", "coverage", ".git", ".chaff-cache"];
|
|
7
|
+
const SKIP: ReadonlySet<string> = new Set(["node_modules", "dist", "build", "coverage", ".git", ".chaff-cache"]);
|
|
8
8
|
|
|
9
9
|
const isMarkdown = (path: string): boolean => MARKDOWN.some((ext) => path.toLowerCase().endsWith(ext));
|
|
10
10
|
|
|
11
|
-
const isSkipped = (path: string): boolean => path.split(sep).some((part) => SKIP.
|
|
11
|
+
const isSkipped = (path: string): boolean => path.split(sep).some((part) => SKIP.has(part));
|
|
12
12
|
|
|
13
13
|
const expand = (target: string): string[] => {
|
|
14
14
|
const stat = statSync(target, { throwIfNoEntry: false });
|
|
@@ -30,5 +30,5 @@ const byPath = (left: string, right: string): number => left.localeCompare(right
|
|
|
30
30
|
|
|
31
31
|
export const collectTargets = (targets: readonly string[]): string[] => {
|
|
32
32
|
const found = targets.flatMap(expand).filter((path) => !isSkipped(path));
|
|
33
|
-
return [...new Set(found)].
|
|
33
|
+
return [...new Set(found)].toSorted((left, right) => byPath(relative(".", left), relative(".", right)));
|
|
34
34
|
};
|
package/src/genre-check.ts
CHANGED
|
@@ -15,7 +15,7 @@ type PathGenre = { readonly files: readonly string[]; readonly genre: string | u
|
|
|
15
15
|
type WrittenGenres = { readonly flag: string | undefined; readonly config: string | undefined; readonly byPath: readonly PathGenre[] };
|
|
16
16
|
|
|
17
17
|
/** Commands that never read a genre from chaff.yaml. A wrong one there must not stop them: genres is how to find the right name. */
|
|
18
|
-
const GENRE_FREE: ReadonlySet<string> = new Set(["init", "genres", "skill", "relax", "strict", "off"
|
|
18
|
+
const GENRE_FREE: ReadonlySet<string> = new Set(["init", "genres", "skill", "relax", "strict", "off"]);
|
|
19
19
|
|
|
20
20
|
/** The genres a command would read: --genre always (every command refuses a wrong one), chaff.yaml's only where the command reads a genre. */
|
|
21
21
|
export const writtenGenres = (
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { parseGenres, presetLevelsOf, presetProfileOf, type GenreData, type PresetLevels } from "./genre-parse.ts";
|
|
6
|
+
|
|
7
|
+
const GENRES_FILE = join(dirname(fileURLToPath(import.meta.url)), "..", "genres.yaml");
|
|
8
|
+
|
|
9
|
+
const loaded: { value: GenreData | undefined } = { value: undefined };
|
|
10
|
+
|
|
11
|
+
/** The bundled genres.yaml. Read once. */
|
|
12
|
+
export const loadGenres = (): GenreData => {
|
|
13
|
+
if (loaded.value !== undefined) return loaded.value;
|
|
14
|
+
try {
|
|
15
|
+
loaded.value = parseGenres(parse(readFileSync(GENRES_FILE, "utf8")));
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new Error(`cannot read ${GENRES_FILE}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
18
|
+
}
|
|
19
|
+
return loaded.value;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const presetLevels = (genre: string): PresetLevels => presetLevelsOf(loadGenres(), genre);
|
|
23
|
+
|
|
24
|
+
export const presetProfile = (genre: string): string | undefined => presetProfileOf(loadGenres(), genre);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { isLevel } from "./levels.ts";
|
|
2
|
+
import type { Level, Localized } from "./plugin.ts";
|
|
3
|
+
|
|
4
|
+
/** A preset's rules: the level each named rule runs at, under whatever chaff.yaml's rules say. */
|
|
5
|
+
export type PresetLevels = Readonly<Record<string, Level>>;
|
|
6
|
+
|
|
7
|
+
/** A group of genres (legal, literature): its name, and the levels every genre in it shares. */
|
|
8
|
+
export type GenreGroup = { readonly id: string; readonly name: Localized; readonly rules: PresetLevels };
|
|
9
|
+
|
|
10
|
+
/** One genre of genres.yaml: what it is for, the levels it sets on top of its group's, and the profile it reads with. */
|
|
11
|
+
export type GenreDefinition = {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly name: Localized;
|
|
14
|
+
readonly summary: Localized;
|
|
15
|
+
readonly rules: PresetLevels;
|
|
16
|
+
readonly profile: string | undefined;
|
|
17
|
+
readonly suggest: GenreSuggest;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A line shape that marks the genre, and how many lines must have it. */
|
|
21
|
+
type LineCue = { readonly line: RegExp; readonly minLines: number };
|
|
22
|
+
|
|
23
|
+
/** What makes a document look like this genre when no genre is set: its path, or enough lines of the genre's own shape, per language. */
|
|
24
|
+
export type GenreSuggest = { readonly paths: readonly RegExp[]; readonly lines: Readonly<Record<string, LineCue>> };
|
|
25
|
+
|
|
26
|
+
export type GenreData = { readonly groups: readonly GenreGroup[]; readonly genres: readonly GenreDefinition[] };
|
|
27
|
+
|
|
28
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
|
|
30
|
+
const isLocalized = (value: unknown): value is Localized =>
|
|
31
|
+
isRecord(value) && typeof value["ja"] === "string" && typeof value["en"] === "string" && value["ja"] !== "" && value["en"] !== "";
|
|
32
|
+
|
|
33
|
+
const levelsOf = (value: unknown, where: string): PresetLevels => {
|
|
34
|
+
if (value === undefined) return {};
|
|
35
|
+
if (!isRecord(value)) throw new Error(`${where}: rules must be a map of rule ids to levels`);
|
|
36
|
+
const wrong = Object.entries(value).find(([, level]) => !isLevel(level));
|
|
37
|
+
if (wrong !== undefined) throw new Error(`${where}: ${wrong[0]} has the level ${String(wrong[1])}; use strict, normal, relaxed or off`);
|
|
38
|
+
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, Level] => isLevel(entry[1])));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const localizedOf = (value: unknown, field: string, where: string): Localized => {
|
|
42
|
+
if (!isLocalized(value)) throw new Error(`${where}: ${field} needs a ja and an en text`);
|
|
43
|
+
return value;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const idOf = (value: unknown, index: number): string => {
|
|
47
|
+
const id = isRecord(value) ? value["id"] : undefined;
|
|
48
|
+
if (typeof id !== "string" || id === "") throw new Error(`entry ${String(index + 1)} has no id`);
|
|
49
|
+
return id;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const groupOf = (value: unknown, index: number): GenreGroup => {
|
|
53
|
+
const id = idOf(value, index);
|
|
54
|
+
const raw = isRecord(value) ? value : {};
|
|
55
|
+
return { id, name: localizedOf(raw["name"], "name", id), rules: levelsOf(raw["rules"], id) };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const patternOf = (value: unknown, flags: string, where: string): RegExp => {
|
|
59
|
+
if (typeof value !== "string" || value === "") throw new Error(`${where}: a pattern must be a non-empty string`);
|
|
60
|
+
try {
|
|
61
|
+
return new RegExp(value, flags);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new Error(`${where}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const lineCueOf = (value: unknown, where: string): LineCue => {
|
|
68
|
+
if (!isRecord(value)) throw new Error(`${where}: needs line and min_lines`);
|
|
69
|
+
const minLines = value["min_lines"];
|
|
70
|
+
if (typeof minLines !== "number" || !Number.isInteger(minLines) || minLines < 1) throw new Error(`${where}: min_lines must be a whole number of 1 or more`);
|
|
71
|
+
return { line: patternOf(value["line"], "u", where), minLines };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const suggestOf = (value: unknown, where: string): GenreSuggest => {
|
|
75
|
+
if (value === undefined) return { paths: [], lines: {} };
|
|
76
|
+
if (!isRecord(value)) throw new Error(`${where}: suggest must be a map`);
|
|
77
|
+
const paths = value["paths"] ?? [];
|
|
78
|
+
if (!Array.isArray(paths)) throw new Error(`${where}: suggest.paths must be a list`);
|
|
79
|
+
const lines = Object.entries(value).filter(([key]) => key !== "paths");
|
|
80
|
+
return {
|
|
81
|
+
paths: paths.map((path) => patternOf(path, "iu", `${where} suggest.paths`)),
|
|
82
|
+
lines: Object.fromEntries(lines.map(([language, cue]) => [language, lineCueOf(cue, `${where} suggest.${language}`)])),
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const genreOf = (value: unknown, index: number): GenreDefinition => {
|
|
87
|
+
const id = idOf(value, index);
|
|
88
|
+
const raw = isRecord(value) ? value : {};
|
|
89
|
+
const profile = raw["profile"];
|
|
90
|
+
if (profile !== undefined && (typeof profile !== "string" || profile === "")) throw new Error(`${id}: profile must be a profile's id`);
|
|
91
|
+
return {
|
|
92
|
+
id,
|
|
93
|
+
name: localizedOf(raw["name"], "name", id),
|
|
94
|
+
summary: localizedOf(raw["summary"], "summary", id),
|
|
95
|
+
rules: levelsOf(raw["rules"], id),
|
|
96
|
+
profile,
|
|
97
|
+
suggest: suggestOf(raw["suggest"], id),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const listOf = (raw: Record<string, unknown>, key: string): unknown[] => {
|
|
102
|
+
const value = raw[key];
|
|
103
|
+
if (!Array.isArray(value)) throw new Error(`genres.yaml needs a list under ${key}`);
|
|
104
|
+
return value;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** genres.yaml as read by the yaml package. Throws on anything it cannot read: the file ships with chaff, so a wrong entry is chaff's bug. */
|
|
108
|
+
export const parseGenres = (raw: unknown): GenreData => {
|
|
109
|
+
if (!isRecord(raw)) throw new Error("genres.yaml must be a map with groups and genres");
|
|
110
|
+
const groups = listOf(raw, "groups").map(groupOf);
|
|
111
|
+
const genres = listOf(raw, "genres").map(genreOf);
|
|
112
|
+
const orphan = genres.find((genre) => !groups.some((group) => genre.id.startsWith(`${group.id}/`)));
|
|
113
|
+
if (orphan !== undefined) throw new Error(`${orphan.id}: no group matches the part before its /`);
|
|
114
|
+
return { groups, genres };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** The levels a genre's preset sets: its group's, then its own on top. Empty for a genre chaff does not know. */
|
|
118
|
+
export const presetLevelsOf = (data: GenreData, genre: string): PresetLevels => {
|
|
119
|
+
const own = data.genres.find((entry) => entry.id === genre);
|
|
120
|
+
if (own === undefined) return {};
|
|
121
|
+
const group = data.groups.find((entry) => genre.startsWith(`${entry.id}/`));
|
|
122
|
+
return { ...group?.rules, ...own.rules };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** The profile a genre reads its documents with, when it names one. */
|
|
126
|
+
export const presetProfileOf = (data: GenreData, genre: string): string | undefined => data.genres.find((entry) => entry.id === genre)?.profile;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { GenreData, GenreDefinition } from "./genre-parse.ts";
|
|
2
|
+
import { detected, type ProfileDefinition } from "./profile/parse.ts";
|
|
3
|
+
|
|
4
|
+
/** The file a genre is suggested for: where it is, what it says, and its language. */
|
|
5
|
+
export type SuggestInput = { readonly path: string; readonly source: string; readonly language: string };
|
|
6
|
+
|
|
7
|
+
/** A path cue names the kind outright, so it outweighs any count of lines. */
|
|
8
|
+
const BY_PATH = Number.POSITIVE_INFINITY;
|
|
9
|
+
|
|
10
|
+
/** How many times over the genre's lines reach their minimum: 1 is just enough, 0 is none of that shape. */
|
|
11
|
+
const linesScore = (genre: GenreDefinition, input: SuggestInput): number => {
|
|
12
|
+
const cue = genre.suggest.lines[input.language];
|
|
13
|
+
if (cue === undefined) return 0;
|
|
14
|
+
return input.source.split("\n").filter((line) => cue.line.test(line)).length / cue.minLines;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A genre that reads with a profile has the profile's shape (a statute's article lines) as just-enough evidence. */
|
|
18
|
+
const profileScore = (genre: GenreDefinition, input: SuggestInput, profiles: readonly ProfileDefinition[]): number => {
|
|
19
|
+
const profile = profiles.find((definition) => definition.id === genre.profile);
|
|
20
|
+
return profile !== undefined && detected(profile, input.source, input.language) ? 1 : 0;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const scoreOf = (genre: GenreDefinition, input: SuggestInput, profiles: readonly ProfileDefinition[]): number =>
|
|
24
|
+
genre.suggest.paths.some((pattern) => pattern.test(input.path)) ? BY_PATH : Math.max(linesScore(genre, input), profileScore(genre, input, profiles));
|
|
25
|
+
|
|
26
|
+
type Scored = { readonly genre: GenreDefinition; readonly score: number };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The genre a file with no genre set looks like, for the screen to suggest. Never used to check the file: switching silently
|
|
30
|
+
* would change what an existing run reports. The genre whose cues the file meets most strongly wins (a transcript that
|
|
31
|
+
* discusses a bill has more speaker lines than lines about "this Act"); a tie goes to the genre listed first in genres.yaml.
|
|
32
|
+
*/
|
|
33
|
+
export const suggestGenre = (input: SuggestInput, data: GenreData, profiles: readonly ProfileDefinition[]): GenreDefinition | undefined =>
|
|
34
|
+
data.genres
|
|
35
|
+
.map((genre): Scored => ({ genre, score: scoreOf(genre, input, profiles) }))
|
|
36
|
+
.filter((entry) => entry.score >= 1)
|
|
37
|
+
.reduce<Scored | undefined>((best, entry) => (best === undefined || entry.score > best.score ? entry : best), undefined)?.genre;
|
package/src/genre.ts
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
"blog/tech",
|
|
6
|
-
"blog/essay",
|
|
7
|
-
"blog/owned-media",
|
|
8
|
-
"business/proposal",
|
|
9
|
-
"business/report",
|
|
10
|
-
"business/email",
|
|
11
|
-
"business/press-release",
|
|
12
|
-
"business/meeting-notes",
|
|
13
|
-
];
|
|
1
|
+
import { loadGenres } from "./genre-load.ts";
|
|
2
|
+
|
|
3
|
+
/** chaff が知っているジャンル。genres.yaml の並び。rule の use_for はこの頭の部分で当てる。 */
|
|
4
|
+
export const GENRES: readonly string[] = loadGenres().genres.map((genre) => genre.id);
|
|
14
5
|
|
|
15
6
|
export type GenreGuess = { readonly genre: string; readonly from: "front-matter" | "path" | "content" };
|
|
16
7
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { GenreData } from "./genre-parse.ts";
|
|
2
|
+
import type { Texts, UiLanguage } from "./ui.ts";
|
|
3
|
+
|
|
4
|
+
/** The genre init writes when nothing is chosen: the one a check uses when none is set. */
|
|
5
|
+
export const DEFAULT_GENRE = "blog/tech";
|
|
6
|
+
|
|
7
|
+
const TEXT: Texts<{ readonly heading: string; readonly prompt: string; readonly notAGenre: (answer: string) => string; readonly noAnswer: string }> = {
|
|
8
|
+
ja: {
|
|
9
|
+
heading: "どの種類の文書をここに置きますか? 選んだ種類の書き方に合わせて見ます。",
|
|
10
|
+
prompt: `番号か名前を入れてください(Enter で ${DEFAULT_GENRE}): `,
|
|
11
|
+
notAGenre: (answer) => `「${answer}」は一覧にありません。何も作っていません。npx chaffjs init --genre <ジャンル> でも選べます。`,
|
|
12
|
+
noAnswer: "答えが無いまま入力が終わったので、何も作っていません。",
|
|
13
|
+
},
|
|
14
|
+
en: {
|
|
15
|
+
heading: "What kind of document goes here? chaff checks it the way that kind is written.",
|
|
16
|
+
prompt: `Type a number or a name (Enter for ${DEFAULT_GENRE}): `,
|
|
17
|
+
notAGenre: (answer) => `"${answer}" is not in the list. Nothing was created. npx chaffjs init --genre <genre> chooses one too.`,
|
|
18
|
+
noAnswer: "The input ended without an answer, so nothing was created.",
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The numbered list init shows before it asks: every genre with what it is for, in genres.yaml's order. */
|
|
23
|
+
export const genreChoices = (data: GenreData, ui: UiLanguage): string[] => {
|
|
24
|
+
const width = Math.max(...data.genres.map((genre) => genre.id.length)) + 2;
|
|
25
|
+
const numberWidth = String(data.genres.length).length;
|
|
26
|
+
return [
|
|
27
|
+
"",
|
|
28
|
+
TEXT[ui].heading,
|
|
29
|
+
"",
|
|
30
|
+
...data.genres.map((genre, index) => ` ${String(index + 1).padStart(numberWidth)} ${genre.id.padEnd(width)}${genre.summary[ui] ?? ""}`),
|
|
31
|
+
"",
|
|
32
|
+
];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const choicePrompt = (ui: UiLanguage): string => TEXT[ui].prompt;
|
|
36
|
+
|
|
37
|
+
export const notAGenre = (answer: string, ui: UiLanguage): string => TEXT[ui].notAGenre(answer);
|
|
38
|
+
|
|
39
|
+
export const noAnswer = (ui: UiLanguage): string => TEXT[ui].noAnswer;
|
|
40
|
+
|
|
41
|
+
/** A number from the list, a genre's name, or nothing (the default). undefined for anything else: init then writes nothing. */
|
|
42
|
+
export const genreFromAnswer = (answer: string, ids: readonly string[]): string | undefined => {
|
|
43
|
+
const typed = answer.trim();
|
|
44
|
+
if (typed === "") return DEFAULT_GENRE;
|
|
45
|
+
if (ids.includes(typed)) return typed;
|
|
46
|
+
return /^\d+$/u.test(typed) ? ids[Number(typed) - 1] : undefined;
|
|
47
|
+
};
|