Skip to content

feat(oss-writing): check the mechanical prose tells and gate on them - #24

Open
svyatov wants to merge 10 commits into
mainfrom
feat/oss-writing-tells-checker
Open

feat(oss-writing): check the mechanical prose tells and gate on them#24
svyatov wants to merge 10 commits into
mainfrom
feat/oss-writing-tells-checker

Conversation

@svyatov

@svyatov svyatov commented Jul 27, 2026

Copy link
Copy Markdown
Owner

This repository's prose rules could only be enforced by a reader, so R-DOC-05 was scored by judgment and the house style drifted between files. oss-writing now ships a dependency-free checker at skills/oss-writing/scripts/check-tells.mjs that finds the mechanical subset of its tells, plus a --rule R-DOC-05 mode that produces that rule's evidence for oss-audit. Git hooks and CI run it, and the skill offers to install the wiring once, on evidence.

The tradeoff is that half the catalog stays unenforced. A pattern the token alone cannot decide, such as key in cache key, prints as a suspicion and leaves the exit code alone; only a pattern with no legitimate use in technical prose fails a build. Blocking on suspicions would make a checker people uninstall.

A model now runs the checker over a draft rather than reading its own prose against a list, and stops offering the hooks once core.hooksPath names the shipped directory. No failing run sits behind this. The rules were unenforceable rather than misapplied, so the driver was the gap itself, not a transcript.

R-DOC-05's Check: line names the checker and drops the phrase describes the project, which no token match can decide. That tightens the rule, so a repository that passed yesterday can fail today.

Verification beyond the checks CI runs: a review found four defects, and each was reproduced in a throwaway checkout and re-run after its fix. An emoji ahead of a fenced block leaked the code inside it into the scan, because the mask was built per code point and indexed per UTF-16 unit. The checker rejected the Breaking marker R-CHG-01 requires, which would have deadlocked CI against a correctly marked changelog entry. The rule mode answered from the filesystem outside a git checkout, scoring one tree two ways. Four callers omitted the option terminator, so a Markdown file whose name begins with a hyphen was read as a flag and never checked. Node 22 and 24 were not exercised locally and are covered by the CI matrix; an independent cross-model review was attempted and timed out without producing output.

BREAKING CHANGE: R-DOC-05 requires more than it did. Run node skills/oss-writing/scripts/check-tells.mjs --rule R-DOC-05 . and clear what it reports.

Affects: R-DOC-05, oss-writing, oss-audit

svyatov added 10 commits July 27, 2026 20:47
Extraction blanks masked regions rather than deleting them, so a finding's
line and column stay true to the source a reader has open.

A four-space indent under a list item is a CommonMark continuation
paragraph, not a code block, so masking every indent blind would stop
checking real prose and return a false clean. The list rule is a
heuristic rather than a parser, and it errs toward prose.
A pattern is an offence when no correct repo prose contains it, and a
suspicion when the catalog documents a legitimate use or the same
spelling has an ordinary technical noun sense. That rule, not the seed
list, decides each entry, so `cache key` prints and does not block.

Emoji detection uses `\p{Extended_Pictographic}`. `\p{Emoji}` matches the
ASCII digits and `#`, which would report every version number.

The Claude co-author trailer is matched without regard to case, because
git and GitHub both write `Co-authored-by` and the tell is the same one
either way.

`blazing` and `effortless` join the promotional adjectives row of
`references/tells.md`. R-DOC-05 already names both, and neither appeared
in the catalog the script has to agree with.
The sentence start is the first word, or the word after a leading label
token: a first or second token ending in `:`, or a first token ending in
`.`. Without that rule the checker reports this repository's own house
style, where a rule heading reads `### R-DOC-01: The README opens with
one sentence` and a procedural heading reads `## Step 1: Find
STANDARD.md`.

`Go` and `Actions` stay out of the default allowlist. Both are ordinary
English words, and shipping them buys a false negative on `## read the
Actions log` to avoid a false positive a repository clears with one entry
of its own.

A repository declares its own proper nouns in `.oss-kit.json`, under an
`oss-writing` key, so later settings have a file to go in. Two auditors
passing different flags would otherwise score the same repository
differently, which is the failure R-DOC-05's rationale exists to prevent.
The 51 headings this repository trips are all product names: Claude Code,
GitHub Actions, Agent Skills, OpenSSF Scorecard, Keep a Changelog.
The mode restricts both the file set and the pattern set to that rule's
`Check:` line, and promotes every finding to an offence, because
`oss-audit` scores pass or fail from the exit code and needs the same
answer on two runs. The file list is sorted for the same reason: readdir
order is not stable across platforms.

It reads `git ls-files` rather than the filesystem. `.gitignore` excludes
`docs/` in full here, and that directory holds agent-facing planning
documents `AGENTS.md` says are untracked by design, so a plain recursive
walk fails this repository on prose it deliberately excludes from its own
standard. Outside a git checkout the walk is the fallback.

The mode takes the repository root as its one positional argument, since
`oss-audit` scores a repository that is usually not the working
directory, and rejects a second one so a mistyped path cannot read as a
clean run. `--allow` is rejected here: two auditors passing different
flags would score the same repository differently, so the allowlist comes
from `.oss-kit.json` at the root.

From `CHANGELOG.md` it takes the text under every `##` heading, excluding
the preamble, the heading lines, and the link reference definitions, so a
version range and a compare URL are not read as prose.
Both hooks resolve the checker from their own directory rather than from
the repository root. Installing one skill puts it under a host-specific
path such as `.claude/skills/oss-writing/`, where a root-relative lookup
would take the skip branch on every commit and report nothing forever.

The installer points `core.hooksPath` at the shipped directory rather
than copying either hook anywhere, so there is one source and nothing to
drift. It runs the checker over tracked Markdown first and leaves
`pre-commit` disabled where that comes back dirty: the hook reads whole
files, so in a repository with a backlog it blocks every commit on prose
nobody in that commit wrote, which is how a hook gets uninstalled. The
switch is `osskit.precommit`, which the hook reads on each run.

`commit-msg` drops git's own comment lines and everything below the
scissors line `git commit --verbose` adds, so a diff is not read as prose.
The drift test asserts every pattern the script carries is named in
`references/tells.md` or `SKILL.md`, so deleting a tell from the prose
without touching the script fails CI. A regex source is not searchable
prose, so each entry carries an explicit token for this.

The two dash checks in `authored-docs.test.ts` now call the checker over
the same file set, which removes the second `proseOnly` implementation.
That loosens one thing: the old test read raw bytes, so it caught a dash
inside a fence on the three site pages, and routing it through the prose
stripper stops that. Reproducing quoted material verbatim is what the
skill's own Do not touch section requires, so this is the correct
behaviour rather than a regression.
…e clause

The `Check:` line names the script, following the precedent R-SKL-02 set
by naming the validator, and scopes its evidence to prose outside code
blocks, code spans, and quoted material, which is what the checker
measures and what the skill's own Do not touch section requires.

It also drops the phrase `describes the project`. No token match can
decide whether an adjective describes the project, so the qualifier was
never scored, and leaving it in the standard would have left the script
contradicting the rule it implements. Dropping it tightens what the rule
requires, which the README's Versioning section calls an incompatible
change: a repository that passed yesterday can fail today. The version is
below 1.0.0, so it ships in a MINOR release and `CHANGELOG.md` says so.

An auditor without `skills/oss-writing/` installed marks the rule unknown
rather than scoring it by reading. `oss-audit` supports a single-skill
install, so the script may be absent, and unknown is the third state that
skill already defines for evidence it cannot reach.

The README's Versioning section now covers the checker's interface beside
the validator's, because every audit depends on it once a `Check:` line
names it, and a patch release renaming `--rule` would break audits
silently.

`bun run lint:prose` runs the checker over every tracked Markdown file.
`CONTRIBUTING.md` documents it, which is what makes R-CI-02 bind, and the
`check` job runs it. The `runtime` job runs the audit mode beside the
validator, so both shipped scripts prove the same Node 22 floor on the
same matrix.
…twice

Two regex literals sat inside loops, one per line of a fenced block and one
per heading, where every other pattern in the file is a module-level constant.
Hoisting them matches the surrounding style and removes the per-iteration
allocation.

findConfig read each candidate file in full and threw the contents away, only
to prove the path existed, and then readConfigAllow read the same path again to
parse it. A stat is what an existence walk needs, and isFile keeps the walk
stepping past a directory that happens to carry the name.
maskProse built its character array by spreading the string, which yields
one element per code point, then indexed it with offsets counted in UTF-16
units. One emoji put the two a slot apart, so masking landed off its target
and the code inside a fence was scanned as prose. Splitting keeps both in
the same units.

The inline-header-bullet pattern flagged the Breaking marker R-CHG-01
requires on every incompatible changelog entry. The two rules contradicted
each other, and CI would have rejected a correctly marked entry. The marker
is now exempt, and this release's own entry carries it.

The rule mode fell back to walking the filesystem when git ls-files failed,
so the same tree scored one way inside a checkout and another way outside
it. That is the disagreement the mode exists to prevent, so it now reports
the rule unknown and exits 2. The walk had no other caller and is gone. An
empty file set now says so too, because a silent exit 0 reads as a pass
with no evidence behind it.

Four callers passed file names without the option terminator the script
already supports, so a Markdown file whose name begins with a hyphen was
read as a flag and never checked. One of them was the command the installer
prints for a reader to paste into their own CI.

The installer recorded core.hooksPath with symlinks unresolved, which left
an agent loaded through a linked path comparing it against the real
directory and offering the wiring again every session. findConfig now stops
at the repository root, because an allowlist decides what counts as an
offence and one above the checkout would change how the repository scores.
The check reported every capitalized word past a heading's sentence start,
so it had to know which capitalized words are proper nouns. That set is
unbounded, which is why it carried DEFAULT_ALLOW, an `--allow` flag,
`.oss-kit.json` with a bounded walk to the repository root, and a label
heuristic protecting this repository's own `R-DOC-01:` and `Step 1:`
headings. Across 371 headings here it reported nothing, and needed 11
allowlist entries to stay that quiet.

Deciding on the whole heading retires the question. Three or more words past
the first, all capitalized, is Title Case; `## configure GitHub Actions`
clears on the count and `## read the Actions log` clears on `the`, so no
repository declares its own proper nouns. `### R-DOC-01: The README opens
with one sentence` clears because the rest is a sentence, which retires the
label heuristic, and the Contributor Covenant's headings run one or two
words, which retires the code of conduct exemption.

What escapes is Title Case that lowercases its short prepositions, so
`## Getting Started with Docker` goes unreported. R-DOC-05's `Check:` line
now asks for what a checker can decide rather than for sentence case
throughout, which loosens what the rule requires.

A finding quotes its heading from the source rather than the masked copy, so
a code span reads as the author wrote it. `maskProse` normalizes CRLF, so
that copy is normalized first; slicing the argument would run a byte per
line off on a CRLF file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant