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.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/package.json +26 -0
- package/skills/tech-writer/SKILL.md +249 -0
- package/skills/tech-writer/assets/templates/design-doc.md +29 -0
- package/skills/tech-writer/assets/templates/pr-description.md +23 -0
- package/skills/tech-writer/assets/templates/readme.md +38 -0
- package/skills/tech-writer/assets/templates/user-manual.md +22 -0
- package/skills/tech-writer/references/doctypes/api-docs.md +33 -0
- package/skills/tech-writer/references/doctypes/code-comments.md +39 -0
- package/skills/tech-writer/references/doctypes/design-doc.md +42 -0
- package/skills/tech-writer/references/doctypes/pr-commit.md +82 -0
- package/skills/tech-writer/references/doctypes/qiita.md +68 -0
- package/skills/tech-writer/references/doctypes/readme.md +43 -0
- package/skills/tech-writer/references/doctypes/release-notes.md +30 -0
- package/skills/tech-writer/references/doctypes/user-manual.md +58 -0
- package/skills/tech-writer/references/doctypes/zenn.md +66 -0
- package/skills/tech-writer/references/style-constitution.md +89 -0
- package/skills/tech-writer/scripts/lint.py +371 -0
|
@@ -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())
|