docs: a documentation standard with named owners, held by repolint

Documents on the studio line had no owner, no status and no shape. Of
120 Markdown files, 544 paragraphs and list items ran past 320
characters. Chinese copies had drifted from the English ones (SPEC.md
1301 lines, SPEC.zh-CN.md 492). CODEOWNERS still named five paths that
no longer exist. The Studio release doc claimed a required approval and a
main-v2-only signing policy, neither of which is true any more.

- docs/DOCS_STANDARD.md states the rules as ID tables: ownership,
  document classes, header, writing width, section shape per type,
  language, and lifecycle.
- .github/CODEOWNERS gives every area a primary and a backup, in that
  order. Every rule still lists both maintainers, so review requests do
  not change.
- docs/STUDIO_RELEASE.md becomes a runbook: owners, preconditions,
  steps, verification, recovery, release-note format, reference. Each
  fact was checked against release-studio.yml, the environment's branch
  policy and the 2.17.0 release.
- repolint gains five rules:
  - codeowners: a rule must match a file and name two people.
  - doc-owner: a document header's owner and backup must equal the
    matching CODEOWNERS rule.
  - doc-prose: a block must stay within 320 display columns, with a CJK
    character counting as two.
  - doc-language: only three onboarding pages keep a Chinese copy.
  - release-note: fixed group headings, one referenced short line per
    change.

Document classes are declared by path. Landing pages, model-read files,
GitHub templates and release notes carry no header, because each is
rendered or consumed verbatim; CODEOWNERS owns them.

Baseline: -allow-widen raises only the five new rules, from nothing to
the debt that exists today (doc-owner 91, doc-prose 2755 weighted,
doc-language 33, release-note 661, codeowners 0). No existing rule
widened. Deleting stale documents and the untranslated copies follows
in its own change, after the list is reviewed.
This commit is contained in:
esengine committed 2026-09-16 20:57:15 -07:00
1 parent 44150b0aa4
commit 6b6a41d367
8 files changed
+1312 -130

No files matched your search

+30 -15
View File
@@ -1,16 +1,31 @@
# Every pull request requests review from the maintainers below.
# Either maintainer can review and merge any change.
* @SivanCola @esengine
# Ownership for the studio line. Standard: docs/DOCS_STANDARD.md.
#
# Each rule lists exactly two owners: primary first, backup second.
# GitHub requests review from both. The last matching rule wins.
# repolint (codeowners, doc-owner) fails a rule for a missing path, a rule
# without a backup, and a document header that names different owners.
# Keep the provider-side signing policy and its GitHub control plane under the
# same explicit review boundary as the machine-readable contract.
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/.github/workflows/release-studio.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
/scripts/package-windows-desktop.sh @SivanCola @esengine
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
/scripts/studio-build.sh @SivanCola @esengine
/cmd/signpath-contract/ @SivanCola @esengine
# Default: kernel, Studio shell, tooling, standing instructions.
* @esengine @SivanCola
# User-facing guides and references.
/docs/ @SivanCola @esengine
/sdk/ @SivanCola @esengine
/README.md @SivanCola @esengine
/README.zh-CN.md @SivanCola @esengine
/SECURITY.md @SivanCola @esengine
/CHANGELOG.md @SivanCola @esengine
# Contracts and processes the Studio line runs on.
/docs/DOCS_STANDARD.md @esengine @SivanCola
/docs/STUDIO_*.md @esengine @SivanCola
/docs/SESSION_MEMORY_RETRIEVAL.md @esengine @SivanCola
/docs/research/ @esengine @SivanCola
/release-notes/ @esengine @SivanCola
# Release signing: provider policy, its contract and the workflow that uses it.
/.signpath/ @esengine @SivanCola
/.github/workflows/release-studio.yml @esengine @SivanCola
/cmd/signpath-contract/ @esengine @SivanCola
/scripts/complete-signpath-request.ps1 @esengine @SivanCola
/scripts/verify-windows-authenticode.ps1 @esengine @SivanCola
+95
View File
@@ -0,0 +1,95 @@
---
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-09-17
---
# Documentation standard
Scope: every Markdown file on the `studio` branch outside `benchmarks/`.
Enforcement: `make check` and CI run `repolint`; rule IDs below name its findings.
## 1. Ownership
| ID | Rule |
| --- | --- |
| O1 | Every path MUST match a `.github/CODEOWNERS` rule listing exactly a primary and a backup. |
| O2 | The primary MUST review and merge changes to the path; the backup acts when the primary is unavailable. |
| O3 | A CODEOWNERS rule MUST match at least one file. Delete the rule with the file. |
| O4 | Owners MUST have write access to the repository. |
Current areas:
| Area | Paths | Primary | Backup |
| --- | --- | --- | --- |
| Kernel, Studio shell, tooling, standing instructions | `*` | @esengine | @SivanCola |
| User guides and references | `docs/`, `sdk/`, `README*`, `SECURITY.md`, `CHANGELOG.md` | @SivanCola | @esengine |
| Studio contracts and processes | `docs/STUDIO_*.md`, `docs/DOCS_STANDARD.md`, `docs/research/` | @esengine | @SivanCola |
| Release notes and release signing | `release-notes/`, `.signpath/`, `release-studio.yml` | @esengine | @SivanCola |
## 2. Document classes
| Class | Paths | Header | Prose width |
| --- | --- | --- | --- |
| Document | everything not listed below | required | 320 |
| Landing | `README.md`, `README.zh-CN.md` | none (CODEOWNERS) | 320 |
| Machine-read | `REASONIX.md`, `AGENTS.md`, `CLAUDE.md`, `.github/`, `.reasonix/`, skill and guardian prompts | none (CODEOWNERS) | 320 |
| Release note | `release-notes/` | none (published verbatim) | 200 per item |
The class table lives in `tools/repolint/docs.go`. Change both together.
## 3. Header (`doc-owner`)
A document MUST start with this block:
```yaml
---
owner: @github-handle # CODEOWNERS primary for this path
backup: @github-handle # CODEOWNERS backup for this path
status: active # active | deprecated
reviewed: YYYY-MM-DD # last time the owner confirmed it matches the code
---
```
| ID | Rule |
| --- | --- |
| H1 | `owner` and `backup` MUST equal the first and second owner of the matching CODEOWNERS rule. |
| H2 | `status: deprecated` means scheduled for deletion; link the replacement in the first line. |
| H3 | The owner SHOULD update `reviewed` whenever the described behavior changes. |
## 4. Writing (`doc-prose`)
| ID | Rule |
| --- | --- |
| W1 | State content as numbered rules, ordered steps, or tables. |
| W2 | A paragraph or list item MUST stay within 320 display columns (a CJK character counts as two). |
| W3 | Use MUST, SHOULD and MAY for requirements. Anything else is information. |
| W4 | Describe the current behavior. History and rationale belong in commit messages. |
| W5 | Put commands in fenced code blocks so they can be copied as-is. |
## 5. Types and required sections
| Type | File pattern | Required sections, in order |
| --- | --- | --- |
| Contract | `*_CONTRACT.md`, `SPEC.md` | Scope, Rules (ID table), Enforcement |
| Runbook | `*_RELEASE.md`, `*_RUNBOOK.md` | Owners, Preconditions, Steps, Verification, Recovery |
| Guide | everything else under `docs/` | Purpose, Steps or Reference tables |
Sections are reviewed by the owner. The gate checks header, width and language only.
## 6. Language (`doc-language`)
| ID | Rule |
| --- | --- |
| L1 | English is the maintained language of every document. |
| L2 | Only `README.zh-CN.md`, `docs/GUIDE.zh-CN.md` and `docs/CLI.zh-CN.md` MAY keep a Chinese copy. |
| L3 | Release notes are written in Chinese, the language Studio users read. |
## 7. Lifecycle
| ID | Rule |
| --- | --- |
| C1 | A document describing removed behavior MUST be deleted in the change that removes it. |
| C2 | Git history is the archive. Do not add `archive/` directories. |
| C3 | Existing violations are recorded in `tools/repolint/baseline.json` and may only go down. |
+101 -115
View File
@@ -1,132 +1,118 @@
# Releasing Reasonix Studio
---
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-09-17
---
Studio ships from the `studio` branch and publishes as a prerelease: it does not
enter the website's download page, does not claim the repository-wide "latest"
release, and does not touch the desktop line's rollback catalog.
# Studio release runbook
## Its own line
Workflow: `.github/workflows/release-studio.yml`. Trigger: push of a `studio-vX.Y.Z` tag.
`release-studio.yml` is the only release workflow this branch carries. It has its
own tag namespace (`studio-v*`), its own environment gate and its own rollback
catalog, so nothing it publishes can reach the desktop line's users.
## 1. Owners
Windows signing is the one boundary that still bites. The production SignPath
policy allows exactly one origin (protected `main-v2`), which is what lets it
avoid trusting wildcard tag-like branch names. A build from `studio` cannot
request it, so **Windows artifacts are unsigned** and users see a SmartScreen
warning. macOS is not affected: one Developer ID signs any bundle identifier its
team owns, so Studio is signed and notarized exactly as the desktop line is.
| Role | Person | Responsibility |
| --- | --- | --- |
| Release owner | @esengine | Decides the version, writes the notes, pushes the tag, verifies, recovers. |
| Backup | @SivanCola | Runs this runbook when the release owner is unavailable. |
## One-time setup
## 2. Preconditions
`release-studio.yml` gates publication on a GitHub environment named
`studio-release`. Create it with the owner as a required reviewer:
| ID | Check | Command |
| --- | --- | --- |
| P1 | The release commit is the fetched head of `origin/studio`. | `git fetch origin studio && git rev-parse origin/studio` |
| P2 | Every job of `CI` and `Studio` succeeded on that commit. A flaky job is rerun, not ignored. | `gh run list --branch studio --commit <sha> --json name,conclusion` |
| P3 | `release-notes/studio/X.Y.Z.md` is in that commit and passes `make check`. | `git cat-file -e <sha>:release-notes/studio/X.Y.Z.md` |
| P4 | The version is valid semver and above the latest tag. | `git tag -l 'studio-v*' --sort=-v:refname \| head -1` |
| P5 | The Windows signing mode is known. `true` signs through SignPath; `false` ships unsigned and the body says so. | `gh variable list \| grep STUDIO_SIGNING_ENABLED` |
- Settings → Environments → New environment → `studio-release`
- Required reviewers: the owner
## 3. Steps
It reuses these existing repository secrets — none are Studio-specific:
1. Write `release-notes/studio/X.Y.Z.md` (format in section 6), commit, push to `studio`.
2. Wait until P2 holds for the commit that contains the notes.
3. Tag the verified commit and push the tag:
| Secret | Used for |
```bash
git fetch origin studio
SHA=$(git rev-parse origin/studio)
git tag studio-vX.Y.Z "$SHA"
git fetch origin studio && test "$(git rev-parse origin/studio)" = "$SHA"
git push origin studio-vX.Y.Z
```
4. Watch the run until it finishes:
```bash
RUN=$(gh run list --workflow release-studio.yml --limit 1 --json databaseId --jq '.[0].databaseId')
gh run watch "$RUN" --exit-status
```
Jobs in the run:
| Job | Does | Gate |
| --- | --- | --- |
| `resolve` | Validates the tag shape and that the commit is on `studio`. | fails on any other ref |
| `signing-contract` | Validates `.signpath/` locally and prints its fingerprint. | does not call SignPath |
| `build` | Builds windows/amd64, darwin/amd64, darwin/arm64, linux/amd64; signs macOS and, if enabled, Windows. | Apple secrets are required |
| `cli` | Builds `reasonix` archives for six OS/arch targets plus `SHA256SUMS`. | fails on a missing archive |
| `publish` | Minisigns, writes `latest.json`, creates the GitHub prerelease, mirrors to R2. | environment `studio-release` |
The `studio-release` environment allows the `studio-v*` tag and the `studio` branch. It has no required reviewer.
## 4. Verification
| ID | Expected | Command |
| --- | --- | --- |
| V1 | Prerelease exists with 22 assets (per-platform packages, `.minisig` files, CLI archives, `latest.json`, `SHA256SUMS`). | `gh release view studio-vX.Y.Z --json isPrerelease,assets --jq '.isPrerelease, (.assets \| length)'` |
| V2 | The catalog lists the new version first. | `curl -s https://dl.reasonix.io/studio/versions.json \| jq -r '.versions[0].tag'` |
| V3 | The manifest is served. | `curl -sI https://dl.reasonix.io/studio-vX.Y.Z/latest.json \| head -1` |
| V4 | The body contains the version notes and the standing install text. | `gh release view studio-vX.Y.Z --json body --jq .body` |
## 5. Recovery
| Symptom | Cause | Action |
| --- | --- | --- |
| No run appears, or a rerun ends in `startup_failure` with no jobs. | GitHub Actions runner outage. | Wait for queued runs to drain, then `gh workflow run release-studio.yml --ref studio -f tag=studio-vX.Y.Z`. |
| A build or publish step failed. | Workflow or runner fault. | Fix on `studio` if needed, then dispatch as above. The dispatch rebuilds from the tag's commit with the workflow from `studio`. |
| Windows signing fails with `Invalid request to SignPath API.` | SignPath rejected the request; the action hides the status. | `gh run rerun <run> --failed --debug` to see it. On quota exhaustion set `STUDIO_SIGNING_ENABLED=false` and dispatch. |
| Signing is restored after an unsigned release. | Artifacts were published unsigned. | Set `STUDIO_SIGNING_ENABLED=true` and dispatch the same tag; `publish` replaces the assets. |
| The body is missing or wrong. | Notes are read from the tag's commit, not the branch. | `gh release edit studio-vX.Y.Z --notes-file <file>`; append the standing text from the previous body. |
| The tag points at the wrong commit and `publish` has not run. | Tagging error. | `git push origin :refs/tags/studio-vX.Y.Z`, delete the local tag, restart at step 3. |
| The tag points at the wrong commit and `publish` has run. | Tagging error after release. | Do not move the tag. Release the next patch version. |
## 6. Release note format
File: `release-notes/studio/X.Y.Z.md`, without the tag's `v`. Language: Chinese. Enforced by `release-note` and `doc-prose`.
| ID | Rule |
| --- | --- |
| `MINISIGN_PRIVATE_KEY`, `MINISIGN_PASSWORD` | detached signatures |
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ACCOUNT_ID`, `R2_BUCKET` | catalog + artifact mirror |
| `APPLE_CERT_P12`, `APPLE_CERT_PASSWORD`, `APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER_ID` | Developer ID signing and notarization |
| R1 | The first line is a one-sentence summary. No other paragraph. |
| R2 | Group headings are `## 新增`, `## 变更`, `## 修复`, `## 移除`, `## 升级须知`, in that order, omitting empty ones. No deeper headings. |
| R3 | One change is one list item within 200 display columns. |
| R4 | Every item names its issue (`#123`), pull request or commit. |
| R5 | Describe what the user observes. The explanation belongs in the linked commit. |
The minisign key is deliberately the desktop line's: the public key that verifies
it is compiled into the client (`update.PublicKey`), so a separate key would not
verify. The five `APPLE_*` secrets are required rather than optional — the macOS
job fails closed without them, because an unsigned build that reached users would
break the next self-update rather than the first launch. If the R2 secrets are
absent the GitHub release still publishes, but the catalog is not updated and
running builds will not see the new version.
```markdown
本版修复读图模型误报看不到图,并让被 ACL 残留阻塞的 Windows 安装恢复启动。
## Cutting a release
## 修复
Write the version's notes first. `release-studio.yml` reads
`release-notes/studio/<semver>.md` — named without the tag's v — and appends
the standing text about signing,
installation and self-update; without that file it publishes the standing text
alone and warns.
```bash
$EDITOR release-notes/studio/2.0.0.md
git checkout studio
git tag studio-v2.0.0
git push origin studio-v2.0.0
- 读图模型不再声称看不到已附加的图片 (44150b0aa)
- Windows 安装目录带 AppContainer 包 SID 授权时窗口可以正常打开 #10435
```
The tag triggers `release-studio.yml`, which:
## 7. Reference
1. validates the tag shape (`studio-vMAJOR.MINOR.PATCH[-PRERELEASE]`) and that
it sits on `studio` history;
2. builds Windows x64, macOS universal and Linux x64 with electron-builder,
from `desktop/electron/electron-builder.yml`. Windows and macOS package in
two steps because both need something done to the bundle in between — an
Authenticode signature there, notarization and stapling here;
3. waits for the `studio-release` approval;
4. minisign-signs every artifact and verifies each signature;
5. generates `latest.json` with `desktop/cmd/studio-manifest`;
6. publishes a GitHub **prerelease** (never `latest`);
7. mirrors artifacts to `dl.reasonix.io/<tag>/` and updates
`dl.reasonix.io/studio/versions.json`.
| Name | Kind | Used by |
| --- | --- | --- |
| `APPLE_CERT_P12`, `APPLE_CERT_PASSWORD`, `APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER_ID` | secret | macOS signing and notarization |
| `SIGNPATH_API_TOKEN`, `SIGNPATH_ORGANIZATION_ID` | secret | Windows Authenticode signing |
| `STUDIO_SIGNING_ENABLED` | variable | Windows signing switch and the body's Windows sentence |
| `MINISIGN_PRIVATE_KEY`, `MINISIGN_PASSWORD` | secret | detached signatures verified by the updater |
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ACCOUNT_ID`, `R2_BUCKET` | secret | artifact mirror and catalog |
`workflow_dispatch` with an existing tag recovers a failed run.
## R2 layout
```
dl.reasonix.io/
versions.json # desktop line — release-studio.yml never writes this
studio/versions.json # Studio's catalog; studioCatalog in desktop/next
studio-v2.0.0/… # Studio artifacts, latest.json, signatures
desktop-v1.26.0/… # desktop artifacts
```
Tag prefixes cannot collide because the tag names differ, so Studio artifacts sit
beside desktop ones without a separate bucket.
## Self-update
`latest.json` carries three maps and the updater reads each by role:
- `downloads` is what a person is offered, so it lists only what installs itself:
the `.dmg`, the Windows installer, the `.deb`.
- `platforms` is what the updater resolves — the Windows installer, which it runs
to upgrade in place, and the macOS universal `.zip`, whose bundle it swaps. A
portable archive is never listed: resolving one would hand the updater an
artifact it cannot install.
- `nativePackages` carries the `.deb`, which is how Linux upgrades, because dpkg
replaces the binary and the SPA tree together.
`desktop/cmd/studio-manifest` derives all three from the artifact names, and its
tests assert the manifest never claims a platform it cannot install.
The Windows installer is started as the user who asked for the update, not
elevated. It requests nothing in its manifest and raises consent itself only if
that user chooses an all-users install; starting it with `runas` would put it in
the consenting account, where its per-user default writes that profile and
leaves the updating user on the build they had. `update.StudioLine` states which
of the two it ships, and a line that states neither is refused rather than
guessed for.
That installer also takes over a Wails-era install rather than sitting beside
it. The old one is per-machine under Program Files with its own uninstall key,
this one is per-user under a key derived from the appId, so nothing about
installing it would have replaced the old one — both would have stayed in
Add/Remove Programs and only one would ever have been updated again.
`desktop/electron/assets/installer.nsh` runs the old uninstaller from
`customInstall`; a declined consent prompt leaves both installed rather than
failing the install.
## Known gaps
- No `versioninfo` stamp and **no windows/arm64 build**: `desktop/next` builds
with plain `go build` rather than the Wails CLI, and Windows x64 takes its icon
from the committed `rsrc_windows_amd64.syso` — arm64 has no `.syso`.
- Giving `desktop/next` its own `wails.json` fixes the version stamp and arm64
together. When doing it, keep Studio's install directory and uninstall registry
key distinct from `reasonix-desktop` — sharing them would let installing Studio
overwrite an existing desktop install.
- Installs of the desktop line (`desktop-v*`) read the root catalog, so they are
never offered Studio. Migrating them is not wired up.
| R2 path | Owner | Content |
| --- | --- | --- |
| `studio/versions.json` | this workflow | Studio catalog, newest first |
| `studio-vX.Y.Z/` | this workflow | artifacts, signatures, `latest.json` |
| `versions.json` | desktop line | never written by this workflow |
+470
View File
@@ -3,8 +3,12 @@
"banner": 0,
"build-artifact": 0,
"claude-dialect": 1,
"codeowners": 0,
"commented-code": 0,
"complexity": 1495,
"doc-language": 33,
"doc-owner": 91,
"doc-prose": 2755,
"error-text": 9,
"essay": 3207,
"file-size": 31285,
@@ -16,11 +20,36 @@
"narrative": 0,
"orphan": 80,
"refusal-path": 0,
"release-note": 661,
"spec-parity": 0,
"struct-state": 32,
"wire-parity": 0
},
"files": {
".github/pull_request_template.md": {
"doc-prose": 1
},
"CHANGELOG.md": {
"doc-owner": 1,
"doc-prose": 94
},
"CONTRIBUTING.md": {
"doc-owner": 1,
"doc-prose": 1
},
"README.md": {
"doc-prose": 1
},
"README.zh-CN.md": {
"doc-prose": 1
},
"REASONIX.md": {
"doc-prose": 92
},
"SECURITY.md": {
"doc-owner": 1,
"doc-prose": 1
},
"cmd/e2ebench/main.go": {
"essay": 1
},
@@ -30,6 +59,10 @@
"cmd/reasonix-plugin-example/main.go": {
"essay": 19
},
"desktop/README.md": {
"doc-owner": 1,
"doc-prose": 2
},
"desktop/cmd/sign/main.go": {
"essay": 14
},
@@ -47,9 +80,346 @@
"desktop/cmd/update-helper/versioned_windows.go": {
"essay": 10
},
"desktop/frontend-next/perf/README.md": {
"doc-owner": 1,
"doc-prose": 21
},
"desktop/frontend-next/src/styles/LAYOUT.md": {
"doc-owner": 1,
"doc-prose": 128
},
"desktop/frontend-next/src/styles/TOKENS.md": {
"doc-owner": 1,
"doc-prose": 22
},
"desktop/internal/winuninstall/registry_windows.go": {
"essay": 1
},
"docs/ACP.md": {
"doc-owner": 1,
"doc-prose": 13
},
"docs/ACP.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 3
},
"docs/BILLING.md": {
"doc-owner": 1,
"doc-prose": 2
},
"docs/BILLING.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/BOT_GUIDE.md": {
"doc-owner": 1,
"doc-prose": 27
},
"docs/BOT_GUIDE.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 11
},
"docs/CAPABILITY_DIAGNOSTICS.md": {
"doc-owner": 1,
"doc-prose": 5
},
"docs/CAPABILITY_DIAGNOSTICS.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 3
},
"docs/CHECKPOINTS.md": {
"doc-owner": 1,
"doc-prose": 4
},
"docs/CHECKPOINTS.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 1
},
"docs/CLI.md": {
"doc-owner": 1,
"doc-prose": 50
},
"docs/CLI.zh-CN.md": {
"doc-owner": 1,
"doc-prose": 17
},
"docs/COLLABORATION_MODES.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/CONFIG_PATHS.md": {
"doc-owner": 1,
"doc-prose": 30
},
"docs/CONFIG_PATHS.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 12
},
"docs/DESKTOP_CRASH_DIAGNOSTICS_RUNBOOK.md": {
"doc-owner": 1,
"doc-prose": 11
},
"docs/DESKTOP_CRASH_DIAGNOSTICS_RUNBOOK.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 2
},
"docs/DESKTOP_HOOKS.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 10
},
"docs/EXTENSIONS.md": {
"doc-owner": 1,
"doc-prose": 16
},
"docs/EXTENSIONS.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 4
},
"docs/EXTENSION_PROTOCOL.generated.md": {
"doc-owner": 1
},
"docs/EXTENSION_PROTOCOL.md": {
"doc-owner": 1,
"doc-prose": 20
},
"docs/EXTENSION_PROTOCOL.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 6
},
"docs/EXTENSION_RUNTIME_V2.md": {
"doc-owner": 1,
"doc-prose": 13
},
"docs/EXTENSION_RUNTIME_V2.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 6
},
"docs/EXTENSION_RUNTIME_V2_PERF.md": {
"doc-owner": 1,
"doc-prose": 9
},
"docs/EXTENSION_RUNTIME_V2_PERF.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 3
},
"docs/GOAL_ENFORCEMENT.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 8
},
"docs/GUIDE.md": {
"doc-owner": 1,
"doc-prose": 372
},
"docs/GUIDE.zh-CN.md": {
"doc-owner": 1,
"doc-prose": 178
},
"docs/HISTORY_SEARCH_CATALOG.md": {
"doc-owner": 1,
"doc-prose": 8
},
"docs/HISTORY_SEARCH_CATALOG.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/MIGRATING.md": {
"doc-owner": 1,
"doc-prose": 44
},
"docs/MIGRATING.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 3
},
"docs/PLUGIN_PACKAGES.md": {
"doc-owner": 1,
"doc-prose": 103
},
"docs/PLUGIN_PACKAGES.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 69
},
"docs/REASONING_LANGUAGE.md": {
"doc-owner": 1,
"doc-prose": 3
},
"docs/REASONING_LANGUAGE.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/REASONING_PROVIDERS.md": {
"doc-owner": 1,
"doc-prose": 23
},
"docs/REASONING_PROVIDERS.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 11
},
"docs/RECOVERY.md": {
"doc-owner": 1,
"doc-prose": 2
},
"docs/RECOVERY.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/RELEASING.md": {
"doc-owner": 1,
"doc-prose": 7
},
"docs/SESSION_MEMORY_RETRIEVAL.md": {
"doc-owner": 1,
"doc-prose": 15
},
"docs/SESSION_MEMORY_RETRIEVAL.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 3
},
"docs/SESSION_REFERENCE_ARCHITECTURE.md": {
"doc-owner": 1
},
"docs/SIGNPATH_WINDOWS_ADMIN_SOP.md": {
"doc-owner": 1
},
"docs/SPEC.md": {
"doc-owner": 1,
"doc-prose": 341
},
"docs/SPEC.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 78
},
"docs/STUDIO_SHELL_BOUNDARIES.md": {
"doc-owner": 1,
"doc-prose": 2
},
"docs/SUBAGENT_PROFILES.md": {
"doc-owner": 1,
"doc-prose": 4
},
"docs/SUBAGENT_PROFILES.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/SUBAGENT_PROGRESS.md": {
"doc-owner": 1,
"doc-prose": 5
},
"docs/SUBAGENT_PROGRESS.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 1
},
"docs/TASK_CONTRACT.md": {
"doc-owner": 1,
"doc-prose": 1
},
"docs/TASK_CONTRACT.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/THEME_ASSETS.md": {
"doc-owner": 1,
"doc-prose": 2
},
"docs/THEME_ASSETS.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/THEME_PACK.md": {
"doc-owner": 1,
"doc-prose": 5
},
"docs/THEME_PACK.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 1
},
"docs/TOOL_APPROVAL_MODES.md": {
"doc-owner": 1,
"doc-prose": 10
},
"docs/TOOL_APPROVAL_MODES.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 1
},
"docs/TOOL_CONTRACT.md": {
"doc-owner": 1,
"doc-prose": 41
},
"docs/TOOL_CONTRACT.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1,
"doc-prose": 20
},
"docs/USAGE_CATALOG.md": {
"doc-owner": 1,
"doc-prose": 2
},
"docs/USAGE_CATALOG.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/WINDOWS_LTSC_2019_CRASH_RUNBOOK.md": {
"doc-owner": 1
},
"docs/WINDOWS_LTSC_2019_CRASH_RUNBOOK.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/production_checklist.md": {
"doc-owner": 1
},
"docs/production_checklist.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"docs/research/cache-aware-compaction-design.md": {
"doc-owner": 1
},
"docs/research/goal-verification-authority.md": {
"doc-owner": 1
},
"docs/research/subagent-handoff-baseline.md": {
"doc-owner": 1,
"doc-prose": 3
},
"docs/superpowers/audits/2026-06-30-autoresearch-runtime-verification.md": {
"doc-owner": 1,
"doc-prose": 2
},
"docs/superpowers/plans/2026-06-29-autoresearch-runtime-implementation.md": {
"doc-owner": 1,
"doc-prose": 1
},
"docs/superpowers/plans/billing-currency-acceptance.md": {
"doc-owner": 1
},
"docs/superpowers/specs/2026-06-29-autoresearch-runtime-design.md": {
"doc-owner": 1,
"doc-prose": 2
},
"docs/superpowers/specs/2026-08-07-spatiotemporal-composability-v2.md": {
"doc-owner": 1,
"doc-prose": 3
},
"internal/acp/clientio.go": {
"essay": 4
},
@@ -1039,6 +1409,9 @@
"internal/guardian/guardian.go": {
"essay": 25
},
"internal/guardian/guardian_policy.md": {
"doc-prose": 1
},
"internal/history/indexed.go": {
"orphan": 1
},
@@ -1586,9 +1959,94 @@
"internal/update/manifest.go": {
"essay": 1
},
"release-notes/studio/2.0.0.md": {
"release-note": 29
},
"release-notes/studio/2.1.0.md": {
"doc-prose": 1,
"release-note": 25
},
"release-notes/studio/2.10.0.md": {
"doc-prose": 28,
"release-note": 46
},
"release-notes/studio/2.11.0.md": {
"doc-prose": 206,
"release-note": 75
},
"release-notes/studio/2.12.0.md": {
"doc-prose": 16,
"release-note": 35
},
"release-notes/studio/2.13.0.md": {
"release-note": 23
},
"release-notes/studio/2.14.0.md": {
"doc-prose": 28,
"release-note": 53
},
"release-notes/studio/2.14.1.md": {
"doc-prose": 16,
"release-note": 7
},
"release-notes/studio/2.15.0.md": {
"doc-prose": 110,
"release-note": 43
},
"release-notes/studio/2.16.0.md": {
"doc-prose": 107,
"release-note": 47
},
"release-notes/studio/2.17.0.md": {
"doc-prose": 43,
"release-note": 26
},
"release-notes/studio/2.2.0.md": {
"doc-prose": 1,
"release-note": 26
},
"release-notes/studio/2.3.0.md": {
"doc-prose": 2,
"release-note": 30
},
"release-notes/studio/2.4.0.md": {
"doc-prose": 1,
"release-note": 45
},
"release-notes/studio/2.5.0.md": {
"doc-prose": 10,
"release-note": 29
},
"release-notes/studio/2.6.0.md": {
"doc-prose": 8,
"release-note": 31
},
"release-notes/studio/2.7.0.md": {
"doc-prose": 44,
"release-note": 26
},
"release-notes/studio/2.8.0.md": {
"doc-prose": 58,
"release-note": 37
},
"release-notes/studio/2.9.0.md": {
"doc-prose": 32,
"release-note": 28
},
"sdk/go/README.md": {
"doc-owner": 1,
"doc-prose": 11
},
"sdk/go/examples/fullsidecar/main.go": {
"essay": 25
},
"sdk/go/examples/starterextension/README.md": {
"doc-owner": 1
},
"sdk/go/examples/starterextension/README.zh-CN.md": {
"doc-language": 1,
"doc-owner": 1
},
"sdk/go/sdk.go": {
"essay": 27,
"file-size": 367
@@ -1602,6 +2060,18 @@
"tools/repolint/orphan.go": {
"essay": 2
},
"tools/trajectory/README.md": {
"doc-owner": 1,
"doc-prose": 11
},
"tools/workgroupassay/README.md": {
"doc-owner": 1,
"doc-prose": 1
},
"workers/accounts/README.md": {
"doc-owner": 1,
"doc-prose": 4
},
"workers/crash-report/src/index.ts": {
"file-size": 686
}
+124
View File
@@ -0,0 +1,124 @@
// codeowners.go — who answers for a path, read from .github/CODEOWNERS.
// The first owner on a line is the primary and the second the backup; GitHub
// requests review from both, and the documentation gate reads the order.
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
)
const codeownersPath = ".github/CODEOWNERS"
type ownerRule struct {
line int
pattern string
owners []string
match *regexp.Regexp
}
func loadOwnerRules(root string) ([]ownerRule, error) {
data, err := os.ReadFile(filepath.Join(root, codeownersPath))
if err != nil {
return nil, err
}
var rules []ownerRule
for i, raw := range strings.Split(string(data), "\n") {
fields := strings.Fields(raw)
if len(fields) == 0 || strings.HasPrefix(fields[0], "#") {
continue
}
rules = append(rules, ownerRule{line: i + 1, pattern: fields[0], owners: fields[1:], match: ownerPattern(fields[0])})
}
return rules, nil
}
// ownersOf is the last matching rule, which is the one GitHub applies.
func ownersOf(rules []ownerRule, rel string) (ownerRule, bool) {
for _, r := range slices.Backward(rules) {
if r.match.MatchString(rel) {
return r, true
}
}
return ownerRule{}, false
}
// ownerPattern translates the subset of CODEOWNERS syntax this repository
// uses: a leading slash anchors, a trailing slash names a directory, * stays
// within a segment and ** crosses them.
func ownerPattern(pattern string) *regexp.Regexp {
anchored := strings.HasPrefix(pattern, "/") || strings.Contains(strings.Trim(pattern, "/"), "/")
dir := strings.HasSuffix(pattern, "/")
body := strings.Trim(pattern, "/")
var b strings.Builder
for i := 0; i < len(body); i++ {
switch {
case strings.HasPrefix(body[i:], "**"):
b.WriteString(".*")
i++
case body[i] == '*':
b.WriteString("[^/]*")
case body[i] == '?':
b.WriteString("[^/]")
default:
b.WriteString(regexp.QuoteMeta(body[i : i+1]))
}
}
prefix := "^"
if !anchored {
prefix = "^(?:.*/)?"
}
suffix := "(?:/.*)?$"
if dir {
suffix = "/.*$"
}
return regexp.MustCompile(prefix + b.String() + suffix)
}
// checkCodeOwners holds every rule to a path that exists and to a primary and
// a backup who are two different people. A rule for a deleted file routes
// nothing, and reading it tells a newcomer the file is still owned.
func checkCodeOwners(root string) []Finding {
rules, err := loadOwnerRules(root)
if err != nil {
return []Finding{{codeownersPath, 1, ruleCodeOwners, "no CODEOWNERS: nothing names who answers for a path", 1}}
}
paths, err := repoFiles(root)
var out []Finding
for _, r := range rules {
if len(r.owners) < 2 || r.owners[0] == r.owners[1] {
out = append(out, Finding{codeownersPath, r.line, ruleCodeOwners,
fmt.Sprintf("%s names %v; a rule needs a primary and a different backup", r.pattern, r.owners), 1})
}
if err == nil && !anyMatch(r.match, paths) {
out = append(out, Finding{codeownersPath, r.line, ruleCodeOwners,
fmt.Sprintf("%s matches no file in the tree; remove the rule with the file it owned", r.pattern), 1})
}
}
return out
}
func anyMatch(re *regexp.Regexp, paths []string) bool {
return slices.ContainsFunc(paths, re.MatchString)
}
// repoFiles is the tree a reviewer would see: tracked files plus new ones git
// does not ignore, minus anything deleted from the working tree. A walk would
// also read local worktrees and caches nobody commits.
func repoFiles(root string) ([]string, error) {
listed, err := gitPaths(root, "ls-files", "--cached", "--others", "--exclude-standard")
if err != nil {
return nil, err
}
out := listed[:0]
for _, rel := range listed {
if _, err := os.Stat(filepath.Join(root, rel)); err == nil {
out = append(out, rel)
}
}
return out, nil
}
+304
View File
@@ -0,0 +1,304 @@
// docs.go — a document names who answers for it and states its content as
// rules, steps and tables. docs/DOCS_STANDARD.md is the contract; this file is
// the part of it a machine can hold. Classes are declared by path, never
// inferred from what a file happens to contain.
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"unicode"
)
type docClass int
const (
// classDoc carries a metadata header and is held to the prose width.
classDoc docClass = iota
// classLanding renders as the repository's front page, where a header
// table would be the first thing a visitor reads; CODEOWNERS owns it.
classLanding
// classMachine is read verbatim by a model, GitHub or a release body, so a
// header would travel with it; CODEOWNERS owns it.
classMachine
classReleaseNote
)
const (
docProseWidth = 320
releaseNoteItemWidth = 200
proseWeightUnit = 80
docMetadataDelimiter = "---"
)
var docClassByPrefix = []struct {
prefix string
class docClass
}{
{"release-notes/", classReleaseNote},
{"README.md", classLanding},
{"README.zh-CN.md", classLanding},
{"REASONIX.md", classMachine},
{"AGENTS.md", classMachine},
{"CLAUDE.md", classMachine},
{".github/", classMachine},
{".reasonix/", classMachine},
{"internal/skill/builtincontent/", classMachine},
{"internal/guardian/", classMachine},
}
// docSkippedPrefixes are corpora and fixtures: text a benchmark or a test
// reads as data, which no reader is meant to follow.
var docSkippedPrefixes = []string{"benchmarks/"}
// translatedDocs are the onboarding pages kept in Chinese beside the English
// original. Every other document has one language, English.
var translatedDocs = []string{
"README.zh-CN.md",
"docs/GUIDE.zh-CN.md",
"docs/CLI.zh-CN.md",
}
var (
docStatuses = []string{"active", "deprecated"}
releaseNoteGroups = []string{"新增", "变更", "修复", "移除", "升级须知"}
isoDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
listItem = regexp.MustCompile(`^\s*(?:[-*+]|\d+\.)\s+`)
changeReference = regexp.MustCompile(`#\d+|\b[0-9a-f]{7,40}\b|https?://`)
)
func classifyDoc(rel string) (docClass, bool) {
if !strings.HasSuffix(rel, ".md") {
return 0, false
}
for _, p := range docSkippedPrefixes {
if strings.HasPrefix(rel, p) {
return 0, false
}
}
for seg := range strings.SplitSeq(rel, "/") {
if skipDirs[seg] {
return 0, false
}
}
for _, c := range docClassByPrefix {
if rel == c.prefix || (strings.HasSuffix(c.prefix, "/") && strings.HasPrefix(rel, c.prefix)) {
return c.class, true
}
}
return classDoc, true
}
func checkDocs(root string) []Finding {
files, err := repoFiles(root)
if err != nil {
return nil
}
rules, rulesErr := loadOwnerRules(root)
var out []Finding
for _, rel := range files {
class, ok := classifyDoc(rel)
if !ok {
continue
}
data, err := os.ReadFile(filepath.Join(root, rel))
if err != nil {
continue
}
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
if strings.HasSuffix(rel, ".zh-CN.md") && !slices.Contains(translatedDocs, rel) {
out = append(out, Finding{rel, 1, ruleDocLanguage,
"only onboarding pages keep a Chinese copy; the English document is the one maintained", 1})
}
switch class {
case classDoc:
if rulesErr == nil {
out = append(out, checkDocMetadata(rel, lines, rules)...)
}
out = append(out, checkProse(rel, lines, docProseWidth)...)
case classReleaseNote:
out = append(out, checkReleaseNote(rel, lines)...)
default:
out = append(out, checkProse(rel, lines, docProseWidth)...)
}
}
return out
}
// checkDocMetadata reads the header and holds its owners to CODEOWNERS, so the
// name printed on the page is the name GitHub asks for review.
func checkDocMetadata(rel string, lines []string, rules []ownerRule) []Finding {
meta, ok := docMetadata(lines)
if !ok {
return []Finding{{rel, 1, ruleDocOwner, "no metadata header: start with ---, owner, backup, status, reviewed, ---", 1}}
}
var problems []string
rule, owned := ownersOf(rules, rel)
switch {
case !owned || len(rule.owners) < 2:
problems = append(problems, "no CODEOWNERS rule names a primary and a backup for this path")
case meta["owner"] != rule.owners[0] || meta["backup"] != rule.owners[1]:
problems = append(problems, fmt.Sprintf("owner/backup %s/%s differ from CODEOWNERS line %d (%s %s)",
meta["owner"], meta["backup"], rule.line, rule.owners[0], rule.owners[1]))
}
if !slices.Contains(docStatuses, meta["status"]) {
problems = append(problems, fmt.Sprintf("status %q is not one of %v", meta["status"], docStatuses))
}
if !isoDate.MatchString(meta["reviewed"]) {
problems = append(problems, fmt.Sprintf("reviewed %q is not YYYY-MM-DD", meta["reviewed"]))
}
if len(problems) == 0 {
return nil
}
return []Finding{{rel, 1, ruleDocOwner, strings.Join(problems, "; "), len(problems)}}
}
func docMetadata(lines []string) (map[string]string, bool) {
if len(lines) == 0 || strings.TrimSpace(lines[0]) != docMetadataDelimiter {
return nil, false
}
meta := map[string]string{}
for _, line := range lines[1:] {
if strings.TrimSpace(line) == docMetadataDelimiter {
return meta, true
}
if key, value, ok := strings.Cut(line, ":"); ok {
meta[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
return nil, false
}
// proseBlock is one paragraph or one list item with its continuation lines.
type proseBlock struct {
line int
item bool
width int
text string
}
func proseBlocks(lines []string) []proseBlock {
var out []proseBlock
var cur *proseBlock
flush := func() {
if cur != nil {
out = append(out, *cur)
cur = nil
}
}
inFence, inMeta := false, false
for i, raw := range lines {
t := strings.TrimSpace(raw)
switch {
case i == 0 && t == docMetadataDelimiter:
inMeta = true
continue
case inMeta:
inMeta = t != docMetadataDelimiter
continue
case strings.HasPrefix(t, "```"):
inFence = !inFence
flush()
continue
case inFence:
continue
case t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, "|") || strings.HasPrefix(t, "<"):
flush()
continue
}
if loc := listItem.FindStringIndex(raw); loc != nil {
flush()
cur = &proseBlock{line: i + 1, item: true, text: raw[loc[1]:]}
continue
}
if cur == nil {
cur = &proseBlock{line: i + 1}
}
cur.text += " " + strings.TrimLeft(t, "> ")
}
flush()
for i := range out {
out[i].text = strings.TrimSpace(out[i].text)
out[i].width = displayWidth(out[i].text)
}
return out
}
// displayWidth counts a CJK character as two columns: the same budget in runes
// would let a Chinese paragraph run twice as long as an English one.
func displayWidth(s string) int {
n := 0
for _, r := range s {
if unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul) || (r >= 0x3000 && r <= 0x303f) || (r >= 0xff00 && r <= 0xffef) {
n += 2
} else {
n++
}
}
return n
}
func checkProse(rel string, lines []string, limit int) []Finding {
var out []Finding
for _, b := range proseBlocks(lines) {
if b.width <= limit {
continue
}
out = append(out, Finding{rel, b.line, ruleDocProse,
fmt.Sprintf("%s is %d columns, over %d: split it into rules, steps or a table", blockKind(b), b.width, limit),
(b.width - limit + proseWeightUnit - 1) / proseWeightUnit})
}
return out
}
func blockKind(b proseBlock) string {
if b.item {
return "list item"
}
return "paragraph"
}
// checkReleaseNote holds a version's notes to the published shape: one summary
// line, changes grouped under the fixed headings, one short referenced line per
// change. The reasoning behind a change lives in its commit.
func checkReleaseNote(rel string, lines []string) []Finding {
var out []Finding
paragraphs := 0
for i, raw := range lines {
t := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(t, "## "):
if !slices.Contains(releaseNoteGroups, strings.TrimSpace(t[3:])) {
out = append(out, Finding{rel, i + 1, ruleReleaseNote,
fmt.Sprintf("heading %q is not one of %v", t, releaseNoteGroups), 1})
}
case strings.HasPrefix(t, "#"):
out = append(out, Finding{rel, i + 1, ruleReleaseNote, "only ## group headings; a change is one list item, not a section", 1})
}
}
for _, b := range proseBlocks(lines) {
switch {
case !b.item:
paragraphs++
if paragraphs > 1 {
out = append(out, Finding{rel, b.line, ruleReleaseNote, "only the first line may be a summary paragraph; state changes as list items", 1})
}
case !changeReference.MatchString(b.text):
out = append(out, Finding{rel, b.line, ruleReleaseNote, "a change names its issue, pull request or commit", 1})
}
limit := docProseWidth
if b.item {
limit = releaseNoteItemWidth
}
if b.width > limit {
out = append(out, Finding{rel, b.line, ruleDocProse,
fmt.Sprintf("%s is %d columns, over %d", blockKind(b), b.width, limit),
(b.width - limit + proseWeightUnit - 1) / proseWeightUnit})
}
}
return out
}
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// docRepo is a git repository holding files, uncommitted: repoFiles lists what
// git does not ignore, so no commit is needed for the gate to see them.
func docRepo(t *testing.T, files map[string]string) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not on PATH")
}
dir, err := os.MkdirTemp("", "repolint-docs")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
for rel, body := range files {
path := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
git(t, dir, "init", "--quiet")
return dir
}
func ruleFindings(findings []Finding, rule string) []Finding {
var out []Finding
for _, f := range findings {
if f.Rule == rule {
out = append(out, f)
}
}
return out
}
func TestOwnerPatternFollowsCodeOwnersAnchoring(t *testing.T) {
cases := []struct {
pattern, path string
want bool
}{
{"*", "docs/GUIDE.md", true},
{"/docs/", "docs/GUIDE.md", true},
{"/docs/", "sub/docs/GUIDE.md", false},
{"docs/", "docs/GUIDE.md", true},
{"/docs/STUDIO_*.md", "docs/STUDIO_RELEASE.md", true},
{"/docs/STUDIO_*.md", "docs/nested/STUDIO_RELEASE.md", false},
{"*.md", "a/b/c.md", true},
{"/release-notes/**/*.md", "release-notes/studio/2.0.0.md", true},
{"/SECURITY.md", "SECURITY.md", true},
{"/SECURITY.md", "docs/SECURITY.md", false},
}
for _, c := range cases {
if got := ownerPattern(c.pattern).MatchString(c.path); got != c.want {
t.Errorf("%s vs %s = %v, want %v", c.pattern, c.path, got, c.want)
}
}
}
func TestCodeOwnersNeedsALivePathAndTwoPeople(t *testing.T) {
root := docRepo(t, map[string]string{
".github/CODEOWNERS": "* @a @b\n/docs/ @a\n/gone.yml @a @b\n/kept.md @a @a\n",
"docs/x.md": "x",
"kept.md": "x",
})
got := ruleFindings(checkCodeOwners(root), ruleCodeOwners)
lines := map[int]bool{}
for _, f := range got {
lines[f.Line] = true
}
if len(got) != 3 || !lines[2] || !lines[3] || !lines[4] {
t.Fatalf("findings = %+v, want the single owner (2), the dead path (3) and the repeated owner (4)", got)
}
}
func TestADocumentNamesItsOwnersAsCodeOwnersDoes(t *testing.T) {
header := func(owner, backup string) string {
return "---\nowner: " + owner + "\nbackup: " + backup + "\nstatus: active\nreviewed: 2026-09-17\n---\n# T\n"
}
root := docRepo(t, map[string]string{
".github/CODEOWNERS": "* @a @b\n/docs/ @b @a\n",
"docs/good.md": header("@b", "@a"),
"docs/swapped.md": header("@a", "@b"),
"docs/bare.md": "# No header\n",
"top.md": header("@a", "@b"),
"README.md": "# Landing page, owned through CODEOWNERS only\n",
"REASONIX.md": "# Read by a model verbatim\n",
})
got := ruleFindings(checkDocs(root), ruleDocOwner)
flagged := map[string]string{}
for _, f := range got {
flagged[f.File] = f.Msg
}
if len(flagged) != 2 || flagged["docs/swapped.md"] == "" || flagged["docs/bare.md"] == "" {
t.Fatalf("flagged = %v, want exactly the swapped owners and the missing header", flagged)
}
if !strings.Contains(flagged["docs/swapped.md"], "CODEOWNERS line 2") {
t.Fatalf("the finding must point at the rule it disagrees with: %s", flagged["docs/swapped.md"])
}
}
func TestStatusAndReviewDateAreChecked(t *testing.T) {
rules := []ownerRule{{line: 1, pattern: "*", owners: []string{"@a", "@b"}, match: ownerPattern("*")}}
lines := strings.Split("---\nowner: @a\nbackup: @b\nstatus: draft\nreviewed: last week\n---\n", "\n")
got := checkDocMetadata("x.md", lines, rules)
if len(got) != 1 || got[0].Weight != 2 {
t.Fatalf("findings = %+v, want one finding weighing both problems", got)
}
}
func TestProseWidthCountsChineseAsTwoColumns(t *testing.T) {
if got := displayWidth("ab中文,"); got != 8 {
t.Fatalf("width = %d, want 8", got)
}
long := strings.Repeat("字", 170)
lines := []string{"---", "owner: @a", "---", "# Title", "", long, "", "- short item", "```", long, "```", "| " + long + " |"}
got := checkProse("x.md", lines, docProseWidth)
if len(got) != 1 || got[0].Line != 6 {
t.Fatalf("findings = %+v, want only the paragraph: fences, tables and the header are not prose", got)
}
if got[0].Weight != 1 {
t.Fatalf("weight = %d, want the 20-column excess rounded up to one unit", got[0].Weight)
}
}
func TestAWrappedParagraphIsOneBlock(t *testing.T) {
line := strings.Repeat("word ", 20)
blocks := proseBlocks([]string{line, line, line, line, "", "- item", " continued " + line})
if len(blocks) != 2 || blocks[0].width != 399 || !blocks[1].item || !strings.Contains(blocks[1].text, "continued") {
t.Fatalf("blocks = %+v, want one wrapped paragraph and one item with its continuation", blocks)
}
}
func TestOnlyOnboardingPagesKeepAChineseCopy(t *testing.T) {
root := docRepo(t, map[string]string{
".github/CODEOWNERS": "* @a @b\n",
"README.zh-CN.md": "# 入门\n",
"docs/GUIDE.zh-CN.md": "---\nowner: @a\nbackup: @b\nstatus: active\nreviewed: 2026-09-17\n---\n",
"docs/SPEC.zh-CN.md": "---\nowner: @a\nbackup: @b\nstatus: active\nreviewed: 2026-09-17\n---\n",
"benchmarks/x.zh-CN.md": "corpus",
})
got := ruleFindings(checkDocs(root), ruleDocLanguage)
if len(got) != 1 || got[0].File != "docs/SPEC.zh-CN.md" {
t.Fatalf("findings = %+v, want only the non-onboarding translation", got)
}
}
func TestAReleaseNoteIsGroupedReferencedAndShort(t *testing.T) {
note := strings.Join([]string{
"一句话概括本版。",
"",
"## 修复",
"- 读图模型不再说看不到图 (44150b0aa)",
"- 没有引用的改动",
"- " + strings.Repeat("长", 110) + " #10435",
"",
"### 细节",
"",
"第二段散文。",
"",
"## 亮点",
}, "\n")
got := checkReleaseNote("release-notes/studio/9.9.9.md", strings.Split(note, "\n"))
byRule := map[string]int{}
for _, f := range got {
byRule[f.Rule]++
}
if byRule[ruleReleaseNote] != 4 || byRule[ruleDocProse] != 1 {
t.Fatalf("findings = %+v, want sub-heading, unknown group, unreferenced item, second paragraph, and one long item", got)
}
}
+8
View File
@@ -42,6 +42,11 @@ const (
ruleFrontendParity = "frontend-parity"
ruleFlatView = "flat-view"
ruleBuildArtifact = "build-artifact"
ruleCodeOwners = "codeowners"
ruleDocOwner = "doc-owner"
ruleDocProse = "doc-prose"
ruleDocLanguage = "doc-language"
ruleReleaseNote = "release-note"
)
var allRules = []string{
@@ -50,6 +55,7 @@ var allRules = []string{
ruleFuncSize, ruleComplexity, ruleStructState, ruleRefusalPath, ruleErrorText,
ruleClaudeDialect, ruleSpecParity, ruleWireParity, ruleOrphan,
ruleFrontendParity, ruleFlatView, ruleBuildArtifact,
ruleCodeOwners, ruleDocOwner, ruleDocProse, ruleDocLanguage, ruleReleaseNote,
}
func main() {
@@ -193,6 +199,8 @@ func run(root string) ([]Finding, error) {
findings = append(findings, checkLayering(imports)...)
findings = append(findings, checkSpecParity(root)...)
findings = append(findings, checkBuildArtifacts(root)...)
findings = append(findings, checkCodeOwners(root)...)
findings = append(findings, checkDocs(root)...)
findings = append(findings, wires.findings(root)...)
findings = append(findings, checkTransientTagParity(root)...)
findings = append(findings, checkInboxRefusalParity(root)...)