Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,34 @@ command
- `disableQuote`: `true`にするとコロン後の引用検出を無効にする
- `disableTable`: `true`にするとコロン後のテーブル検出を無効にする

### no-ai-heading-numbers

AIが機械的に生成しがちな、見出しに連番を含めるパターンを検出します。

#### 検出される例

```markdown
# 1. タイトル
## 2. セクション
### 3.1. サブセクション
#### 4) 項目
```

#### より自然な表現

```markdown
# タイトル
## セクション
### サブセクション
#### 項目
```

#### オプション

- `allows`: 指定したパターンにマッチする場合、エラーを報告しません
- 文字列: `"許可したいテキスト"`
- 正規表現: `"/パターン/フラグ"` (例: `"/\\d+\\. .*/i"`)

### ai-tech-writing-guideline

テクニカルライティングのベストプラクティスに基づいて、文書品質の改善提案を行います。
Expand Down Expand Up @@ -335,6 +363,9 @@ command
"disableQuote": false,
"disableTable": false
},
"no-ai-heading-numbers": {
"allows": ["許可したいテキスト", "/正規表現パターン/"]
},
"ai-tech-writing-guideline": {
"severity": "info", // サジェストとして扱う
"allows": ["許可したいテキスト", "/正規表現パターン/"],
Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import aiTechWritingGuideline from "./rules/ai-tech-writing-guideline";
import noAiColonContinuation from "./rules/no-ai-colon-continuation";
import noAiEmphasisPatterns from "./rules/no-ai-emphasis-patterns";
import noAiHeadingNumbers from "./rules/no-ai-heading-numbers";
import noAiHypeExpressions from "./rules/no-ai-hype-expressions";
import noAiListFormatting from "./rules/no-ai-list-formatting";

Expand All @@ -10,7 +11,8 @@ const preset = {
"no-ai-hype-expressions": noAiHypeExpressions,
"no-ai-emphasis-patterns": noAiEmphasisPatterns,
"ai-tech-writing-guideline": aiTechWritingGuideline,
"no-ai-colon-continuation": noAiColonContinuation
"no-ai-colon-continuation": noAiColonContinuation,
"no-ai-heading-numbers": noAiHeadingNumbers
},
rulesConfig: {
"no-ai-list-formatting": true,
Expand All @@ -19,7 +21,8 @@ const preset = {
"ai-tech-writing-guideline": {
severity: "info"
},
"no-ai-colon-continuation": true
"no-ai-colon-continuation": true,
"no-ai-heading-numbers": true
}
};

Expand Down
56 changes: 56 additions & 0 deletions src/rules/no-ai-heading-numbers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { matchPatterns } from "@textlint/regexp-string-matcher";
import type { TextlintRuleContext, TextlintRuleModule } from "@textlint/types";

type Options = {
// If node's text includes allowed patterns, does not report.
// Can be string or RegExp-like string ("/pattern/flags")
allows?: string[];
};

const rule: TextlintRuleModule<Options> = (context: TextlintRuleContext, options = {}) => {
const { Syntax, RuleError, report, getSource, locator } = context;
const allows = options.allows ?? [];

// Pattern to detect numbering at the beginning of headings
// Matches patterns like: "1. ", "2. ", "3.1. ", "1) ", etc.
const numberingPattern = /^\s*(\d+(?:\.\d+)*[.)]\s+)/;

return {
[Syntax.Header](node) {
// Use raw text which includes the heading marker (#)
const text = node.raw || getSource(node);
Copy link
Member

@azu azu Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header nodeの時点でHeaderなのでrawを見る必要はないと思います。
具体的な問題として # を使わない書き方だと動かなくなる( ==== やMarkdown以外など)ので、getSourceだけで良いような気がします。
AIは ==== の方はあんまり使わない感じはするので、意図的に除外する場合は意図的な動作を実装したいですね


// Check if text matches any allowed patterns
if (allows.length > 0) {
const matches = matchPatterns(text, allows);
if (matches.length > 0) {
return;
}
}

// For raw text, we need to skip the heading marker first
// Extract just the heading content (after # and space)
const headingContentMatch = text.match(/^#{1,6}\s+(.+)$/);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ここはtextをtrim or \s{0,10}\d のようなスペースを許容して、先頭が数字から始まるか? な気がしています。

const headingContent = headingContentMatch ? headingContentMatch[1] : text;

const match = headingContent.match(numberingPattern);
if (match) {
const numberPart = match[1];
const matchStart = match.index ?? 0;
const matchEnd = matchStart + numberPart.length;

report(
node,
new RuleError(
"見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

具体的には、見出しのレベルを見直することで、見出しを番号として含める必要がないので、見出しのレベルを1や2のようなテキストで表現しないでください ということが伝わると何をするといいかわかるかもしれないです。

HTML的にはこの辺だと思いますが、ここまで詳細にはいらないですが、

>  Heading information can be used by user agents to construct a table of contents for a document automatically.
>    Do not use heading elements to resize text. Instead, use the CSS font-size property.
>    Do not skip heading levels: always start from <h1>, followed by <h2> and so on.
> https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/Heading_Elements

まだちょっと手つけられてないですが、エラーメッセージの明確性/正確性/修正可能性/文脈適合性を評価して、人間もAIもエラーを直しやすくできるような基準を作ろうとしています。
textlint-ja/textlint-rule-no-doubled-joshi#73

この場合、どうやって修正したらいいかがちょっと伝わりにくいかなと思いました

{
padding: locator.range([matchStart, matchEnd])
}
)
);
}
}
};
};

export default rule;
115 changes: 115 additions & 0 deletions test/rules/no-ai-heading-numbers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import TextLintTester from "textlint-tester";
import noAiHeadingNumbers from "../../src/rules/no-ai-heading-numbers";

const tester = new TextLintTester();

tester.run("no-ai-heading-numbers", noAiHeadingNumbers, {
valid: [
// Normal headings without numbering
"# タイトル",
"## セクション",
"### サブセクション",
"#### 詳細項目",
"##### 更に詳細な項目",
"###### 最小レベルの見出し",
// Headings with text that happens to start with numbers (but no dot/paren)
"# 2025年の振り返り",
"## 3つのポイント",
// Allowed patterns (string)
{
text: "# 1. イントロダクション",
options: {
allows: ["1. イントロダクション"]
}
},
// Allowed patterns (RegExp-like string)
{
text: "## 2. 背景",
options: {
allows: ["/\\d+\\. .*/"]
}
},
// Allowed patterns (case insensitive)
{
text: "### 3.1. サブセクション",
options: {
allows: ["/\\d+\\.\\d+\\. .*/i"]
}
}
],
invalid: [
// Simple numbering patterns
{
text: "# 1. タイトル",
errors: [
{
message:
"見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。",
range: [0, 3]
}
]
},
{
text: "## 2. セクション",
errors: [
{
message:
"見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。",
range: [0, 3]
}
]
},
{
text: "### 10. 10番目のアイテム",
errors: [
{
message:
"見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。",
range: [0, 4]
}
]
},
// Hierarchical numbering (1.1, 1.2, etc.)
{
text: "### 3.1. サブセクション",
errors: [
{
message:
"見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。",
range: [0, 5]
}
]
},
{
text: "#### 1.2.3. 深い階層",
errors: [
{
message:
"見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。",
range: [0, 7]
}
]
},
// Parenthesis style numbering
{
text: "# 1) はじめに",
errors: [
{
message:
"見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。",
range: [0, 3]
}
]
},
{
text: "## 2) 背景",
errors: [
{
message:
"見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。",
range: [0, 3]
}
]
}
]
});
Loading