zhtw-mcp conventions
The gate settles formatting and correctness. cargo fmt, clippy, black,
shellcheck and scripts/indent.sh run in make check, so none of that is
here. What is here is what a reviewer would otherwise have to say out loud,
plus the rules enforced by the git hooks rather than by CI: the commit message
and the staged-content checks. Any cargo build installs them, through
build.rs; make hooks installs them on their own.
README.md and CLAUDE.md are the tracked and local halves respectively, and
this file does not restate either. Where two of them speak to the same thing,
README.md wins.
Commit messages
The house style is Chris Beams' seven rules, and scripts/git-commit-msg.sh
enforces the mechanical ones: subject within 50 columns, capitalized,
imperative, no trailing period, no backticks, no conventional-commit type
prefix, body wrapped at 72 columns, no em dash character, no tabs, and no
control or bidirectional-override characters. An area prefix such as
Windows: retry the replace is not a conventional-commit prefix and is
allowed. Run
git log --no-merges --format=%s if you want the calibration set rather than
the claim; the log sits at 43 columns in the median.
Two of those rules are this project's rather than Beams'. Widths are counted in
terminal columns, so a CJK character costs two, which is what
git log --oneline has to fit. And the subject line is English prose that may
quote a Chinese term, which is what every subject in this log already is:
Narrow 聯繫 cross_strait flagging to contact-copy
Add word "test" to negative_context_clues for pass rule
Take the binary under test from cargo
The rule the hook cannot check is what the body says. This tree keeps its detailed reasoning in the comment next to the code, often several paragraphs per decision. A body that retells the mechanics duplicates that comment and then goes stale on its own. Write the premise and the trade, once, usually a single paragraph. Backticks are fine there and not in the subject.
Prose register
Source comments use plain prose: no em dash, and no backticks around an
identifier. Name it plainly, and use quotes only where a bare token would read
as part of the sentence. scripts/check-comments.sh enforces both, in
make check and in the pre-commit hook, so this is a rule you hear about at
commit time rather than in review.
Two exemptions, both because the text is markup rather than prose. Markdown
files under docs/ and the README use ordinary GitHub Markdown. Rust doc
comments, /// and //!, are rustdoc markdown: a backticked span renders as
code and an intra-doc link resolves inside one, so backticks stay there. The
em dash rule has no exemption; it is about prose in any markup.
The doubled em dash is the zh-TW 破折號 and is data in this tree, not punctuation. The checker leaves it alone, along with one inside quotes.
Chinese in a comment is not only allowed, it is often the only honest way to
say what a rule is about: // 聯繫 is contact-copy in zh-TW and a verb in zh-CN. Use it where the term is the subject. Do not use it for prose that has
an English form, and keep user-facing strings, identifiers and file names
English.
Comments
Brevity is part of correctness, but the bar is rationale rather than length: this tree carries long comments where the reasoning is long, and the rule is that every line says something the code cannot. Delete anything restating the statement below it.
- Bad:
let end = start + len; // add the length - Good:
let end = start + len; // byte offsets, mapped back through NFC above, so this indexes the original text and not the normalized copy
Comment width is settled by commentflow through scripts/indent.sh, so do
not hand-wrap to a column and do not fight the result; run make indent.
Never point a comment, a doc comment or a docs/ page at TODO.md. It is
untracked and per-developer, so the reference dangles for everyone else.
Working docs at the repo root
TODO.md, DONE.md and CLAUDE.md are excluded through .git/info/exclude,
not through .gitignore, because the exclusion is one person's habit and
.gitignore would push it onto everybody. Never git add, stage, commit or
delete them, and never assume a clone has them. Reports and analyses stay out
of the tree the same way: a scratch directory, not a new tracked file. A
finished TODO item moves to DONE.md rather than being deleted.
Deleting a surface
A flag, subcommand or helper that turns out to be redundant gets deleted, along
with its call sites in the Makefile, the scripts, the README, docs/ and the
tests. It does not become an alias and it does not get a deprecation warning.
"Never break userspace" here means the observable contract: the MCP tool schema
in docs/mcp.md, the CLI exit codes, the SARIF and JSON output shapes, and the
golden fixtures under tests/. The convenience surface is not that contract.
Pull requests and review replies
The commit body carries what and why. A review thread carries a correction, a measurement, or nothing: no pasted agent walkthroughs, no severity tables, no re-summarizing a diff git already shows.
Layout
src/engine/ Scanner passes, normalization, s2t conversion, scoring
src/rules/ Rule store, ruleset schema, overrides, judgment cache
src/cli/ Argument parsing, file discovery, output rendering
src/mcp/ MCP server, stdio transport, tool types, sampling
assets/ ruleset.json, the source of truth for vocabulary rules
scripts/ Table generator, ruleset checker, gate and hook scripts
extension/ Browser extension over the browser-wasm build of the library
tests/ Integration suites, corpora and fixtures
tests/unit/ Unit test bodies, mirroring the module path they belong to
docs/ Internals, rule schema, CLI and MCP references
src/engine/s2t_data.rs is generated and gitignored. See zhtw-verify for what
regenerates it, and zhtw-rules for how a vocabulary rule is added.
Test code does not live in src
No test bodies under src/. A module that has unit tests declares them and
points at the file:
#[cfg(test)]
#[path = "../../tests/unit/engine/excluded/tests.rs"]
mod tests;
The file lives at tests/unit/ plus the module path plus the module name, so
src/engine/excluded.rs puts its tests module in
tests/unit/engine/excluded/tests.rs and src/engine/scan/mod.rs puts its in
tests/unit/engine/scan/tests.rs. The relative path in the attribute is counted
from the directory holding the source file, not from the repository root.
They stay unit tests, which is the point: they keep private access, they compile
into the same test binary, and no internal item has to become pub to be
reachable. Making an internal pub so a test in tests/*.rs can see it is the
thing this arrangement exists to avoid.
What legitimately remains in src/ is not test bodies: a #[cfg(test)] helper
on a production type, such as Trie::get_freq in src/engine/segment.rs or
Scanner::force_bytewise in src/engine/scan/mod.rs, is an affordance the type
offers its tests and belongs with the type. A test-only free function is test
code and goes to tests/unit/, which is where the nine legacy grammar scanners
sit as tests/unit/engine/scan/grammar/legacy.rs.