kotonoha 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.9"
4
+ # dependencies = []
5
+ # ///
6
+ """tech-writer skill: a lint script that mechanically checks a technical
7
+ document's *structure*.
8
+
9
+ Where natural-japanese's lint.py detects sentence-level naturalness
10
+ (vocabulary, rhythm), this script only detects structural problems specific
11
+ to technical documents (heading hierarchy, code examples, leftover
12
+ placeholders, suspicious links). The two scripts intentionally don't
13
+ overlap in scope.
14
+
15
+ Findings are flags, not mandates: exit code is always 0 regardless of the
16
+ finding count (it's a lint, so it shouldn't block CI). Exit code 1 is
17
+ reserved for the input file being missing or unreadable.
18
+
19
+ Pass --atomic when linting a commit message, PR description, issue report,
20
+ code comment/docstring, or a single release-notes entry: these atomic
21
+ artifacts follow their own doctype skeleton (see style-constitution.md's
22
+ scope note) and legitimately have no H1 title, so --atomic skips the
23
+ living-document intro-paragraph check that would otherwise misfire on
24
+ them (e.g. assets/templates/pr-description.md lints clean with --atomic).
25
+
26
+ Usage:
27
+ uv run scripts/lint.py <file>
28
+ uv run scripts/lint.py --json <file>
29
+ uv run scripts/lint.py --atomic <file>
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import re
36
+ import sys
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+
40
+
41
+ @dataclass
42
+ class Finding:
43
+ line: int
44
+ category: str
45
+ message: str
46
+ snippet: str = ""
47
+
48
+
49
+ @dataclass
50
+ class LintResult:
51
+ file: str
52
+ findings: list = field(default_factory=list)
53
+
54
+ def to_dict(self) -> dict:
55
+ return {
56
+ "file": self.file,
57
+ "finding_count": len(self.findings),
58
+ "findings": [f.__dict__ for f in self.findings],
59
+ }
60
+
61
+
62
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
63
+ # Generic heading labels that read as content-free in either language.
64
+ GENERIC_HEADINGS = {
65
+ "overview", "introduction", "usage", "notes", "misc", "others",
66
+ "概要", "はじめに", "使い方", "使用方法", "注意点", "注意事項", "その他", "補足",
67
+ }
68
+ # A fence marker is 3+ backticks or 3+ tildes, optionally followed by an
69
+ # info string (e.g. the language). CommonMark requires the closing fence to
70
+ # use the same character and be at least as long as the opener, with no
71
+ # info string of its own.
72
+ FENCE_RE = re.compile(r"^(`{3,}|~{3,})(.*)$")
73
+ PLACEHOLDER_RE = re.compile(r"\b(TODO|FIXME|TBD|XXX)\b", re.IGNORECASE)
74
+ MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
75
+ INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
76
+ NON_PARAGRAPH_RE = re.compile(
77
+ r"^(?:[-*+]\s|\d+[.)]\s|>|<!--|\|)|^(?:-{3,}|\*{3,}|_{3,})$"
78
+ )
79
+ # CommonMark indented code block: 4+ leading spaces or a leading tab.
80
+ INDENTED_CODE_RE = re.compile(r"^(?: {4,}|\t)\S")
81
+ # A YAML frontmatter field named "title" with a non-empty value, e.g. a
82
+ # Zenn/Qiita article's `title: "..."` (the platform renders this as the
83
+ # page title, so the body conventionally has no in-body '#' heading).
84
+ TITLE_FIELD_RE = re.compile(r'^title:\s*(["\']?)\S')
85
+
86
+
87
+ def strip_inline_code(line: str) -> str:
88
+ """Blank out inline `code span` contents so placeholder/link checks
89
+ don't fire on tokens that are only being *mentioned* as code, not left
90
+ unresolved in prose (e.g. a doctype guide showing `TODO(#123): ...` as
91
+ an example of the correct form)."""
92
+ return INLINE_CODE_RE.sub(lambda m: " " * len(m.group(0)), line)
93
+
94
+
95
+ def parse_fences(lines: list) -> tuple:
96
+ """Scan for fenced code blocks.
97
+
98
+ Returns (findings, fence_mask) where fence_mask[i] is True when line i
99
+ (0-based) is part of a fenced code block (opening/closing marker or
100
+ content in between). Other checks should skip masked lines so that
101
+ headings, TODOs, or links written *inside* example code blocks aren't
102
+ mistaken for real document structure.
103
+ """
104
+ findings = []
105
+ fence_mask = [False] * len(lines)
106
+ open_char = None
107
+ open_len = 0
108
+ open_line = None
109
+
110
+ def fence_match(raw_line):
111
+ # CommonMark/GFM only recognizes a fence indented by at most three
112
+ # spaces; four or more spaces (or a leading tab, which expands to
113
+ # 4+ columns) is indented code, not a fence.
114
+ expanded = raw_line.expandtabs(4)
115
+ indent = len(expanded) - len(expanded.lstrip(" "))
116
+ if indent > 3:
117
+ return None
118
+ return FENCE_RE.match(raw_line.strip())
119
+
120
+ for i, line in enumerate(lines):
121
+ m = fence_match(line)
122
+ if open_char is None:
123
+ if m:
124
+ marker, info = m.group(1), m.group(2).strip()
125
+ open_char, open_len, open_line = marker[0], len(marker), i + 1
126
+ fence_mask[i] = True
127
+ if not info:
128
+ findings.append(
129
+ Finding(
130
+ line=i + 1,
131
+ category="code_fence_no_lang",
132
+ message="Code block has no language tag (recommended for syntax highlighting and copy detection).",
133
+ snippet=line.strip(),
134
+ )
135
+ )
136
+ continue
137
+
138
+ fence_mask[i] = True
139
+ if m:
140
+ marker, info = m.group(1), m.group(2).strip()
141
+ is_closing = marker[0] == open_char and len(marker) >= open_len and not info
142
+ if is_closing:
143
+ open_char, open_len, open_line = None, 0, None
144
+
145
+ if open_char is not None:
146
+ findings.append(
147
+ Finding(
148
+ line=open_line or 0,
149
+ category="unclosed_code_fence",
150
+ message="A code block may not be closed.",
151
+ )
152
+ )
153
+ return findings, fence_mask
154
+
155
+
156
+ def check_heading_hierarchy(lines: list, fence_mask: list) -> list:
157
+ findings = []
158
+ prev_level = 0
159
+ for i, line in enumerate(lines):
160
+ if fence_mask[i]:
161
+ continue
162
+ m = HEADING_RE.match(line)
163
+ if not m:
164
+ continue
165
+ level = len(m.group(1))
166
+ text = m.group(2).strip()
167
+ if prev_level and level > prev_level + 1:
168
+ findings.append(
169
+ Finding(
170
+ line=i + 1,
171
+ category="heading_skip",
172
+ message=f"Heading level jumps from H{prev_level} to H{level}.",
173
+ snippet=line.strip(),
174
+ )
175
+ )
176
+ stripped = text.rstrip("::").strip().lower()
177
+ if stripped in GENERIC_HEADINGS:
178
+ findings.append(
179
+ Finding(
180
+ line=i + 1,
181
+ category="generic_heading",
182
+ message="Generic heading label; make it preview the content instead (structure constitution rule 2).",
183
+ snippet=line.strip(),
184
+ )
185
+ )
186
+ prev_level = level
187
+ return findings
188
+
189
+
190
+ def check_placeholders(lines: list, fence_mask: list) -> list:
191
+ findings = []
192
+ for i, line in enumerate(lines):
193
+ if fence_mask[i]:
194
+ continue
195
+ checked = strip_inline_code(line)
196
+ for m in PLACEHOLDER_RE.finditer(checked):
197
+ # A justified/tracked marker like "TODO(#123): ..." documents a
198
+ # reason and a tracking reference, which is exactly what this
199
+ # skill's own guidance asks for — don't flag that form. An empty
200
+ # "TODO()" carries no reason at all, so it still gets flagged.
201
+ if checked[m.end():m.end() + 1] == "(":
202
+ close_idx = checked.find(")", m.end())
203
+ content = checked[m.end() + 1:close_idx] if close_idx != -1 else ""
204
+ if content.strip():
205
+ continue
206
+ findings.append(
207
+ Finding(
208
+ line=i + 1,
209
+ category="placeholder",
210
+ message=f"Unresolved placeholder '{m.group(1)}' remains without a reason/tracking reference; resolve before publishing.",
211
+ snippet=line.strip(),
212
+ )
213
+ )
214
+ return findings
215
+
216
+
217
+ def check_links(lines: list, fence_mask: list) -> list:
218
+ findings = []
219
+ for i, line in enumerate(lines):
220
+ if fence_mask[i]:
221
+ continue
222
+ checked = strip_inline_code(line)
223
+ for m in MD_LINK_RE.finditer(checked):
224
+ text, target = m.group(1), m.group(2)
225
+ if not text.strip():
226
+ findings.append(
227
+ Finding(
228
+ line=i + 1,
229
+ category="empty_link_text",
230
+ message="Link text is empty. Avoid content-free link text like 'here'/'こちら' too.",
231
+ snippet=line.strip(),
232
+ )
233
+ )
234
+ if target.strip() in ("#", "", "javascript:void(0)"):
235
+ findings.append(
236
+ Finding(
237
+ line=i + 1,
238
+ category="dead_link_placeholder",
239
+ message="Link target is still an unset placeholder.",
240
+ snippet=line.strip(),
241
+ )
242
+ )
243
+ return findings
244
+
245
+
246
+ def check_intro_paragraph(lines: list, fence_mask: list) -> list:
247
+ """Check that the document opens with a non-empty H1 title immediately
248
+ followed by a genuine body paragraph.
249
+
250
+ This is a heuristic proxy for structure constitution rule 1 ("say what
251
+ this is and the outcome up front"). The very first non-blank line after
252
+ the title must be plain prose — a list item, blockquote, HTML comment,
253
+ table row, thematic break, another heading, a fenced code block, or
254
+ indented code does not count, even if real prose follows it further
255
+ down. A leading YAML frontmatter block (e.g. skill metadata, or a
256
+ platform frontmatter with its own `title:` field such as Zenn/Qiita) is
257
+ skipped before this check begins; fenced code is only skipped while
258
+ still searching for the title itself (a heading can't appear inside
259
+ one).
260
+
261
+ A frontmatter block that already carries a non-empty `title:` field
262
+ counts as satisfying the title requirement on its own — those platforms
263
+ render that field as the page/article title and conventionally don't
264
+ repeat it as an in-body '#' heading.
265
+ """
266
+ start = 0
267
+ frontmatter_has_title = False
268
+ if lines and lines[0].strip() == "---":
269
+ for j in range(1, len(lines)):
270
+ if lines[j].strip() == "---":
271
+ start = j + 1
272
+ break
273
+ if TITLE_FIELD_RE.match(lines[j]):
274
+ frontmatter_has_title = True
275
+ state = "after_title" if frontmatter_has_title else "before_title"
276
+ for i, line in enumerate(lines):
277
+ if i < start:
278
+ continue
279
+ if state == "before_title" and fence_mask[i]:
280
+ continue
281
+ stripped = line.strip()
282
+ if not stripped:
283
+ continue
284
+ if state == "after_title" and (fence_mask[i] or INDENTED_CODE_RE.match(line)):
285
+ # Fenced or indented code right after the title isn't prose.
286
+ break
287
+ m = HEADING_RE.match(stripped)
288
+ if state == "before_title":
289
+ if m and len(m.group(1)) == 1 and m.group(2).strip():
290
+ state = "after_title"
291
+ continue
292
+ # Either the first heading isn't a non-empty top-level title,
293
+ # or non-heading content appeared before any title — either way
294
+ # there's nothing valid to anchor the check against.
295
+ break
296
+ # state == "after_title": the very next non-blank line decides it.
297
+ if m or NON_PARAGRAPH_RE.match(stripped):
298
+ break
299
+ return []
300
+ return [
301
+ Finding(
302
+ line=1,
303
+ category="missing_intro",
304
+ message="Document must open with a non-empty '#' title heading immediately followed by a plain-prose paragraph (not a list, blockquote, comment, table, code block, or another heading) stating what this is and the reader outcome (structure constitution rule 1).",
305
+ )
306
+ ]
307
+
308
+
309
+ def run_lint(path: Path, atomic: bool = False) -> LintResult:
310
+ text = path.read_text(encoding="utf-8")
311
+ lines = text.splitlines()
312
+ result = LintResult(file=str(path))
313
+ fence_findings, fence_mask = parse_fences(lines)
314
+ result.findings.extend(fence_findings)
315
+ result.findings.extend(check_heading_hierarchy(lines, fence_mask))
316
+ result.findings.extend(check_placeholders(lines, fence_mask))
317
+ result.findings.extend(check_links(lines, fence_mask))
318
+ if not atomic:
319
+ # check_intro_paragraph assumes a living, multi-section document
320
+ # (H1 title + opening paragraph); atomic artifacts like a PR
321
+ # description or a single release-notes entry follow their own
322
+ # doctype skeleton instead (see style-constitution.md's scope
323
+ # note) and legitimately have no H1 at all.
324
+ result.findings.extend(check_intro_paragraph(lines, fence_mask))
325
+ result.findings.sort(key=lambda f: f.line)
326
+ return result
327
+
328
+
329
+ def main() -> int:
330
+ parser = argparse.ArgumentParser(description="tech-writer structural lint")
331
+ parser.add_argument("file", type=str, help="Target Markdown file")
332
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
333
+ parser.add_argument(
334
+ "--atomic",
335
+ action="store_true",
336
+ help=(
337
+ "Lint as an atomic artifact (commit message, PR description, "
338
+ "issue report, code comment/docstring, a single release-notes "
339
+ "entry): skips the living-document intro-paragraph check, "
340
+ "which doesn't apply to these doctypes' own skeletons."
341
+ ),
342
+ )
343
+ args = parser.parse_args()
344
+
345
+ path = Path(args.file)
346
+ if not path.is_file():
347
+ print(f"error: file not found: {path}", file=sys.stderr)
348
+ return 1
349
+
350
+ try:
351
+ result = run_lint(path, atomic=args.atomic)
352
+ except OSError as e:
353
+ print(f"error: failed to read file: {e}", file=sys.stderr)
354
+ return 1
355
+
356
+ if args.json:
357
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
358
+ else:
359
+ if not result.findings:
360
+ print(f"{path}: no structural findings.")
361
+ else:
362
+ print(f"{path}: {len(result.findings)} finding(s)")
363
+ for f in result.findings:
364
+ print(f" L{f.line} [{f.category}] {f.message}")
365
+ if f.snippet:
366
+ print(f" > {f.snippet}")
367
+ return 0
368
+
369
+
370
+ if __name__ == "__main__":
371
+ sys.exit(main())