diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 310bf258..6f935e47 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -26,3 +26,12 @@ if git stripspace --strip-comments <"${msg_file}" | grep -qiE 'bump version'; th echo " (Not blocking this commit; the release build enforces it at tag time.)" >&2 fi fi + +# The writing standard covers a commit message like any other artifact, and the pre-commit hook +# beside this one reads only the staged files. Reports and never blocks, like the check above. +prose_check="${repo_root}/src/usr/share/ai-tools/skills/ai-tools-technical-docs/prose-check.py" +if command -v python3 >/dev/null 2>&1 && [[ -r "${prose_check}" ]]; then + if ! python3 "${prose_check}" --message "${msg_file}" >&2; then + echo " (not blocking this commit)" >&2 + fi +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..38726139 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# pre-commit hook: report the prose figures the ai-tools-technical-docs skill rules out, in the +# lines this commit ADDS. Reuses the checker the skill ships (prose-check.py beside its +# SKILL.md), so the hook and a full pass apply one set of patterns. +# +# Never blocks, matching the commit-msg reminder beside it: the default checks report correct +# prose occasionally, and a hook that refuses a commit over a style call is a hook developers +# turn off. It reports, the author decides. +# +# Only the added lines are read, so the ~650 findings already in the tree stay out of every +# commit until a sweep addresses them. +# +# Not installed automatically -- enable once per clone with `make -C packaging hooks` +# (sets core.hooksPath to .githooks). +set -euo pipefail + +command -v python3 >/dev/null 2>&1 || exit 0 +repo_root="$(git rev-parse --show-toplevel)" +checker="${repo_root}/src/usr/share/ai-tools/skills/ai-tools-technical-docs/prose-check.py" +[[ -r "${checker}" ]] || exit 0 + +if ! python3 "${checker}" --staged; then + echo " (not blocking this commit)" >&2 +fi +exit 0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ccdd683..7e6a6048 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,10 +28,12 @@ See the root `README.md`'s manual install steps if you're working without the RP Optional, recommended for regular contributors: - make -C packaging hooks # enable the local git hooks (a non-blocking changelog reminder) + make -C packaging hooks # enable the local git hooks (non-blocking reminders) A per-clone developer opt-in: it sets `core.hooksPath` to `.githooks` and quiets git's -ignored-hook advice for sandbox-account commits. None of this ships in the RPM — the +ignored-hook advice for sandbox-account commits. Two hooks come with it, and neither blocks a +commit: a `commit-msg` changelog reminder, and a `pre-commit` prose report over the lines the +commit adds (`prose-check.py`, shipped with the `ai-tools-technical-docs` skill). None of this ships in the RPM — the package builds only from `src/`, `docs/`, the spec, and the compiled policy. ## Running the tests diff --git a/packaging/Makefile b/packaging/Makefile index 4923954d..2d0dd495 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -88,8 +88,10 @@ check-version: changelog: @bash $(CURDIR)/changelog-draft.sh -# Opt in (once per clone) to the local git hooks in .githooks -- currently a non-blocking -# commit-msg reminder to complete the %changelog on a "bump version" commit. Also silence +# Opt in (once per clone) to the local git hooks in .githooks -- a non-blocking commit-msg +# reminder to complete the %changelog on a "bump version" commit, and a non-blocking pre-commit +# report of the prose figures the ai-tools-technical-docs skill rules out in the lines a commit +# adds. Also silence # git's ignored-hook advice: the sandbox account commits under the SELinux ai_tools_t domain, # which has no execute on the project-tree ai_tools_project_t label, so git skips the hook and # nags about it on every agent commit. That skip is the confinement working (the operator, who diff --git a/src/usr/share/ai-tools/skills/ai-tools-technical-docs/SKILL.md b/src/usr/share/ai-tools/skills/ai-tools-technical-docs/SKILL.md index 548c45c0..dccf6b7c 100644 --- a/src/usr/share/ai-tools/skills/ai-tools-technical-docs/SKILL.md +++ b/src/usr/share/ai-tools/skills/ai-tools-technical-docs/SKILL.md @@ -3,8 +3,8 @@ name: ai-tools-technical-docs # ai-tools managed asset — provenance/versioning (RFC-draft lifecycle); the name above is stable. x-ai-tools-managed: true x-ai-tools-status: draft -x-ai-tools-version: 1 -x-ai-tools-updated: 2026-08-24 +x-ai-tools-version: 3 +x-ai-tools-updated: 2026-09-04 description: > Technical writing standard for every software engineering artifact. Use when writing or editing README and usage guides, CLAUDE.md / AGENTS.md, *.rule.md, file and module headers, @@ -63,16 +63,16 @@ to the mechanism. The grammatical subject is a component, command, function, file, or person — something with an implementation a reader can open. Abstractions describe; they do not act. -- Off style: `A claim that can grant nothing leaves nothing registered.` -- In style: `--project-claim writes no allowlist entry when an existing entry already covers the path.` +- In style: `register() does not write an entry when an existing one already covers the path.` +- Off style: `A registration that can add nothing leaves nothing recorded.` ### State the mechanism, not the definition -A sentence shaped *"an X that ⟨property⟩ is not an X"* restates a definition and gives the -reader nothing to verify. Write what the code does and what follows from it. +A sentence shaped *"an X that ⟨property⟩ is not an X"* restates a definition, which a reader +cannot check against the code. Write what the code does and what follows from it. +- In style: `stop() does not take a target and enumerates every process in the account's cgroup, so a task cannot exclude itself from the sweep.` - Off style: `A stop path the monitored system can put itself outside of is not a stop path.` -- In style: `--stop takes no target and enumerates every cgroup in the account's slice, so a session cannot select itself out of the sweep.` A document may carry **one** such formulation as its stated binding rule, where the compression earns its place. Everywhere else, describe the mechanism. @@ -82,20 +82,20 @@ earns its place. Everywhere else, describe the mechanism. "Never", "always", and "cannot" are claims about the implementation. Name the guard that makes each one true, in the same sentence. -- Off style: `The sandbox account is never an operator.` -- In style: `ai-tools-run refuses to launch when SANDBOX_USER appears in ai-ops.` +- In style: `launch() exits non-zero when the service account appears in the admin group.` +- Off style: `The service account is never an administrator.` Where no guard exists, describe the behaviour without the absolute. ### Name the absent input rather than writing "nothing" -- Off style: `There is nothing left to gate, and nothing to trust.` -- In style: `The helper takes no path argument, so safe-paths.lib.sh is not loaded.` +- In style: `The helper does not take a path argument, so the path validator is not loaded.` +- Off style: `There is nothing left to check, and nothing to trust.` ### Domain vocabulary points at a mechanism `grant`, `claim`, `authority`, and `privilege` are correct when they name something in the -code — a sudoers rule, a POSIX ACL entry, the `--project-claim` verb. Used as metaphor for +code — a sudoers rule, a POSIX ACL entry, a `claim` subcommand. Used as metaphor for what code merely does, they read as legal prose. The same test applies to any borrowed vocabulary: point at the mechanism it names, or choose a plainer word. @@ -114,8 +114,8 @@ retries, logs, skips, reads, writes, starts, stops, maps, serializes, emits, for Open with the behaviour. Where a reader benefits from knowing what the behaviour prevents, that comes second. -- Off style: `Without this check a symlink could redirect the chown outside the project tree.` -- In style: `ai-tools-chown resolves the path and acts only on the pinned inode, so the change stays inside the project tree even if the path is swapped mid-operation.` +- In style: `chown() resolves the path once and acts on the pinned inode, so the change stays inside the tree even when the path is swapped mid-operation.` +- Off style: `Without this check a symlink could redirect the chown outside the tree.` ### Affirmative framing is structural @@ -127,6 +127,21 @@ Keep this structural: no praise, no intensifiers, no tone words, and never overs guarantee. No single sentence looks upbeat; across a corpus the effect accumulates, and the documentation reads as capable and dependable. +**Write a negation with `does not`.** Fronting the quantifier instead — `writes no entry`, +`takes no argument` — attaches the negative to the object instead of the verb. It reads formal +to archaic, and it is the determiner statutes are built from (*no person shall*, *no warranty is +given*). It is also the shorter form, and clarity outranks brevity: the razor takes the fewest +words that stay clear. + +- In style: `does not write any entries`, `does not take any path arguments` +- Off style: `writes no entry`, `takes no path argument` + +Pluralize an indefinite object under `any`. A single instance takes its article — `does not +write an entry` — and so does a definite one: `does not increment the counter`. + +The same applies to `nothing` as a subject or object, which the checklist already catches: name +the absent input instead. + ### Keep severity proportionate A routine check reads as a routine check. Reserve the vocabulary of failure and risk for @@ -145,20 +160,51 @@ dependency* is an RPM relation, not a statement about the quality of what it pul Leave out legal phrasing (hereby, pursuant to, thereunder, entitlement, standing, void), aphorisms and slogans, philosophical framing, and marketing language. -- Off style: `An empty request cannot produce a result.` - In style: `Returns an empty collection when no items match.` -- Off style: `A caller lacking identity receives no authorization.` +- Off style: `An empty request cannot produce a result.` - In style: `Returns 401 when the request is unauthenticated.` +- Off style: `A caller lacking identity receives no authorization.` ## Sentence craft +### Rationale is the payload — state it as a fact + +Purpose is what prose exists to carry. The code already shows what happens, so a header earns +its place by recording why: the constraint that forced the choice, the alternative rejected, the +foot-gun avoided. Write that freely — it is the content worth keeping. + +Write it in the same register as everything else, because this is the register that slips. +Explaining why attracts every figure in *Rhetorical figures* below: contrast ("rather than", +"instead of"), metaphor ("spends the signal"), definition ("a check that cannot fail is not a +check"). Each states the reason as a figure instead of a mechanism, so a reader cannot check it +against the code. + +State the reason as a fact about the code, and name the constraint behind it — an external +requirement, a kernel quirk, an ordering dependency. A "so that ⟨outcome⟩" clause is the usual +join. A because-, so-that-, or rather-than-sentence is the cue to re-read it against that table. +Run the check while drafting. + +**Attach purpose where the reason is non-obvious, and nowhere else.** A named construction turns +into a slot a writer fills, and a document whose every sentence makes a causal claim reads as +though none of them does. Three tests before a purpose clause stays: + +- **It says something the first half did not.** `The file is 0644, so it is world-readable` + restates the mode. Cut the clause. +- **A reader would miss it.** Where the consequence follows from the fact for anyone who knows + the domain, the fact stands alone. +- **The consequent names a mechanism.** `so it takes the same report` is vague; `so the + commit-msg hook runs the checker over the message` is the same claim, checkable. + +Purpose also lands without the join: as its own sentence, or as a paragraph's whole job. Where a +paragraph already makes one causal claim, check whether the next sentence earns a second. + ### One fact per sentence, in one direction Keep sentences short and single-idea. Avoid mirrored clauses that a reader must unpick to recover one fact. -- Off style: `A missing one costs you a label rather than costing the stop a target.` -- In style: `A session whose project cannot be read shows as unknown, and is terminated like any other.` +- In style: `A task whose project cannot be read shows as unknown, and is terminated like any other.` +- Off style: `A missing one costs you a label rather than costing the sweep a target.` ### Present tense, active voice @@ -201,18 +247,28 @@ A reader should follow *how* something works from the code alone. Prose carries the non-obvious trade-off a name, type, or signature cannot hold. Restating what the code does adds a second copy that drifts. -### Write the least that leaves a reader oriented +### Occam's razor — the fewest words that carry the full fact -The starting point for any explanation is none. A sentence earns its place by carrying something -the code cannot: the purpose, a constraint imposed from outside, a rejected alternative, a -foot-gun. Where the code can be made to say it instead, that is the better fix — a variable or -function renamed to state what it holds or does, spelled out in full and following the naming -conventions of the language in hand; a function extracted; a stronger type. +Use the fewest words that still carry the full fact. The starting point for any explanation is +none. A sentence earns its place only when it carries something the code cannot: purpose, an +external constraint, a rejected alternative, or a foot-gun. -A docs-to-code ratio that rivals the file is a late alarm rather than a budget to write up to; by -the time prose reaches that size the code has usually stopped being self-descriptive. Some of it -is warranted anyway, where the code cannot be made clearer — a kernel quirk, an ordering -constraint, a workaround for a defect elsewhere — and naming the constraint is the point. +Where the code can say it instead, prefer that fix: rename a variable or function so the name +itself states what it holds or does (full words, following the language's conventions); extract +a function; strengthen a type. + +Two habits do most of the work: + +- Merge sentences that share a subject. +- Cut any fact already carried by this file, by the code below it, or by the domain rule that + owns it. Each fact has one home. + +**Length is a symptom, never a budget.** Prose that approaches the size of the code it describes +usually means the code has stopped being self-descriptive; the fix is to make the code say it. +Short prose is not automatically finished prose either: the only test is whether every remaining +sentence still carries a fact. A longer header is correct precisely when the code cannot be made +clearer — a kernel quirk, an ordering constraint, a workaround for a defect elsewhere — and the +point of the prose is to name that constraint. Judge each file on its own. A header at a good altitude stays as it is, and a change that merely touches a file edits only the passages it invalidates. On a header that has grown past its @@ -243,8 +299,9 @@ Purpose, style, and tense constrain different things, and compose: RFCs are full of purpose: "receivers MUST ignore unknown fields *so that* the format stays forward-compatible" is purpose, spec style, and present tense at once. Friction appears only -when purpose is written as **history** or as a **predicted human action**. Attach purpose as a -"so that ⟨invariant⟩" clause on a fact about what the code does. +when purpose is written as **history** or as a **predicted human action**. A "so that +⟨invariant⟩" clause on a fact about what the code does is one way to attach it — see the limit +on it under *Rationale is the payload*. --- @@ -393,7 +450,7 @@ subject here, so the current-state rule above does not apply. - **One or two sentences per entry.** Depth comes from a link to the issue, PR, or doc. - **Grouped so a scan works** — Added, Changed, Deprecated, Removed, Fixed, Security, or the project's established headings. Breaking changes appear in one place. -- **Internal churn produces no entry** — tests, formatting, CI, version bumps. +- **Internal churn does not produce an entry** — tests, formatting, CI, version bumps. - **Present the gain plainly.** A reader should finish an entry knowing what they get, without the entry sounding like it is being sold. @@ -463,20 +520,35 @@ Use structured templates so fields survive into the journal or the log store. # Anti-patterns +## Rhetorical figures + +Name the figure and it becomes greppable. Each of these is a *shape*, not a word, so a +vocabulary filter cannot see any of them. + +| Figure | Example | Why it fails | Instead | +|---|---|---|---| +| **Definitional negation** — "an X that fails a test is not an X" | "A threshold nobody acts on is not a threshold" | A tautology dressed as a finding | "An unacknowledged threshold does not raise any alert, so each one names the person who receives it" | +| **Abstraction as subject** | "A claim *leaves* nothing registered" | The subject cannot be opened in the code | "`claim()` does not write an entry when one already covers the path" | +| **Chiasmus** — mirrored clauses | "costs you a label rather than costing the sweep a target" | The reader unpicks a mirror to get one fact | Two plain sentences, or one fact stated once | +| **"Nothing" as a quantifier** | "there is nothing left to gate" | Hides *which* input is missing | "The helper does not take a path argument, so the path check is skipped" | +| **Unbacked absolute** | "The service account is never an administrator" | A claim about the code with no check named | "`start()` exits non-zero when the service account holds the admin role" | +| **Metaphor for a mechanism** | "spends the strict-mode signal" | Does not name any operation a reader can find | "does not increment any counter, so it stays out of the summary" | +| **Negation as framing** | "a host with nothing wrong" | States the absence of a fault instead of the state | "a host in a supported configuration" | + +## Artifact-level + | Off style | In style | |---|---| -| `A claim that can grant nothing leaves nothing registered.` | `--project-claim writes no allowlist entry when an existing entry already covers the path.` | -| `A threshold nobody acts on is not a threshold.` | `An unacknowledged threshold raises no alert, so each one names the operator who receives it.` | -| `The sandbox account is never an operator.` | `ai-tools-run refuses to launch when SANDBOX_USER appears in ai-ops.` | -| `Arming the timer so it does not fail to fire` | `Enabling the timer so the update runs daily` | -| `Directories are pruned from the walk` | `Directories on the skip list are omitted from the walk, which keeps the sweep fast` | | Paragraph of preamble, then code | Code block, then one paragraph naming the mechanism | +| `Arming the timer so it does not fail to fire` | `Enabling the timer so the update runs daily` | +| `Entries are pruned from the walk` | `Entries on the skip list are omitted from the walk, which keeps the sweep fast` | | `The framework was updated to support async` | `The async handler takes precedence when both are defined` | | `Improved reliability / Various fixes` | `Fixed HttpClient retry on 429; corrected timezone parsing in date fields` | -| Changelog entry describing the mechanism | Entry describing what the operator gains | -| Commit body as long as the diff | Two paragraphs: the why, and where the detail lives | +| Changelog entry describing the mechanism | Entry describing what the caller or operator gains | +| Commit body as long as the diff | Two short paragraphs: the why, and where the detail lives | | Rambling multi-sentence doc comment | One-line contract; a second sentence for a real precondition | | Bulleted list narrating each behaviour | Connected prose; bullets for true enumerations | +| Slogan or abstract principle | The observable outcome, or the concrete rule that produces it | --- @@ -493,7 +565,23 @@ Scan the finished text for each of these, since every one is checkable: 7. History in reference prose: "now", "used to", "previously", "was changed", a date. 8. A fact stated in full in more than one place from the same perspective. 9. Filler and intensifiers. -10. A doc comment or header longer than the code it describes. +10. A sentence carrying no fact the code, this file, or the domain rule lacks — cut it. Two + sentences sharing a subject — merge them. (Length is the symptom, not the test: prose the + size of its code says the code stopped being self-descriptive, and prose that is merely + short has not thereby passed.) + +**Run the checkable ones.** `prose-check.py` ships beside this file and greps items 2, 3, 4, 5, +7 and 9 plus the `does not` rule, so the pass is a command rather than an act of attention: + +```bash +python3 /opt/ai-tools/skills/ai-tools-technical-docs/prose-check.py ... +``` + +It reports and never blocks. Two of its checks are near-exact; the rest (`--all`) report correct +prose often enough to need a reader on every hit. Quoted and backticked spans are skipped, so a +document may quote the prose it warns against; mark anything else deliberate with +`prose-check: allow` on the line. Run it before committing prose, and on the commit message +too — the universal rules cover that artifact like any other. When in doubt: describe what the code does, name the mechanism that does it, and use fewer words. diff --git a/src/usr/share/ai-tools/skills/ai-tools-technical-docs/prose-check.py b/src/usr/share/ai-tools/skills/ai-tools-technical-docs/prose-check.py new file mode 100644 index 00000000..b97ca838 --- /dev/null +++ b/src/usr/share/ai-tools/skills/ai-tools-technical-docs/prose-check.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# prose-check.py -- reports the rhetorical figures this skill rules out, as file:line, so the +# final-pass checklist runs mechanically instead of by eye. It ships beside the SKILL.md it +# enforces, so the rule and its check are versioned together. +# +# Seeded assets are mode 640, so run it through its interpreter: +# +# python3 /opt/ai-tools/skills/ai-tools-technical-docs/prose-check.py ... +# +# Three modes. `--staged` reads the added lines of the git index, which is what a pre-commit +# hook runs; `--message` reads a commit message, an artifact this standard covers like any +# other; named paths are read whole, for a sweep. +# Shell files contribute their comment lines, Markdown and man pages every line. The patterns +# match English, so they carry to any codebase. +# +# The default checks are the two that scored above 95% precision when sampled against this +# repository. `--all` adds four more that report correct prose often enough to need a reader on +# every hit -- `cannot` scored 0 of 6, because the rule it implements ("with no guard named in the +# same sentence") is not a property a regex can see. +# +# A line carrying `prose-check: allow` is skipped, which is how a style guide keeps the labelled +# bad examples it has to contain. + +import argparse +import re +import subprocess +import sys + +ALLOW_MARKER = "prose-check: allow" + +# Any third-person verb before `no`, rather than a list of them: an enumerated list finds only +# the verbs whoever wrote it thought of, and this construction takes every transitive verb in the +# language. Two exclusions keep the suggestion honest. `is`/`was`/`has` carry the existential +# "there is no X", which reads plainly and has no mechanical rewrite; `means`/`implies` negate a +# following clause rather than an object, so "no operator means no ownership" wants "means there +# is no ownership" instead. The object stop-list drops the fixed adverbials. +FRONTED_QUANTIFIER = re.compile( + r"\b(?!is\b|was\b|has\b|means\b|implies\b)([a-z]{3,}s)" + r"\s+no\s+(?!longer\b|one\b|matter\b|doubt\b)([a-z][a-z-]*)") + +# Each entry is (name, pattern, hint). The hint is what to write instead, since a report naming +# only the defect leaves the reader to rediscover the fix on every hit. +DEFAULT_CHECKS = [ + ("fronted-quantifier", FRONTED_QUANTIFIER, None), # hint derived; see suggest() + ("nothing", re.compile(r"\bnothing\b"), "name the absent input"), +] + +# Third-person singular endings that need more than a dropped "s". +_ES_ENDINGS = ("sses", "shes", "ches", "xes", "zes", "oes") + + +def base_form(verb): + """The base form of a third-person singular verb: carries -> carry, passes -> pass.""" + if verb.endswith("ies"): + return verb[:-3] + "y" + if verb.endswith(_ES_ENDINGS): + return verb[:-2] + return verb[:-1] + + +def suggest(name, match, static_hint): + """What to write instead, derived from the match where the fix is mechanical.""" + if name == "fronted-quantifier": + verb, obj = match.group(1), match.group(2) + return f"`does not {base_form(verb)} any {obj}`" + return static_hint + + +EXTRA_CHECKS = [ + ("mirrored-clause", re.compile(r"\brather than\b"), "state the fact once, in one direction"), + ("definitional", re.compile(r"\bis not (a|an|the)\b|\bis no\b"), "describe the mechanism"), + ("unbacked-absolute", re.compile(r"\b(never|always|cannot)\b"), + "name the guard in the same sentence"), + ("history", re.compile(r"\b(used to|previously|no longer|was changed)\b"), + "state current behaviour"), + ("filler", re.compile(r"\b(simply|obviously|clearly|basically|naturally|effectively" + r"|actually|essentially|robust|elegant|powerful|flexible)\b"), + "cut it"), +] + +PROSE_WHOLE_FILE = (".md", ".1", ".5", ".8") + + +MESSAGE = "" # the path a commit message is reported under + + +def is_prose_line(path, line): + """True when this line carries prose: any line of a document, a comment in a script. + + A commit message inverts the script rule -- its body is prose and its `#` lines are the + template git strips -- so it is passed under its own path and tested here. + """ + if path == MESSAGE: + return not line.lstrip().startswith("#") + if path.endswith(PROSE_WHOLE_FILE): + return True + stripped = line.lstrip() + return stripped.startswith("#") and not stripped.startswith("#!") + + +def staged_lines(): + """Yield (path, line) for every line this commit adds, from the index.""" + diff = subprocess.run( + ["git", "diff", "--cached", "-U0", "--no-color", "--diff-filter=ACM"], + capture_output=True, text=True, check=False).stdout + path = None + for line in diff.splitlines(): + if line.startswith("+++ b/"): + path = line[6:] + elif line.startswith("+") and not line.startswith("+++") and path: + yield path, line[1:] + + +def file_lines(paths): + """Yield (path, line) for every line of every readable path.""" + for path in paths: + try: + with open(path, errors="ignore") as handle: + for line in handle: + yield path, line.rstrip("\n") + except OSError as exc: + print(f"prose-check: cannot read {path}: {exc}", file=sys.stderr) + + +BACKTICK_SPAN = re.compile(r"`[^`]*`") +QUOTED_SPAN = re.compile(r"`[^`]*`|\"[^\"]*\"") + + +def author_prose(path, line): + """The line with the spans that are not the author's own prose blanked out. + + A backticked span is a code reference in either kind of file. A double-quoted span is a + quotation in a DOCUMENT -- most often the labelled bad example a style guide has to contain -- + so documents drop it too. A comment keeps its quoted text, because a message template quoted + in a comment is prose this standard covers. + """ + span = QUOTED_SPAN if path.endswith(PROSE_WHOLE_FILE) else BACKTICK_SPAN + # " -- " rather than a space: a removed span must still separate the words around it, or + # `takes \x60--for\x60 no target` fuses into a phrase the patterns then match. + return span.sub(" -- ", line) + + +def findings(source, checks): + for path, line in source: + if ALLOW_MARKER in line or not is_prose_line(path, line): + continue + subject = author_prose(path, line) + for name, pattern, hint in checks: + match = pattern.search(subject) + if match: + yield path, name, match.group(0), suggest(name, match, hint), line.strip() + + +def main(): + parser = argparse.ArgumentParser( + description="report prose figures the writing standard rules out") + parser.add_argument("--staged", action="store_true", + help="check the lines this commit adds") + parser.add_argument("--message", metavar="FILE", + help="check a commit message; template comments skipped") + parser.add_argument("--all", action="store_true", + help="add the lower-precision checks") + parser.add_argument("paths", nargs="*", help="files to read whole") + args = parser.parse_args() + + modes = [args.staged, bool(args.message), bool(args.paths)] + if sum(1 for m in modes if m) != 1: + parser.error("give exactly one of --staged, --message FILE, or one or more paths") + + checks = DEFAULT_CHECKS + (EXTRA_CHECKS if args.all else []) + if args.staged: + source = staged_lines() + elif args.message: + source = ((MESSAGE, line.rstrip("\n")) for line in open(args.message, errors="ignore")) + else: + source = file_lines(args.paths) + + count = 0 + for path, name, token, hint, text in findings(source, checks): + count += 1 + print(f"{path}: {name} [{token}] -- {hint}") + print(f" {text[:110]}") + if count: + print(f"\n{count} finding(s). See the ai-tools-technical-docs skill; " + f"mark a deliberate example with '{ALLOW_MARKER}'.") + return 1 if count else 0 + + +if __name__ == "__main__": + sys.exit(main())