mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Comment-quality standard, and the gate that enforces it (#7663)
## The problem AI PRs write comments that restate the line below them, mark sections with box drawing, and narrate the diff. Nothing in the repo said not to, and nothing checked. `AGENTS.md` had one line about comments and it was buried in the Python section. Banners and `Step N:` narration have zero occurrences in the 15 months before Aug 2025, so this is new. ## The fix A written standard, plus a linter that enforces the mechanical part of it on added lines only. - [devGuide/CODE_COMMENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/devGuide/CODE_COMMENTS.md) holds the reasoning and worked examples; a section in [AGENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/AGENTS.md) holds the operative rules, kept short so they stay in an agent's context. The two are split by kind rather than duplicated, because the same prose in two places drifts. - Rules in [comment-rules.mjs](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs), shared by both engines. - Two engines. `.ts` / `.tsx` / `.mjs` go to an [oxlint JS plugin](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-oxlint-plugin.mjs) so comments come from the parser rather than a line scan; `.java` / `.py` go to a [line scanner](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint.mjs). Neither reads the other's files, so they cannot disagree about one file. - Between them they read every comment form the repo writes: `//` and `/* */`, Javadoc and JSDoc, JSX comments, `#`, and Python docstrings. - Runs in `task pre-commit`, so the git hook and the `pre_commit.yml` CI job both get it, and as a Claude Code [`Stop` hook](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-hook.mjs) so an agent fixes the comment inside the turn that wrote it. ## The rules The part worth arguing about. **Every rule blocks.** A rule that only warns is a rule nobody acts on, so a finding you believe is wrong is a bug in the rule: narrow it, or mark the line and say why. | | Fires on | | --- | --- | | [CMT001](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L71) | Every word in the comment already appears in the code below it. Max 6 words, skipped for prose punctuation and for a bare Arrange/Act/Assert marker | | [CMT002](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L92) | 4+ rule or box-drawing characters, or a bare section label from [a fixed list](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L84) (`Types`, `Helpers`, `State`, `Handlers`, ...) | | [CMT003](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L110) | `Step N:` with a separator, or `Then,` / `Next,` / `Finally,`. Suppressed in test files | | [CMT004](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L129) | A comment about the code's own past: `this used to`, `renamed from`, `was previously called`. Suppressed in test files | | [CMT005](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L154) | 3+ consecutive comment lines where 2/3 [parse as code](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L143) | | [CMT006](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L31) | A run of implementation comment over 12 lines, outside the first 5 lines of a file. Doc blocks are exempt, because the standard asks for thorough contracts | | [CMT007](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L180) | A parameter or return description that adds no word its name lacks. Reads Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google `name: description` | | [CMT008](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L239) | An allow directive naming a rule that does not exist, or one that silenced nothing | | [CMT009](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L219) | A `TODO` / `FIXME` / `HACK` naming no issue or link. An owner is not accepted: a username goes stale, an issue outlives it | Each rule carries the readings it deliberately excludes, next to the rule. Those exclusions came from running the rules over this repo, not from taste: `CMT004` does not match a bare "no longer needed" because that is as often about runtime lifecycle as about history, and `CMT003` needs a separator after the number so a wrapped line beginning "step 2 unmounts + remounts the panel" reads as the prose it is. A comment sharing a line with code is judged by the rules that do not depend on the code below it, so a trailing `// TODO fix this` or `/* this used to run before the flush */` still reports, while `50L * 1024 * 1024 // 50 MB` does not. `CMT001` would have been wrong about six in seven trailing comments here, so it stays out of them. If a finding is wrong, `// comment-lint-allow: CMT002` on the line above. Rule-specific, [no blanket disable](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L229). A directive naming a rule that does not exist, or silencing nothing, is itself a `CMT008` failure, so a typo cannot quietly disable a rule and a stale one gets deleted rather than accumulating. No native linter covers `CMT007`. `eslint-plugin-jsdoc`'s `require-param-description`, Checkstyle's `NonEmptyAtclauseDescription` and ruff's D-rules all check that a description exists, not whether it says anything. ## Scoping Added comment **text** only, not lines git calls new. Reindenting a file or moving a block makes git mark untouched comments as added; findings are matched against the comment text at the base, so only genuinely new content reports. The whole file is read and every comment in it evaluated. Only the *reporting* is filtered, so a rule still sees the code a comment introduces, the full run it belongs to, and the base version of the file. Existing tree is untouched. `task pre-commit:comment-lint:all` reports it and always exits 0: | | java | ts/js | py | | --- | --- | --- | --- | | findings | 1,218 | 741 | 204 | 2,163 across 542 files, mostly `CMT002` banners (1,482) and `CMT001` restatements (456). Clearing it is separate work, by directory. Not in this PR: an advisory LLM review layer for the things no pattern can judge. ## Verification Run against [#7494](https://github.com/Stirling-Tools/Stirling-PDF/pull/7494) as CI would, in a throwaway worktree: **two findings on a 78 file, +4,512 line change, both genuine banners, in 952ms**. A whole-file scan of those same files gives 11; the other 9 were withheld because that PR's author did not write them, and they are the `@param teamId the team ID` shape this standard exists to stop. Both scanners blank string and character literals before looking for comment markers, because a partial lex desynchronises everything after it: one apostrophe in a Java comment, or one Python template whose closing quotes start a line, is enough to read dozens of lines of code as a single comment. Two fixtures carry canaries that stop being reported if either engine ever desynchronises again. The [fixture corpus](https://github.com/Stirling-Tools/Stirling-PDF/tree/claude/ai-pr-comment-quality-dd970e/scripts/lint/fixtures) pins all 9 rules against both engines, and `--selftest` fails if the two disagree about the same file. ## Two things reviewers should know **The oxlint JS plugin API is alpha.** oxlint itself is stable and already this repo's frontend linter; the plugin API is the new dependency. Its documented failure mode ([oxc#25203](https://github.com/oxc-project/oxc/issues/25203)) is being skipped silently while oxlint still reports success. That affects the standalone release binary rather than the npm package this invokes, but the class of failure reads exactly like clean code, so the run asserts `number_of_rules >= 1` from oxlint's own report and a broken engine exits 2 rather than passing. If the API ever breaks, the fallback is folding these rules into the line scanner, which already implements all nine for Java and Python. **`.claude/settings.json` is now committed**, carrying the hook and nothing else: 19 lines, no `permissions`, nothing machine-specific. That partly reverts `c35546a212` ("Ignore claude dir"), which existed because this file had twice been committed by accident with a personal `permissions` allowlist, once with absolute machine paths. Personal config still belongs in `.claude/settings.local.json`, which the new pattern keeps ignored, and hook entries merge across the two so nobody's own hooks are lost. If you already hand-wrote a `.claude/settings.json`, copy it somewhere first: that path used to be git-ignored, and git overwrites an ignored file without warning when a commit starts tracking it. Across 19 local checkouts here, 13 have `settings.local.json` and none has a hand-written `settings.json`. To turn the hook off, `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in local settings. Claude Code can only disable all hooks at once, hence the switch. The commit-time gate still applies. ## How to test ```bash task pre-commit:comment-lint:ci ``` The fixture corpus, then the diff. The corpus checks the rules themselves rather than the code under review, so it runs on CI and before a rule change, not on every local commit. ```bash task comment-lint:branch ``` `clean (34 files in scope)`. `task comment-lint` is the same thing scoped to uncommitted work, which is what the git hook and CI run. To watch it bite, add `// Is banner` above `export function isBanner` in `scripts/lint/comment-rules.mjs` and run `task comment-lint`: one `CMT001`, exit 1. The gate covers its own source, which is why these scripts have no section dividers. ```bash task pre-commit:comment-lint:all ``` The standing backlog, report-only. Verified on the pinned oxlint 1.77.0, not only the 1.79 the plugin was prototyped against.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"${CLAUDE_PROJECT_DIR}/scripts/lint/comment-lint-hook.mjs"
|
||||
],
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ Closes #(issue_number)
|
||||
- [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable)
|
||||
- [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable)
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] Every comment I added says something the code does not ([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md))
|
||||
- [ ] My changes generate no new warnings
|
||||
|
||||
### Documentation
|
||||
|
||||
@@ -38,3 +38,8 @@ jobs:
|
||||
|
||||
- name: Run pre-commit checks
|
||||
run: task pre-commit
|
||||
|
||||
# The fixture corpus checks the comment rules themselves, so it runs here
|
||||
# rather than on every local commit.
|
||||
- name: Check the comment-lint fixture corpus
|
||||
run: task pre-commit:comment-lint:selftest
|
||||
|
||||
+7
-2
@@ -298,8 +298,13 @@ docs/type3/signatures/
|
||||
|
||||
**/application-dev-local.properties
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
# Claude. Contents are ignored so personal config stays local, with the two
|
||||
# shared pieces re-included: settings.json (the comment-lint hook) and skills/.
|
||||
# The directory itself cannot be ignored or git will not look inside it.
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
!.claude/skills/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Playwright MCP screenshots / traces
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -11,6 +11,7 @@ vars:
|
||||
'.github/scripts/*.py'
|
||||
'app/core/src/main/resources/static/python/*.py'
|
||||
':(exclude)*split_photos.py'
|
||||
':(exclude)scripts/lint/fixtures/*'
|
||||
SPELL_FILES: >-
|
||||
'*.html'
|
||||
'*.css'
|
||||
@@ -59,6 +60,7 @@ tasks:
|
||||
- task: gitleaks
|
||||
- task: whitespace
|
||||
- task: toml-sort
|
||||
- task: comment-lint
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
|
||||
@@ -75,6 +77,7 @@ tasks:
|
||||
vars: { FIX: '1' }
|
||||
- task: codespell
|
||||
- task: gitleaks
|
||||
- task: comment-lint
|
||||
|
||||
install:
|
||||
desc: "Install the pinned pre-commit Python tools"
|
||||
@@ -130,6 +133,85 @@ tasks:
|
||||
cmds:
|
||||
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
|
||||
|
||||
comment-lint:
|
||||
desc: "Check comment quality on the lines this branch adds"
|
||||
summary: |
|
||||
Blocks a comment that restates the code below it, a section banner, or a
|
||||
block of commented-out code. Everything else it reports is advisory.
|
||||
|
||||
Scoped to added lines, so touching an old file never surfaces the standing
|
||||
backlog. The standard is devGuide/CODE_COMMENTS.md.
|
||||
|
||||
With no arguments it diffs the working tree against HEAD, which is what a
|
||||
pre-commit run wants: the lines you are about to commit. On a CI pull request
|
||||
it diffs against the target branch instead, via GITHUB_BASE_REF.
|
||||
|
||||
To ask what a whole branch adds instead, use the branch variant, which
|
||||
needs no argument passing:
|
||||
task comment-lint:branch
|
||||
|
||||
Full tree (report only): task pre-commit:comment-lint:all
|
||||
Fixture corpus: task pre-commit:comment-lint:selftest
|
||||
# Depends on the frontend install because the .ts/.tsx half of the rule set
|
||||
# runs as an oxlint plugin. Without it the TS engine warns and skips, which
|
||||
# would leave the frontend silently unchecked on CI.
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs {{.CLI_ARGS}}
|
||||
|
||||
comment-lint:branch:
|
||||
desc: "Check comment quality on everything this branch adds over its base"
|
||||
summary: |
|
||||
Like `task comment-lint`, but scoped to the whole branch rather than to
|
||||
uncommitted work, so it still reports after you commit.
|
||||
|
||||
Exists as its own task because passing `-- --since origin/main` through Task
|
||||
is not portable: with the npm build of Task the launcher is a PowerShell
|
||||
script, and PowerShell strips the `--` before Task sees it, leaving Task to
|
||||
print its own usage.
|
||||
|
||||
Override the base with BASE=<ref>.
|
||||
vars:
|
||||
BASE: '{{.BASE | default "origin/main"}}'
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --since {{.BASE}}
|
||||
|
||||
comment-lint:ci:
|
||||
desc: "Comment gate as CI runs it: fixture corpus, then the diff"
|
||||
summary: |
|
||||
The corpus checks the rules themselves rather than the code under review, so
|
||||
it belongs on CI and not on every local commit. Run this before changing a
|
||||
rule, and let CI run it on every pull request.
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --selftest
|
||||
- node scripts/lint/comment-lint.mjs {{.CLI_ARGS}}
|
||||
|
||||
comment-lint:hook:
|
||||
desc: "Comment gate for the editor hook: everything this turn changed"
|
||||
summary: |
|
||||
Same scope as `task comment-lint`, kept as its own name so the hook has a
|
||||
stable entry point and the taskfile shows every way the linter is invoked.
|
||||
|
||||
Not in the frontend-install dependency chain on purpose: this runs at the end
|
||||
of every turn, so it stays as short as it can be. If oxlint is missing the TS
|
||||
half warns and skips.
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs
|
||||
|
||||
comment-lint:all:
|
||||
desc: "Report every comment finding in the tree (never fails)"
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --all
|
||||
|
||||
comment-lint:selftest:
|
||||
desc: "Check both comment-lint engines against the fixture corpus"
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --selftest
|
||||
|
||||
gitleaks-bin:
|
||||
internal: true
|
||||
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
|
||||
|
||||
@@ -21,6 +21,43 @@ Task `desc:` fields should describe **what** the task does, not **how** it does
|
||||
- `task docker:build` — build standard Docker image
|
||||
- `task docker:up` — start Docker compose stack
|
||||
|
||||
## Comments
|
||||
|
||||
A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it.
|
||||
|
||||
Comment the current state. Not what the code used to do, not what changed, not why it changed: git holds that. Where history explains the shape, state the reason instead, so "this used to reimplement the modal internals" becomes "thin wrapper over the shared Modal: duplicating its portal and focus trap is how dialogs drift apart". Future state goes in a TODO with an issue.
|
||||
|
||||
Write a comment when it does one of these four jobs:
|
||||
|
||||
- **Contract.** What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects. Document the contract of everything a caller outside the file can reach, and nothing else. Goes on the type/method/module as Javadoc, JSDoc, or a docstring.
|
||||
- **Why.** The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason.
|
||||
- **Hazard.** "Must stay in sync with X", "order matters because Y", "do not remove, it prevents Z".
|
||||
- **Map.** A short orientation at the top of a genuinely complex file: what it owns, and what it deliberately does not.
|
||||
|
||||
Never write:
|
||||
|
||||
- A comment that restates the next line. `// Handle drag start` above `handleDragStart` is noise.
|
||||
- Section banners or position markers: `// --- Types ---`, `// Helpers`, `// =====`.
|
||||
- Step narration in a function body (`// Step 1:`, `// Then we`). If the steps need labels they need names: extract functions. Numbering a genuinely numbered thing, like a wizard step, is fine.
|
||||
- Commented-out code. Delete it.
|
||||
- Doc tags that restate the signature. `@param blob - The blob` says nothing; omit the tag rather than pad it.
|
||||
- Docs on self-explanatory members with no constraint to state.
|
||||
|
||||
Two tests before keeping a comment:
|
||||
|
||||
- **Delete it.** Is any information lost? If not, it stays deleted.
|
||||
- **Could a name carry it instead?** A better identifier, an extracted function, or a named constant beats a comment. Prefer the code change.
|
||||
|
||||
A comment at the end of a line usually decodes that line, and that is worth keeping: `{0x25, 0x50} // "%PDF"`, `50L * 1024 * 1024 // 50 MB`. The rules that compare a comment against the code below it do not apply there, but a trailing TODO or a trailing bit of history is judged like any other.
|
||||
|
||||
A reference is supplementary, never load-bearing: the comment must survive deleting it. `// See #1234` is a dead end; `// saving first loses every annotation (#6865)` is not. Prefer a spec (`RFC 3161`) or CVE where one applies.
|
||||
|
||||
A TODO needs an issue, not an owner: `// TODO(#1234): re-enable the gate once account syncing lands`. If it is not worth an issue, it is not worth a TODO. A question is not a TODO.
|
||||
|
||||
A comment block over ~12 lines outside a file or type header usually means the code needs restructuring, or that the prose is product documentation and belongs in the docs repo.
|
||||
|
||||
`task comment-lint` checks the mechanical part of this on the lines you add, and runs inside `task pre-commit`. Reasoning, worked examples and the linter's own rules: @devGuide/CODE_COMMENTS.md
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
@@ -70,7 +107,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
- Avoid nested functions and nested classes unless the language construct requires them.
|
||||
- Prefer composition to inheritance when combining concepts.
|
||||
- Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle.
|
||||
- Add comments sparingly and only when they explain non-obvious intent.
|
||||
- Comments follow the repo-wide rules in the "Comments" section above.
|
||||
|
||||
#### Python Typing and Models
|
||||
- Deserialize into Pydantic models as early as possible.
|
||||
|
||||
@@ -42,6 +42,7 @@ Please make sure your Pull Request adheres to the following guidelines:
|
||||
- Keep commits atomic. One commit should contain one change. If you want to make multiple changes, submit multiple Pull Requests.
|
||||
- Commits should be clear, concise, and easy to understand.
|
||||
- References to the Issue number in the Pull Request and/or Commit message.
|
||||
- Every comment in the diff should say something the code does not. See [Code comments](devGuide/CODE_COMMENTS.md); `task comment-lint` checks the mechanical part.
|
||||
|
||||
## Translations
|
||||
|
||||
|
||||
@@ -266,6 +266,20 @@ tasks:
|
||||
cmds:
|
||||
- task: frontend:lint
|
||||
- task: engine:lint
|
||||
- task: comment-lint
|
||||
|
||||
comment-lint:
|
||||
desc: "Check comment quality on the lines this branch adds"
|
||||
aliases: [comments]
|
||||
cmds:
|
||||
- task: pre-commit:comment-lint
|
||||
vars: { CLI_ARGS: '{{.CLI_ARGS}}' }
|
||||
|
||||
comment-lint:branch:
|
||||
desc: "Check comment quality on everything this branch adds over its base"
|
||||
cmds:
|
||||
- task: pre-commit:comment-lint:branch
|
||||
vars: { BASE: '{{.BASE}}' }
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix all components"
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# Code comments
|
||||
|
||||
A comment must carry information the code cannot. If a reader could derive it from
|
||||
the code in front of them, delete it: a redundant comment still has to be
|
||||
maintained, will eventually contradict the code, and dilutes the comments that
|
||||
matter.
|
||||
|
||||
The operative rules are in `AGENTS.md`, kept short so they stay in an agent's
|
||||
context. This document is the reasoning and the worked examples behind them, plus
|
||||
how to run the linter.
|
||||
|
||||
## Comment the current state
|
||||
|
||||
Describe the code as it is. Not what it used to be, not what changed, not why it
|
||||
changed. A comment that narrates history is stale the moment the next change
|
||||
lands, and git already holds that record.
|
||||
|
||||
When you know the history and it explains the shape of the code, the useful half is
|
||||
the reason, not the sequence. State the reason:
|
||||
|
||||
```java
|
||||
// Don't:
|
||||
// This used to reimplement the modal internals, which is how the procurement
|
||||
// dialogs drifted from the billing ones.
|
||||
|
||||
// Do:
|
||||
// Thin wrapper over the shared Modal: duplicating its portal and focus trap is
|
||||
// how dialogs drift apart.
|
||||
```
|
||||
|
||||
Future state is the exception, and it belongs in a TODO with an issue.
|
||||
|
||||
## The four jobs
|
||||
|
||||
**Contract.** What a caller must know that the signature cannot say:
|
||||
preconditions, invariants, units, ownership and lifetime, thread-safety, error
|
||||
semantics, side effects.
|
||||
|
||||
The bound is the surface, not the volume: document the contract of everything a
|
||||
caller outside the file can reach, and nothing else. Inside that surface say
|
||||
whatever a caller needs; outside it a comment earns its place on the same terms as
|
||||
any other.
|
||||
|
||||
```java
|
||||
/**
|
||||
* Authority on which filesystem locations a policy may read or write. Fail-closed
|
||||
* in order: denied entirely under the saas profile; Stirling's own config dir is
|
||||
* always rejected; the path must resolve within policies.allowedFolderRoots.
|
||||
*
|
||||
* <p>Compared after normalisation so {@code ..} cannot escape a root. Symlink
|
||||
* escape is not defended: an operator who roots an allowlist on a symlink to a
|
||||
* sensitive location is trusted.
|
||||
*/
|
||||
```
|
||||
|
||||
**Why.** The constraint the code satisfies, the bug it avoids, the alternative
|
||||
rejected and the reason.
|
||||
|
||||
```java
|
||||
// whenComplete runs on the worker thread after the run finishes, so the
|
||||
// terminal event never races the step events.
|
||||
handle.completion()
|
||||
```
|
||||
|
||||
A reference is supplementary, never load-bearing: the comment must survive
|
||||
deleting it. `// See #1234` is a dead end.
|
||||
|
||||
```java
|
||||
// flatten() reads the annotation list that save() clears, so saving first loses
|
||||
// every annotation (#6865).
|
||||
document.flatten(annotations);
|
||||
```
|
||||
|
||||
Prefer a spec (`RFC 3161`, `ISO 4217`) or a CVE where one applies. Both are
|
||||
immutable; a ticket can be closed, moved or made private.
|
||||
|
||||
**Hazard.** "Must stay in sync with X." "Order matters because Y." "Do not remove,
|
||||
it prevents Z."
|
||||
|
||||
**Map.** A short orientation at the head of a genuinely complex file: what it owns,
|
||||
and what it deliberately does not.
|
||||
|
||||
## The test that decides it
|
||||
|
||||
A comment earns its place when it sits at a different level of detail than the line
|
||||
below it: lower, stating a precise fact the code implies but does not say, or
|
||||
higher, giving intent a reader would otherwise assemble from ten lines.
|
||||
Same-altitude is the definition of redundant.
|
||||
|
||||
- **Delete it.** Is any information lost? If not, it stays deleted.
|
||||
- **Could a name carry it instead?** A better identifier, an extracted function or
|
||||
a named constant beats a comment. Prefer the code change.
|
||||
|
||||
## What not to write
|
||||
|
||||
| Don't | Instead |
|
||||
| --- | --- |
|
||||
| `// Handle drag start` above `handleDragStart` | Nothing. The name already says it. |
|
||||
| `// ─── Types ───`, `// Helpers`, `// ====` | If a file needs internal signposting, split the file. |
|
||||
| `// Step 1:` narrating a function body | Extract functions. If the steps need labels they need names. |
|
||||
| `// No longer needed`, `// Previously this used X` | State why the code is as it is now, or nothing. |
|
||||
| Commented-out code | Delete it. Git remembers. |
|
||||
| `@param blob - The blob to download` | Omit the tag rather than pad it. |
|
||||
| Docs on a self-explanatory member | Nothing, unless there is a real constraint to state. |
|
||||
|
||||
Step numbering is fine where it labels a genuinely numbered thing, such as a wizard
|
||||
step or a step in a written test procedure. It is narration when it numbers the
|
||||
lines of one function.
|
||||
|
||||
## Comments at the end of a line
|
||||
|
||||
A trailing comment usually does a different job from one above the code: it decodes
|
||||
the line it sits on. Those are worth keeping, and the linter leaves them alone.
|
||||
|
||||
```java
|
||||
byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; // "%PDF"
|
||||
long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB
|
||||
double buffer = 0.10; // 10% headroom
|
||||
default -> toBytes(value, 2); // MB
|
||||
```
|
||||
|
||||
Each overlaps in words with the code and each adds the interpretation the code
|
||||
leaves implicit, which is the lower-altitude case the test above asks for. So
|
||||
`CMT001` does not judge trailing comments; on this codebase it would have been
|
||||
wrong about roughly six in seven of them.
|
||||
|
||||
What still applies is anything that does not depend on the code below: a trailing
|
||||
`// TODO fix this` is as unowned as one on its own line, and a trailing
|
||||
`// this used to run before the flush` narrates history wherever it sits.
|
||||
|
||||
A comment block over about 12 lines, outside a file or type header, is usually a
|
||||
sign the code needs restructuring. If it is genuinely product documentation, it
|
||||
belongs in the docs repo.
|
||||
|
||||
## TODOs
|
||||
|
||||
A TODO needs an issue, because an issue is the only part that will close it:
|
||||
|
||||
```java
|
||||
// TODO(#1234): re-enable the checkout gate once account syncing lands
|
||||
```
|
||||
|
||||
An owner is not a substitute: a username goes stale when someone changes team and
|
||||
means nothing to an outside contributor. If the work is not worth an issue it is
|
||||
not worth a TODO, and the options are to do it now or leave the code alone. A
|
||||
question is not a TODO.
|
||||
|
||||
## Per language
|
||||
|
||||
**Java.** Google Java Style, which this repo already formats to. Its §7.3.1
|
||||
exception applies: omit Javadoc on a self-explanatory member where there is
|
||||
genuinely nothing to add, but do not cite it to skip something a reader needs.
|
||||
Summary fragments are noun or verb phrases, not sentences starting "This method
|
||||
returns".
|
||||
|
||||
**TypeScript.** JSDoc on the `@app/*` seams, exported hooks, and anything crossing
|
||||
a layer boundary. No `@param`/`@returns` that restates a typed signature. JSX
|
||||
comments follow the same rules as any other.
|
||||
|
||||
**Python.** Docstrings on modules, public functions and Pydantic models where the
|
||||
contract is not obvious from the type.
|
||||
|
||||
## The linter
|
||||
|
||||
```bash
|
||||
task comment-lint # what the working tree adds over HEAD
|
||||
task comment-lint:branch # what the branch adds over origin/main (BASE=<ref> to change)
|
||||
task pre-commit:comment-lint:ci # the fixture corpus, then the diff
|
||||
```
|
||||
|
||||
`comment-lint` is the pre-commit question, so it reports nothing once you have
|
||||
committed; on a CI pull request it compares against the target branch via
|
||||
`GITHUB_BASE_REF`. `comment-lint:branch` is the review question. The corpus checks
|
||||
the rules themselves rather than the code under review, so it runs on CI and before
|
||||
a rule change, not on every local commit.
|
||||
|
||||
`task comment-lint` also runs inside `task pre-commit`, and as a Claude Code `Stop`
|
||||
hook, so an agent is told before it finishes a turn and fixes the comment inside
|
||||
that turn. Stop rather than per file write: a run costs the same for one file as for
|
||||
twenty-five, and half of all writes in a turn go to a file already written in it.
|
||||
|
||||
Findings are scoped to comment text that is new, not to lines git calls new, so
|
||||
reindenting or moving code does not resurface comments you did not write.
|
||||
|
||||
The rules are the `RULES` object in
|
||||
[`scripts/lint/comment-rules.mjs`](../scripts/lint/comment-rules.mjs); the exact
|
||||
condition for each is the predicate of the same name in that file, with the
|
||||
readings it deliberately excludes beside it.
|
||||
|
||||
**Every rule blocks.** A rule that only warns is a rule nobody acts on. So a
|
||||
finding you believe is wrong is a bug in the rule, not something to live with:
|
||||
narrow the rule, or mark the line and say why.
|
||||
|
||||
Every comment form the repo writes is covered: `//` and `/* */`, Javadoc and JSDoc,
|
||||
JSX comments, `#`, and Python docstrings. `CMT007` reads all three parameter
|
||||
conventions in use here, Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google
|
||||
`name: description` under `Args:`.
|
||||
|
||||
Two engines, one rule set. `.ts`/`.tsx`/`.mjs` go to an oxlint JS plugin, so
|
||||
comments come from the parser: a `//` inside a string is not a comment, and JSX
|
||||
`{/* … */}` is. `.java`/`.py` go to a line scanner. Neither reads the other's
|
||||
files, so they cannot disagree about one file. `scripts/lint/fixtures/` is the
|
||||
corpus that keeps them meaning the same thing.
|
||||
|
||||
### When a finding is wrong
|
||||
|
||||
Name the rule on the line above:
|
||||
|
||||
```ts
|
||||
// comment-lint-allow: CMT002
|
||||
// ─── kept deliberately, because <reason> ───
|
||||
```
|
||||
|
||||
There is no form that disables every rule, and the directive has to earn its
|
||||
place. `CMT008` reports one that names something which is not a rule, and one that
|
||||
silences nothing, so a typo does not read as a suppression and a stale
|
||||
suppression does not sit there blinding the line. The whole comment must be the
|
||||
directive; prose that mentions the syntax is just prose.
|
||||
|
||||
If you reach for this more than occasionally the rule is wrong: fix it in
|
||||
`comment-rules.mjs` and update the fixture corpus in the same commit, so the diff
|
||||
shows what moved.
|
||||
|
||||
### The existing backlog
|
||||
|
||||
`task pre-commit:comment-lint:all` reports the whole tree and never fails. There is
|
||||
a standing backlog being cleared by directory; diff scoping is what keeps it off
|
||||
whoever touches a file first.
|
||||
|
||||
To turn the editor hook off, put `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in
|
||||
`.claude/settings.local.json`. The commit-time gate still applies, so you lose the
|
||||
early warning rather than the check.
|
||||
@@ -8,6 +8,7 @@ This directory contains all development-related documentation for Stirling PDF.
|
||||
- **[DeveloperGuide.md](../DeveloperGuide.md)** - Main developer setup and architecture guide (in repo root)
|
||||
- **[Taskfile.yml](../Taskfile.yml)** - Unified task runner for all build/dev/test/lint commands
|
||||
- **[EXCEPTION_HANDLING_GUIDE.md](./EXCEPTION_HANDLING_GUIDE.md)** - Exception handling patterns and i18n best practices
|
||||
- **[CODE_COMMENTS.md](./CODE_COMMENTS.md)** - What a comment is for, what not to write, and the `task comment-lint` rules
|
||||
- **[HowToAddNewLanguage.md](./HowToAddNewLanguage.md)** - Internationalization and translation guide
|
||||
- **[STORAGE_ENCRYPTION_AT_REST.md](./STORAGE_ENCRYPTION_AT_REST.md)** - Encryption at rest for stored files: key setup, migration, revocation, rotation
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "oxlint";
|
||||
|
||||
// Comment-quality rules only, kept out of oxlint.config.ts on purpose.
|
||||
//
|
||||
// The main config is run with --max-warnings=0 over the whole frontend, and the
|
||||
// existing tree still has several hundred findings from these rules. Enabling
|
||||
// them there would fail every build until the cleanup lands. So they live here
|
||||
// and are driven by `task comment-lint`, which scopes findings to the lines a
|
||||
// branch actually added.
|
||||
//
|
||||
// Fold this into oxlint.config.ts once the tree is clean; that is the step that
|
||||
// also buys IDE squiggles, and the point at which this file goes away.
|
||||
|
||||
export default defineConfig({
|
||||
jsPlugins: ["../scripts/lint/comment-lint-oxlint-plugin.mjs"],
|
||||
categories: { correctness: "off" },
|
||||
ignorePatterns: [
|
||||
"dist",
|
||||
"dist-portal",
|
||||
"node_modules",
|
||||
"playwright-report",
|
||||
"storybook-static",
|
||||
"test-results",
|
||||
"editor/dist",
|
||||
"editor/public",
|
||||
"editor/src-tauri",
|
||||
],
|
||||
rules: {
|
||||
"comments/quality": "error",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// Claude Code Stop hook: check the comments this turn wrote, before it ends.
|
||||
//
|
||||
// Wired up by .claude/settings.json. Exit 2 stops Claude from finishing and shows
|
||||
// stderr to it, so the comment is fixed inside the same turn and never reaches a
|
||||
// diff, a CI run, or a reviewer.
|
||||
//
|
||||
// Stop rather than PostToolUse, measured over 605 real turns: a run costs the same
|
||||
// whether it looks at one file or twenty-five, because node startup and one git
|
||||
// diff dominate and the TS engine is spawned once for the batch. Per write it was
|
||||
// 40 minutes of hook latency across those turns, and up to 35 seconds inside a
|
||||
// single heavy one; per turn it is under a second, flat. Half of all writes were
|
||||
// to a file already written that turn, so most of that work was repeated.
|
||||
//
|
||||
// To turn it off, set COMMENT_LINT_HOOK=0. Claude Code has no way to disable one
|
||||
// hook (only disableAllHooks, which turns off everyone's), so the opt-out lives
|
||||
// here instead. Per developer, in .claude/settings.local.json:
|
||||
//
|
||||
// { "env": { "COMMENT_LINT_HOOK": "0" } }
|
||||
//
|
||||
// The commit-time gate still applies either way, so opting out costs you the
|
||||
// early warning, not the check.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = resolve(HERE, "..", "..");
|
||||
|
||||
// Invoked through Task so the taskfile stays the one place that defines how the
|
||||
// linter is called.
|
||||
const TASK_NAME = "pre-commit:comment-lint:hook";
|
||||
const OFF = new Set(["0", "off", "false", "no"]);
|
||||
|
||||
// The linter's own exit codes: 1 when it found something, 2 when its engine could
|
||||
// not run. The hook's codes mean different things, so they are mapped explicitly.
|
||||
const FOUND = 1;
|
||||
const ENGINE_BROKEN = 2;
|
||||
|
||||
if (OFF.has((process.env.COMMENT_LINT_HOOK ?? "").toLowerCase())) process.exit(0);
|
||||
|
||||
const payload = readStdin();
|
||||
|
||||
// Blocking the stop puts Claude back to work, which ends in another stop and
|
||||
// another chance to block. Claude Code sets this flag once a Stop hook has
|
||||
// already blocked in this turn, so a rule the agent cannot satisfy costs one
|
||||
// extra attempt rather than looping. The commit gate still catches whatever
|
||||
// survives.
|
||||
if (payload?.stop_hook_active) process.exit(0);
|
||||
|
||||
const result = run();
|
||||
if (result.status === 0) process.exit(0);
|
||||
|
||||
if (result.status === ENGINE_BROKEN) {
|
||||
process.stderr.write("comment-lint could not run, so comments in this turn were not checked.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Anything else is Task itself failing, which means the check did not happen.
|
||||
if (result.status !== FOUND) {
|
||||
process.stderr.write(`comment-lint did not run (task exit ${result.status}), so comments in this turn were not checked.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// The linter's own report already names the file, line, rule and the standard, so
|
||||
// it is passed through rather than rewritten.
|
||||
process.stderr.write(`${result.output.trim()}\n\nFix these before finishing.\n`);
|
||||
process.exit(2);
|
||||
|
||||
function readStdin() {
|
||||
try {
|
||||
return JSON.parse(readFileSync(0, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// `task` on PATH is a shell wrapper that boots Node to launch the Go binary the
|
||||
// npm package already ships, which costs about 400ms. Prefer the binary; fall
|
||||
// back to the wrapper when the layout is not one of the ones probed, or when Task
|
||||
// came from somewhere else entirely.
|
||||
function taskCommand() {
|
||||
const nodeDir = dirname(process.execPath);
|
||||
const exe = process.platform === "win32" ? "task.exe" : "task";
|
||||
const candidates = [
|
||||
resolve(nodeDir, "node_modules/@go-task/cli/bin", exe),
|
||||
resolve(nodeDir, "../lib/node_modules/@go-task/cli/bin", exe),
|
||||
resolve(REPO, "node_modules/@go-task/cli/bin", exe),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return { command: candidate, shell: false };
|
||||
}
|
||||
return { command: process.platform === "win32" ? "task.cmd" : "task", shell: process.platform === "win32" };
|
||||
}
|
||||
|
||||
function run() {
|
||||
const { command, shell } = taskCommand();
|
||||
try {
|
||||
// --output=interleaved because the root Taskfile sets `output: prefixed`,
|
||||
// which would put the task name in front of every reported finding.
|
||||
// --exit-code because Task otherwise reports its own 201 for any failed task,
|
||||
// which hides whether the linter found something or could not run.
|
||||
// Task's stderr is captured rather than inherited: it announces its own
|
||||
// "Failed to run task" for any non-zero command, which would reach Claude
|
||||
// alongside the findings and read as a tooling error.
|
||||
const output = execFileSync(command, [TASK_NAME, "--silent", "--output=interleaved", "--exit-code"], {
|
||||
cwd: REPO,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1 << 26,
|
||||
shell,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
return { status: 0, output };
|
||||
} catch (error) {
|
||||
return { status: error.status ?? -1, output: error.stdout ?? "" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// oxlint JS plugin: the comment-quality rules for .ts and .tsx.
|
||||
//
|
||||
// This engine owns the frontend outright; comment-lint.mjs never scans TS. The
|
||||
// reason to run oxlint here rather than scan lines is that comment tokens come
|
||||
// from the parser, so a `//` inside a string or regex is not a comment, JSX
|
||||
// `{/* … */}` is, and positions are exact. Rule decisions themselves live in
|
||||
// comment-rules.mjs, shared with the Java/Python engine.
|
||||
//
|
||||
// Reported as one rule with the CMT id in the message, because oxlint config
|
||||
// severity is per rule name and every finding here shares one on/off switch.
|
||||
//
|
||||
// Enabled by frontend/oxlint.comments.config.ts. `context.report` needs
|
||||
// `node.range`; passing start/end throws.
|
||||
|
||||
import { analyse, isTestPath, isGenerated, isExcludedPath, ruleLabel } from "./comment-rules.mjs";
|
||||
|
||||
const comments = {
|
||||
create(context) {
|
||||
return {
|
||||
"Program:exit"() {
|
||||
const sourceCode = context.sourceCode;
|
||||
const filename = context.filename ?? context.getFilename?.() ?? "";
|
||||
if (isExcludedPath(filename)) return;
|
||||
|
||||
const text = sourceCode.text;
|
||||
if (isGenerated(text)) return;
|
||||
|
||||
const lines = sourceCode.getLines();
|
||||
const tokens = sourceCode.getAllComments();
|
||||
if (tokens.length === 0) return;
|
||||
|
||||
const runs = groupIntoRuns(tokens, lines);
|
||||
const findings = analyse({ lines, runs, isTestFile: isTestPath(filename) });
|
||||
|
||||
// Ranges come from the run entries rather than the enclosing token, so a
|
||||
// finding on the eighth line of a doc block points at that line instead
|
||||
// of at the opening `/**`.
|
||||
const ranges = new Map();
|
||||
for (const run of runs) {
|
||||
for (const entry of run.lines) ranges.set(entry.line, entry.range);
|
||||
}
|
||||
|
||||
for (const finding of findings) {
|
||||
context.report({
|
||||
message: `${ruleLabel(finding.rule)}: ${finding.detail}`,
|
||||
node: { type: "Line", range: ranges.get(finding.line) ?? [0, 1] },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Adjacent comment lines with no code between them form one run, which is the
|
||||
// unit the block-length and dead-code rules judge. A comment sharing its line
|
||||
// with code is a trailing note, not part of any run.
|
||||
function groupIntoRuns(tokens, lines) {
|
||||
const runs = [];
|
||||
let current = null;
|
||||
|
||||
for (const token of tokens) {
|
||||
const entries = expand(token, lines);
|
||||
if (entries.length === 0) continue;
|
||||
|
||||
const kind = token.type === "Line" ? "line" : token.value.startsWith("*") ? "doc" : "block";
|
||||
const startLine = entries[0].line;
|
||||
const trailing = entries[0].trailing === true;
|
||||
const contiguous = !trailing && current && startLine === current.endLine + 1 && current.kind === kind && !current.trailing;
|
||||
|
||||
if (contiguous) {
|
||||
current.lines.push(...entries);
|
||||
current.endLine = entries[entries.length - 1].line;
|
||||
continue;
|
||||
}
|
||||
current = { startLine, endLine: entries[entries.length - 1].line, kind, trailing, lines: entries };
|
||||
runs.push(current);
|
||||
|
||||
// Code sits in front of a trailing comment, so nothing can continue it.
|
||||
if (trailing) current = null;
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
// One entry per physical line, with the leading `*` of a doc block stripped so
|
||||
// the rules see the prose rather than the box drawing around it. Each entry
|
||||
// carries its own source range so findings can be reported where they are.
|
||||
function expand(token, lines) {
|
||||
const start = token.loc.start.line;
|
||||
const column = token.loc.start.column + 1;
|
||||
const before = (lines[start - 1] ?? "").slice(0, token.loc.start.column).trim();
|
||||
|
||||
// Code in front of the comment makes it a trailing note. Marked rather than
|
||||
// dropped, so CMT004 and CMT009 still see it: a TODO is a TODO wherever it
|
||||
// sits. The rules that compare a comment against the code below it stay out,
|
||||
// because a trailing comment usually decodes the line it sits on.
|
||||
//
|
||||
// A block comment counts as trailing only when it also closes on that line.
|
||||
// One that runs on has its bulk on lines of its own, so it is judged as the
|
||||
// block it is.
|
||||
const sameLine = token.loc.start.line === token.loc.end.line;
|
||||
const trailing = before.length > 0 && !before.startsWith("{") && (token.type === "Line" || sameLine);
|
||||
|
||||
if (token.type === "Line") {
|
||||
return [{ line: start, column, body: token.value, range: token.range, trailing }];
|
||||
}
|
||||
|
||||
// token.value is the text between the delimiters, so it begins two chars in.
|
||||
let offset = token.range[0] + 2;
|
||||
return token.value.split("\n").map((raw, index) => {
|
||||
const range = [offset, offset + Math.max(raw.length, 1)];
|
||||
offset += raw.length + 1;
|
||||
return {
|
||||
line: start + index,
|
||||
column: index === 0 ? column : 1,
|
||||
body: raw.replace(/^\s*\*+/, "").trim(),
|
||||
range,
|
||||
trailing,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: "comments" },
|
||||
rules: { quality: comments },
|
||||
};
|
||||
@@ -0,0 +1,743 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// comment-lint - the comment-quality gate. Standard: devGuide/CODE_COMMENTS.md
|
||||
//
|
||||
// Owns .java and engine .py directly, and delegates .ts/.tsx to oxlint (see
|
||||
// comment-lint-oxlint-plugin.mjs) so the frontend is judged against real comment
|
||||
// tokens rather than lines. Both paths share the rules in comment-rules.mjs, so
|
||||
// a finding means the same thing whichever engine produced it.
|
||||
//
|
||||
// node scripts/lint/comment-lint.mjs default: everything this
|
||||
// working tree adds over HEAD,
|
||||
// or over the target branch on CI
|
||||
// node scripts/lint/comment-lint.mjs --since main findings on lines this branch added
|
||||
// node scripts/lint/comment-lint.mjs --all whole tree, report only, never fails
|
||||
// node scripts/lint/comment-lint.mjs <paths...> those files, every line
|
||||
// node scripts/lint/comment-lint.mjs --selftest run the fixture corpus
|
||||
// --quiet ...saying nothing unless it fails
|
||||
// node scripts/lint/comment-lint.mjs --json machine-readable findings
|
||||
//
|
||||
// Exits non-zero for any finding on a line in scope: every rule blocks, because a
|
||||
// warning is a finding nobody acts on. --all never fails, because the tree still
|
||||
// has a backlog; it is the mode for working through it.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
analyse,
|
||||
commentBodiesOf,
|
||||
normaliseComment,
|
||||
isExcludedPath,
|
||||
isGenerated,
|
||||
isTestPath,
|
||||
ruleLabel,
|
||||
RULES,
|
||||
} from "./comment-rules.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = resolve(HERE, "..", "..");
|
||||
const FRONTEND = join(REPO, "frontend");
|
||||
|
||||
// The Python this repo owns and formats: the engine service, plus the helper
|
||||
// scripts that pre-commit already runs ruff over. Vendored and sample .py
|
||||
// elsewhere in the tree is not ours to restyle.
|
||||
const JAVA = /\.java$/;
|
||||
const PYTHON = /^(engine|scripts|\.github\/scripts)\/.*\.py$/;
|
||||
|
||||
// The oxlint engine parses plain JS as happily as TS, so the lint scripts and
|
||||
// build tooling are held to the same rules as the app.
|
||||
const TYPESCRIPT = /\.(tsx?|mts|cts|mjs|cjs|jsx?)$/;
|
||||
|
||||
const FIXTURES_REL = "scripts/lint/fixtures/";
|
||||
|
||||
// oxlint rejects any path containing "..", so it is always run from the repo
|
||||
// root and given repo-relative paths. Its config still lives under frontend/,
|
||||
// which is what makes `oxlint` and the plugin resolvable from there.
|
||||
const OXLINT_BIN = "frontend/node_modules/oxlint/bin/oxlint";
|
||||
const OXLINT_CONFIG = "frontend/oxlint.comments.config.ts";
|
||||
|
||||
// Windows caps a command line near 32k characters, which a whole-tree file list
|
||||
// exceeds by a wide margin. Unbatched it dies with ENAMETOOLONG, and silently:
|
||||
// oxlint exits non-zero normally, so the error reads as "no findings".
|
||||
const ARGV_BUDGET = 24_000;
|
||||
|
||||
// Every module constant lives in this block. The top-level run below starts
|
||||
// before any function body is reached, so a `const` declared further down is
|
||||
// still in its temporal dead zone when the first call touches it.
|
||||
const baseComments = new Map();
|
||||
|
||||
// A Java char literal, which is the only thing a single quote can legitimately
|
||||
// open: one character, or one escape. An apostrophe in prose never matches, so
|
||||
// `/** The approver's team ... */` keeps its closing delimiter. Without this the
|
||||
// apostrophe opened a literal that never closed, the `*/` was blanked away, and
|
||||
// the scanner read the next 47 lines of code as one comment.
|
||||
const CHAR_LITERAL = /^'(\\[btnfr'"\\0]|\\u[0-9a-fA-F]{4}|[^'\\])'/;
|
||||
|
||||
// A docstring opens the line, optionally behind a string prefix. Anything with
|
||||
// code in front of the quotes is a value, not documentation.
|
||||
const DOCSTRING_OPEN = /^[rbuf]{0,2}("""|''')/;
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const flags = new Set(argv.filter((a) => a.startsWith("--")));
|
||||
const positional = argv.filter((a) => !a.startsWith("--") && !isFlagValue(a));
|
||||
|
||||
if (flags.has("--selftest")) process.exit(runSelfTest());
|
||||
if (flags.has("--help")) {
|
||||
process.stdout.write(
|
||||
readFileSync(fileURLToPath(import.meta.url), "utf8")
|
||||
.split("\n")
|
||||
.slice(1, 22)
|
||||
.join("\n")
|
||||
.replace(/^\/\/ ?/gm, "") + "\n",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const scope = resolveScope();
|
||||
const findings = collect(scope);
|
||||
process.exit(publish(findings, scope));
|
||||
|
||||
// A "scope" is the set of files to look at plus, when the run is diff-based, the
|
||||
// set of lines that are new. Reporting a legacy finding in a file someone merely
|
||||
// touched is how a gate like this gets switched off, so diff runs filter by line.
|
||||
|
||||
function resolveScope() {
|
||||
if (flags.has("--all")) return { mode: "all", files: trackedFiles(), added: null };
|
||||
|
||||
const paths = positional.map(toRepoPath);
|
||||
|
||||
// Paths plus --since is how the editor hook asks about one file: lint it, but
|
||||
// only the lines this session actually wrote.
|
||||
if (paths.length > 0 && flags.has("--since")) {
|
||||
const ref = mergeBase(flagValue("--since"));
|
||||
return narrow(diffScope(["diff", "--unified=0", "--no-color", ref, "--", ...paths], ref, paths), paths);
|
||||
}
|
||||
if (paths.length > 0) return { mode: "paths", files: paths, added: null };
|
||||
|
||||
if (flags.has("--since")) {
|
||||
const ref = mergeBase(flagValue("--since"));
|
||||
return diffScope(["diff", "--unified=0", "--no-color", ref], ref);
|
||||
}
|
||||
|
||||
// Always a working-tree comparison, never `--cached`. Findings are read from
|
||||
// the file on disk, so diffing the index instead would pair index line numbers
|
||||
// with working-tree content and silently mismatch once the two differ.
|
||||
// CI knows the target branch; a developer running this before a commit does not.
|
||||
const base = process.env.GITHUB_BASE_REF;
|
||||
const ref = mergeBase(base ? `origin/${base}` : "HEAD");
|
||||
return diffScope(["diff", "--unified=0", "--no-color", ref], ref);
|
||||
}
|
||||
|
||||
function mergeBase(ref) {
|
||||
try {
|
||||
return git(["merge-base", "HEAD", ref]).trim();
|
||||
} catch {
|
||||
// A shallow clone or a missing remote ref: compare against the ref itself.
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
|
||||
function diffScope(args, base, paths = null) {
|
||||
let diff;
|
||||
try {
|
||||
diff = git(args);
|
||||
} catch (error) {
|
||||
// A shallow clone, a detached CI checkout, or a base branch that was never
|
||||
// fetched. Degrading to report-only beats failing a build over plumbing.
|
||||
warn(`could not resolve a diff (${firstLine(error.stderr ?? error.message)}), so nothing was checked.`);
|
||||
return { mode: "all", files: [], added: null };
|
||||
}
|
||||
|
||||
const added = new Map();
|
||||
let file = null;
|
||||
for (const line of diff.split("\n")) {
|
||||
if (line.startsWith("+++ ")) {
|
||||
const path = line.slice(4).replace(/^b\//, "").trim();
|
||||
file = path === "/dev/null" ? null : path;
|
||||
if (file) added.set(file, new Set());
|
||||
continue;
|
||||
}
|
||||
if (!file || !line.startsWith("@@")) continue;
|
||||
const hunk = /\+(\d+)(?:,(\d+))?/.exec(line);
|
||||
if (!hunk) continue;
|
||||
const start = Number(hunk[1]);
|
||||
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
||||
for (let i = 0; i < count; i++) added.get(file).add(start + i);
|
||||
}
|
||||
|
||||
// git diff never mentions an untracked file, so a brand new one would be waved
|
||||
// through entirely. Every line of one is new. Listing every untracked file in
|
||||
// the repo costs about as much as the diff, so when the caller already named the
|
||||
// paths, only those are asked about.
|
||||
for (const file of untrackedFiles(paths)) {
|
||||
if (added.has(file)) continue;
|
||||
added.set(file, allLinesOf(file));
|
||||
}
|
||||
|
||||
return { mode: "diff", files: [...added.keys()], added, base };
|
||||
}
|
||||
|
||||
function untrackedFiles(paths = null) {
|
||||
const args = ["ls-files", "--others", "--exclude-standard"];
|
||||
if (paths) args.push("--", ...paths);
|
||||
return git(args).split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
function allLinesOf(file) {
|
||||
const path = insideRepo(file);
|
||||
if (!path || !existsSync(path)) return new Set();
|
||||
const total = readFileSync(path, "utf8").split(/\r?\n/).length;
|
||||
return new Set(Array.from({ length: total }, (_, i) => i + 1));
|
||||
}
|
||||
|
||||
function narrow(scope, paths) {
|
||||
// A diff that could not be resolved already returned a degraded scope with no
|
||||
// added map. Pass it straight through: an empty map here would read as a clean
|
||||
// pass rather than as a run that checked nothing.
|
||||
if (!scope.added) return scope;
|
||||
|
||||
const wanted = new Set(paths);
|
||||
const added = new Map([...scope.added].filter(([file]) => wanted.has(file)));
|
||||
return { mode: "diff", files: [...added.keys()], added, base: scope.base };
|
||||
}
|
||||
|
||||
function trackedFiles() {
|
||||
return git(["ls-files"]).split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
function toRepoPath(path) {
|
||||
return relative(REPO, resolve(process.cwd(), path)).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function collect(scope) {
|
||||
const selected = scope.files.map((f) => f.replace(/\\/g, "/")).filter(isLintable);
|
||||
|
||||
// A path named on the command line and then dropped has to be said out loud.
|
||||
// Reporting "clean" for a file this never opened is the failure mode the rest
|
||||
// of this script works to avoid.
|
||||
if (scope.mode === "paths") {
|
||||
for (const file of scope.files) {
|
||||
if (!selected.includes(file.replace(/\\/g, "/"))) warn(`skipped ${file}: not a lintable file inside the repo.`);
|
||||
}
|
||||
}
|
||||
const results = [];
|
||||
|
||||
for (const file of selected.filter((f) => JAVA.test(f) || PYTHON.test(f))) {
|
||||
results.push(...lintLineBased(file));
|
||||
}
|
||||
|
||||
const ts = selected.filter((f) => TYPESCRIPT.test(f));
|
||||
if (ts.length > 0) results.push(...lintTypeScript(ts));
|
||||
|
||||
if (scope.added) {
|
||||
const onAddedLine = results.filter((r) => scope.added.get(r.file)?.has(r.line));
|
||||
return onAddedLine.filter((r) => !existedAtBase(r, scope.base));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// git marks a reindented or moved line as added, so line membership alone reports
|
||||
// comments nobody wrote. A finding only counts if its comment text is not already
|
||||
// in the file at the base.
|
||||
//
|
||||
// Cost is one `git show` per file, memoised. It gets one case wrong: adding a
|
||||
// further copy of an already-duplicated comment reads as pre-existing. That is the
|
||||
// right way round for a blocking rule.
|
||||
|
||||
function existedAtBase(finding, base) {
|
||||
if (!base) return false;
|
||||
// Findings from the oxlint plugin arrive without their comment text, because
|
||||
// they cross a process boundary as a message string. Recover it from the file
|
||||
// on disk at the reported line, which is the same text the rule judged.
|
||||
const body = finding.body ?? currentLineBody(finding);
|
||||
if (!body) return false;
|
||||
const key = `${base}:${finding.file}`;
|
||||
if (!baseComments.has(key)) {
|
||||
let source = "";
|
||||
try {
|
||||
source = git(["show", key]);
|
||||
} catch {
|
||||
// Not in the base at all, so the whole file is new.
|
||||
}
|
||||
baseComments.set(key, commentBodiesOf(source));
|
||||
}
|
||||
return baseComments.get(key).has(body);
|
||||
}
|
||||
|
||||
function currentLineBody(finding) {
|
||||
try {
|
||||
const line = readFileSync(insideRepo(finding.file), "utf8").split(/\r?\n/)[finding.line - 1];
|
||||
return line === undefined ? "" : normaliseComment(line);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Everything this tool reads is named by git or by a developer on the command
|
||||
// line, so a path outside the repo is a mistake rather than an attack. Resolving
|
||||
// through here keeps the contract true: git show and git diff cannot answer for a
|
||||
// path outside the work tree, so escaping it only produces confusing output.
|
||||
function insideRepo(file) {
|
||||
const target = resolve(REPO, file);
|
||||
const rel = relative(REPO, target);
|
||||
if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return "";
|
||||
return target;
|
||||
}
|
||||
|
||||
function isLintable(file) {
|
||||
if (!insideRepo(file)) return false;
|
||||
if (isExcludedPath(file)) return false;
|
||||
|
||||
// The corpus is deliberately full of findings. Only the selftest reads it,
|
||||
// and it does so by path rather than through this filter.
|
||||
if (file.startsWith(FIXTURES_REL)) return false;
|
||||
if (!JAVA.test(file) && !PYTHON.test(file) && !TYPESCRIPT.test(file)) return false;
|
||||
return existsSync(insideRepo(file));
|
||||
}
|
||||
|
||||
function lintLineBased(file) {
|
||||
const source = readFileSync(insideRepo(file), "utf8");
|
||||
if (isGenerated(source)) return [];
|
||||
const lines = source.split(/\r?\n/);
|
||||
const runs = readRuns(lines, PYTHON.test(file) ? "py" : "java");
|
||||
return analyse({ lines, runs, isTestFile: isTestPath(file) }).map((f) => ({ ...f, file }));
|
||||
}
|
||||
|
||||
// Groups comment lines into runs, the same shape the oxlint plugin builds from
|
||||
// parser tokens. String literals are blanked first so a `//` inside one is not
|
||||
// mistaken for a comment; without that, every URL in a string became a finding.
|
||||
function readRuns(lines, language) {
|
||||
const runs = [];
|
||||
let current = null;
|
||||
let inBlock = false;
|
||||
let docstring = null;
|
||||
|
||||
const push = (index, column, body, kind, trailing = false) => {
|
||||
const line = index + 1;
|
||||
if (!trailing && current && current.endLine === line - 1 && current.kind === kind && !current.trailing) {
|
||||
current.lines.push({ line, column, body });
|
||||
current.endLine = line;
|
||||
return;
|
||||
}
|
||||
current = { startLine: line, endLine: line, kind, trailing, lines: [{ line, column, body }] };
|
||||
runs.push(current);
|
||||
if (trailing) current = null;
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i];
|
||||
const text = blankStrings(raw, language);
|
||||
const trimmed = text.trim();
|
||||
const column = raw.length - raw.trimStart().length + 1;
|
||||
|
||||
if (inBlock) {
|
||||
push(i, column, stripDocPrefix(raw), "doc");
|
||||
if (trimmed.includes("*/")) inBlock = false;
|
||||
continue;
|
||||
}
|
||||
if (trimmed.length === 0) {
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (language === "py") {
|
||||
// Every triple-quoted string is tracked, not just the documenting ones.
|
||||
// A template assigned to a constant opens mid-line and so is not
|
||||
// documentation, but its closing delimiter sits alone on a line and reads
|
||||
// exactly like an opener. Ignoring those strings desynchronised the
|
||||
// scanner for the rest of the file, and 35 lines of ordinary code were
|
||||
// reported as commented-out.
|
||||
if (docstring) {
|
||||
if (docstring.isDoc) push(i, column, stripDocstringDelimiters(raw), "doc");
|
||||
else current = null;
|
||||
if (raw.includes(docstring.delimiter)) docstring = null;
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
push(i, column, raw.trim().replace(/^#+/, ""), "line");
|
||||
continue;
|
||||
}
|
||||
const hashAt = raw.indexOf("#");
|
||||
if (hashAt > 0 && !/["']/.test(raw.slice(0, hashAt))) {
|
||||
const body = raw.slice(hashAt + 1).trim();
|
||||
if (body.length > 0) {
|
||||
push(i, hashAt + 1, body, "line", true);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const quoted = tripleQuoted(trimmed);
|
||||
if (quoted) {
|
||||
if (quoted.isDoc) push(i, column, stripDocstringDelimiters(raw), "doc");
|
||||
else current = null;
|
||||
if (!quoted.closes) docstring = quoted;
|
||||
continue;
|
||||
}
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("/*")) {
|
||||
push(i, column, stripDocPrefix(raw), trimmed.startsWith("/**") ? "doc" : "block");
|
||||
if (!trimmed.includes("*/")) inBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("//")) {
|
||||
push(i, column, raw.trim().replace(/^\/\/+/, ""), "line");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code first, then a comment. blankStrings has already neutralised any `//`
|
||||
// inside a string literal, so this index is a real comment marker. A block
|
||||
// comment counts here only when it also closes on this line, matching the
|
||||
// oxlint engine: one that runs on has its bulk on lines of its own.
|
||||
const trailingLine = text.indexOf("//");
|
||||
const trailingBlock = text.indexOf("/*");
|
||||
const at =
|
||||
trailingLine > 0 ? trailingLine : trailingBlock > 0 && text.includes("*/", trailingBlock) ? trailingBlock : -1;
|
||||
if (at > 0) {
|
||||
const body = raw
|
||||
.slice(at + 2)
|
||||
.replace(/\*\/.*$/, "")
|
||||
.trim();
|
||||
if (body.length > 0) {
|
||||
push(i, at + 1, body, "line", true);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
current = null;
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
// The prose inside a docstring line, with the triple quotes and any string
|
||||
// prefix taken off so the rules see what a reader sees.
|
||||
// Where a triple-quoted string starts on this line, and whether it counts as
|
||||
// documentation. It documents when the quotes open the line, allowing a string
|
||||
// prefix; a template assigned to a constant opens mid-line and is data, and
|
||||
// reading JSON as prose would judge its keys as comments. An odd number of
|
||||
// delimiters means the string continues onto the next line.
|
||||
function tripleQuoted(trimmed) {
|
||||
const found = /("""|''')/.exec(trimmed);
|
||||
if (!found) return null;
|
||||
const delimiter = found[1];
|
||||
const occurrences = trimmed.split(delimiter).length - 1;
|
||||
return {
|
||||
delimiter,
|
||||
isDoc: DOCSTRING_OPEN.test(trimmed),
|
||||
closes: occurrences % 2 === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function stripDocstringDelimiters(raw) {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^[rbuf]{0,2}("""|''')/, "")
|
||||
.replace(/("""|''')\s*$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripDocPrefix(raw) {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^\/\*+/, "")
|
||||
.replace(/\*+\/$/, "")
|
||||
.replace(/^\*+/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Replaces the contents of string and char literals with spaces, preserving
|
||||
// length so columns stay correct. Escapes are honoured so "\"" does not end it.
|
||||
function blankStrings(line, language) {
|
||||
if (language === "py") return line;
|
||||
let out = "";
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") {
|
||||
out += " ";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
out += ch === quote ? ch : " ";
|
||||
if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
quote = ch;
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
const literal = CHAR_LITERAL.exec(line.slice(i));
|
||||
if (!literal) {
|
||||
// Prose, not a literal. Leave it alone.
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
out += `'${" ".repeat(literal[0].length - 2)}'`;
|
||||
i += literal[0].length - 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "/" && line[i + 1] === "/") return out + line.slice(i);
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lintTypeScript(files) {
|
||||
if (!existsSync(join(REPO, OXLINT_BIN))) {
|
||||
warn("frontend/node_modules/oxlint is missing, so TS/TSX was skipped. Run `task frontend:install`.");
|
||||
return [];
|
||||
}
|
||||
return batch(files, ARGV_BUDGET).flatMap(runOxlint);
|
||||
}
|
||||
|
||||
function batch(files, budget) {
|
||||
const batches = [];
|
||||
let current = [];
|
||||
let size = 0;
|
||||
for (const file of files) {
|
||||
if (current.length > 0 && size + file.length + 1 > budget) {
|
||||
batches.push(current);
|
||||
current = [];
|
||||
size = 0;
|
||||
}
|
||||
current.push(file);
|
||||
size += file.length + 1;
|
||||
}
|
||||
if (current.length > 0) batches.push(current);
|
||||
return batches;
|
||||
}
|
||||
|
||||
function runOxlint(files) {
|
||||
let stdout;
|
||||
try {
|
||||
// Invoked as `node <bin>` rather than through npx: spawning a .cmd shim on
|
||||
// Windows fails with EINVAL unless a shell is used, and a shell would mean
|
||||
// quoting every path. It must also be the npm package rather than the
|
||||
// standalone release binary, which accepts a jsPlugins config, skips loading
|
||||
// it, and still reports success (oxc-project/oxc#25203).
|
||||
stdout = execFileSync(process.execPath, [OXLINT_BIN, "--config", OXLINT_CONFIG, "--format=json", ...files], {
|
||||
cwd: REPO,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1 << 28,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
// oxlint exits non-zero whenever it reports something, which is the normal case.
|
||||
stdout = error.stdout ?? "";
|
||||
if (!stdout.trim()) {
|
||||
die(`oxlint failed on ${files.length} file(s): ${firstLine(error.stderr ?? error.message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parseOxlint(stdout);
|
||||
}
|
||||
|
||||
function firstLine(value) {
|
||||
return value.toString().trim().split(/\r?\n/)[0];
|
||||
}
|
||||
|
||||
function parseOxlint(stdout) {
|
||||
const start = stdout.indexOf("{");
|
||||
if (start < 0) die("oxlint produced no JSON report.");
|
||||
let report;
|
||||
try {
|
||||
report = JSON.parse(stdout.slice(start));
|
||||
} catch {
|
||||
die("could not parse oxlint JSON output.");
|
||||
}
|
||||
|
||||
// JS plugins are alpha, and their documented failure mode is being skipped
|
||||
// silently while oxlint still reports success (oxc-project/oxc#25203).
|
||||
// number_of_rules is the report saying whether the plugin's rule was actually
|
||||
// registered. Without this check a dead plugin reads exactly like clean code.
|
||||
if ((report.number_of_rules ?? 0) < 1) {
|
||||
die("oxlint loaded no rules, so the comment plugin did not run. Refusing to report a pass.");
|
||||
}
|
||||
|
||||
return (report.diagnostics ?? []).flatMap((d) => {
|
||||
const parsed = /^(CMT\d{3})\s+(\S+):\s*(.*)$/.exec(d.message);
|
||||
if (!parsed) return [];
|
||||
const [, rule, , detail] = parsed;
|
||||
const span = d.labels?.[0]?.span;
|
||||
return [
|
||||
{
|
||||
file: d.filename.replace(/\\/g, "/"),
|
||||
line: span?.line ?? 1,
|
||||
column: span?.column ?? 1,
|
||||
rule,
|
||||
detail,
|
||||
severity: RULES[rule].severity,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function publish(findings, scope) {
|
||||
if (flags.has("--json")) {
|
||||
process.stdout.write(`${JSON.stringify({ mode: scope.mode, findings }, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (findings.length === 0) {
|
||||
process.stdout.write(`comment-lint: clean (${scope.files.length} file${scope.files.length === 1 ? "" : "s"} in scope)\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const byFile = new Map();
|
||||
for (const f of findings) {
|
||||
if (!byFile.has(f.file)) byFile.set(f.file, []);
|
||||
byFile.get(f.file).push(f);
|
||||
}
|
||||
|
||||
for (const [file, group] of [...byFile.entries()].sort()) {
|
||||
process.stdout.write(`\n${file}\n`);
|
||||
for (const f of group.sort((a, b) => a.line - b.line)) {
|
||||
process.stdout.write(` ${String(f.line).padStart(5)} ${ruleLabel(f.rule)} ${f.detail}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`\ncomment-lint: ${findings.length} finding${findings.length === 1 ? "" : "s"} across ${byFile.size} file${byFile.size === 1 ? "" : "s"}\n`,
|
||||
);
|
||||
|
||||
if (scope.mode === "all") {
|
||||
process.stdout.write("Report-only mode: --all never fails, so the standing backlog can be worked through in chunks.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
"\nThe standard is devGuide/CODE_COMMENTS.md. A comment must carry information the\n" +
|
||||
"code cannot; if a reader could derive it from the code in front of them, delete it.\n" +
|
||||
"If a finding is genuinely wrong, put `comment-lint-allow: CMT00X` on the line above.\n",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
function warn(message) {
|
||||
process.stderr.write(`comment-lint: ${message}\n`);
|
||||
}
|
||||
|
||||
// A gate that cannot run must not report a pass. Reserved for the engine being
|
||||
// broken, as opposed to absent: a missing oxlint install is handled by skipping
|
||||
// with a warning, so the hook stays usable before `task frontend:install`.
|
||||
function die(message) {
|
||||
process.stderr.write(`comment-lint: ${message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// The fixture corpus is the contract between the two engines: the same rule set
|
||||
// applied to .java/.py by the line scanner and to .ts/.tsx by oxlint, with
|
||||
// fixtures/expected.json asserting what each file should produce.
|
||||
//
|
||||
// Expectations live outside the fixtures on purpose. An in-file marker would sit
|
||||
// inside the very comment under test, changing its word count and its run
|
||||
// length, so the fixture would stop being an example of the real thing.
|
||||
//
|
||||
// --selftest compare against expected.json
|
||||
// --selftest --update rewrite expected.json from current behaviour
|
||||
//
|
||||
// A rule change is meant to show up as a reviewable diff in expected.json.
|
||||
|
||||
function runSelfTest() {
|
||||
const dir = join(HERE, "fixtures");
|
||||
const expectedPath = join(dir, "expected.json");
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => /\.(java|py|ts|tsx)$/.test(f))
|
||||
.sort();
|
||||
if (files.length === 0) {
|
||||
warn("no fixtures found");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const asRepoPath = (name) => relative(REPO, join(dir, name)).replace(/\\/g, "/");
|
||||
const tsFixtures = files.filter((f) => TYPESCRIPT.test(f));
|
||||
const tsFindings = tsFixtures.length > 0 ? lintTypeScript(tsFixtures.map(asRepoPath)) : [];
|
||||
|
||||
// A skipped engine looks exactly like a clean engine in the snapshot, so
|
||||
// refuse to record or compare rather than baking in a false pass.
|
||||
if (tsFixtures.length > 0 && tsFindings.length === 0) {
|
||||
warn("the TS engine produced nothing, so it did not run. Install frontend deps first.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const actual = {};
|
||||
for (const name of files) {
|
||||
const found = TYPESCRIPT.test(name)
|
||||
? tsFindings.filter((f) => f.file.endsWith(`/${name}`))
|
||||
: lintLineBased(asRepoPath(name));
|
||||
actual[name] = found
|
||||
.map((f) => `${f.line}:${f.rule}:${f.severity}`)
|
||||
.sort((a, b) => Number(a.split(":")[0]) - Number(b.split(":")[0]));
|
||||
}
|
||||
|
||||
if (flags.has("--update")) {
|
||||
writeFileSync(expectedPath, `${JSON.stringify(actual, null, 2)}\n`);
|
||||
process.stdout.write(
|
||||
`comment-lint selftest: recorded ${Object.keys(actual).length} fixtures to ${relative(REPO, expectedPath)}\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!existsSync(expectedPath)) {
|
||||
warn("fixtures/expected.json is missing. Run --selftest --update to record it.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --quiet says nothing unless something is wrong. It is how the lint tasks run
|
||||
// the corpus first without burying their own output under eleven ok lines.
|
||||
const quiet = flags.has("--quiet");
|
||||
const expected = JSON.parse(readFileSync(expectedPath, "utf8"));
|
||||
let failures = 0;
|
||||
for (const name of files) {
|
||||
const want = (expected[name] ?? []).join(" | ");
|
||||
const got = actual[name].join(" | ");
|
||||
if (want === got) {
|
||||
if (!quiet) process.stdout.write(`ok ${name} (${actual[name].length})\n`);
|
||||
continue;
|
||||
}
|
||||
failures++;
|
||||
process.stdout.write(`FAIL ${name}\n expected: ${want || "(nothing)"}\n actual: ${got || "(nothing)"}\n`);
|
||||
}
|
||||
|
||||
const stale = Object.keys(expected).filter((n) => !files.includes(n));
|
||||
for (const name of stale) {
|
||||
failures++;
|
||||
process.stdout.write(`FAIL ${name} is in expected.json but the fixture is gone\n`);
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
process.stdout.write(
|
||||
`\ncomment-lint selftest: ${failures} fixture(s) differ. If intended, rerun with --update and review the diff.\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (!quiet) process.stdout.write("\ncomment-lint selftest: both engines match the corpus\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isFlagValue(arg) {
|
||||
const index = argv.indexOf(arg);
|
||||
return index > 0 && argv[index - 1] === "--since";
|
||||
}
|
||||
|
||||
function flagValue(flag) {
|
||||
const index = argv.indexOf(flag);
|
||||
return argv[index + 1] ?? "origin/main";
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
// stderr is captured rather than inherited so git's line-ending advice ("CRLF
|
||||
// will be replaced by LF") does not print once per file on Windows. Real
|
||||
// failures still surface: execFileSync throws, and the caller reads .stderr.
|
||||
return execFileSync("git", args, {
|
||||
cwd: REPO,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1 << 28,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
// The comment-quality rule set, shared by both engines so a rule means the same
|
||||
// thing everywhere: the oxlint JS plugin (which owns .ts/.tsx, and has real
|
||||
// comment tokens and an AST) and comment-lint.mjs (which owns .java and .py, and
|
||||
// has only lines). Neither engine ever scans the other's files, so the two can
|
||||
// differ in precision without producing contradictory findings on one file.
|
||||
//
|
||||
// The standard these rules enforce is devGuide/CODE_COMMENTS.md. Changing a rule
|
||||
// here without changing that document leaves the repo with two answers.
|
||||
//
|
||||
// Between them the engines read every comment form the repo writes: // and /* */,
|
||||
// Javadoc and JSDoc, JSX comments, # and Python docstrings.
|
||||
|
||||
export const SEVERITY = { ERROR: "error", WARN: "warn" };
|
||||
|
||||
// Every rule blocks. A rule that only warns is a rule nobody acts on, so a
|
||||
// finding that turns out to be wrong is a bug in the rule: narrow it, or mark the
|
||||
// line with comment-lint-allow and say why. Each rule below carries the readings
|
||||
// it deliberately excludes, which is where to start when one misfires.
|
||||
export const RULES = {
|
||||
CMT001: { name: "restates-code", severity: SEVERITY.ERROR },
|
||||
CMT002: { name: "banner", severity: SEVERITY.ERROR },
|
||||
CMT003: { name: "step-narration", severity: SEVERITY.ERROR },
|
||||
CMT004: { name: "diff-narration", severity: SEVERITY.ERROR },
|
||||
CMT005: { name: "dead-code", severity: SEVERITY.ERROR },
|
||||
CMT006: { name: "block-too-long", severity: SEVERITY.ERROR },
|
||||
CMT007: { name: "doc-restates-signature", severity: SEVERITY.ERROR },
|
||||
CMT008: { name: "bad-allow", severity: SEVERITY.ERROR },
|
||||
CMT009: { name: "unowned-todo", severity: SEVERITY.ERROR },
|
||||
};
|
||||
|
||||
export const MAX_BLOCK_LINES = 12;
|
||||
|
||||
// CMT001 compares a comment against the code it introduces. Both sides are
|
||||
// reduced to the same shape first: lowercased, camel/snake/kebab split into
|
||||
// words, stop words and short words dropped. What survives is the information
|
||||
// each side actually carries, so "Handle drag start" and `handleDragStart` land
|
||||
// on the same set and the comment is shown to add nothing.
|
||||
|
||||
const STOP_WORDS = new Set(
|
||||
(
|
||||
"a an the and or but if then else for to of in on at by with from into is are be was were this that these those it its as we our you your do" +
|
||||
" does done use uses used using will would should can could may might not no yes new only also just so such via per each all any some more" +
|
||||
" most other another same when while where which what who how why here there now next finally first second third let const var function" +
|
||||
" return set get"
|
||||
).split(" "),
|
||||
);
|
||||
|
||||
const WORD_RE = /[a-z][a-z0-9]*/g;
|
||||
|
||||
export function contentWords(text) {
|
||||
return (text.toLowerCase().match(WORD_RE) ?? []).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
|
||||
}
|
||||
|
||||
export function identWords(text) {
|
||||
const split = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_\-.]/g, " ");
|
||||
return contentWords(split);
|
||||
}
|
||||
|
||||
// Sentence punctuation marks prose, which is usually saying something the code
|
||||
// does not. A single trailing full stop does not count.
|
||||
const PROSE_PUNCT = /[.;:?!]/;
|
||||
const MAX_RESTATE_WORDS = 6;
|
||||
|
||||
// Arrange/Act/Assert and Given/When/Then label the shape of a test rather than
|
||||
// describe the line beneath. Exempt only as a bare marker, so
|
||||
// `// Assert the cap is clamped to the tier maximum` is prose and judged on its
|
||||
// merits.
|
||||
const TEST_STRUCTURE = /^(arrange|act|assert|given|when|then)\b/i;
|
||||
const MAX_MARKER_WORDS = 4;
|
||||
|
||||
export function restatesCode(body, codeText) {
|
||||
if (TEST_STRUCTURE.test(body.trim()) && body.trim().split(/\s+/).length <= MAX_MARKER_WORDS) return false;
|
||||
if (PROSE_PUNCT.test(body.replace(/\.$/, ""))) return false;
|
||||
const comment = contentWords(body);
|
||||
if (comment.length === 0 || comment.length > MAX_RESTATE_WORDS) return false;
|
||||
const code = identWords(codeText);
|
||||
if (code.length === 0) return false;
|
||||
|
||||
// Prefix matching either way, so "config" covers "configuration" and vice versa.
|
||||
return comment.every((w) => code.some((k) => k.startsWith(w) || w.startsWith(k)));
|
||||
}
|
||||
|
||||
const RULE_CHARS = /^[=~_*#+\-]{4,}|[─-╿]{4,}|[=~_*+]{4,}$/;
|
||||
const SECTION_LABEL = new RegExp(
|
||||
"^(imports?|exports?|types?|interfaces?|constants?|config|helpers?|utils?|utilities|state|handlers?|callbacks?|effects?" +
|
||||
"|render|rendering|styles?|props?|hooks?|setup|teardown|cleanup|main|public|private|internal|api|queries|mutations" +
|
||||
"|selectors?|actions?|reducers?|components?|fields?|getters?|setters?|lifecycle|boilerplate)" +
|
||||
"\\s*(section|area|block)?\\s*$",
|
||||
"i",
|
||||
);
|
||||
|
||||
export function isBanner(body) {
|
||||
if (RULE_CHARS.test(body.trim())) return true;
|
||||
|
||||
// A label wrapped in decoration is still a label: strip the decoration first.
|
||||
const bare = body
|
||||
.replace(/[=~_*#+\-─-╿]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return bare.length > 0 && SECTION_LABEL.test(bare);
|
||||
}
|
||||
|
||||
// A bare "1." is not narration: numbered lists are how a doc block enumerates
|
||||
// conditions or alternatives, and matching them buries the rule in false
|
||||
// positives. Only the explicit step form and sequencing adverbs qualify, and the
|
||||
// number needs a separator after it, so a wrapped line beginning "step 2 unmounts
|
||||
// + remounts the panel" reads as the prose it is.
|
||||
const STEP = /^(step\s*\d+(\.\d+)?\s*[:.)\-]|(then|next|finally|afterwards|lastly)\s*[,:]\s+\S)/i;
|
||||
|
||||
export function isStepNarration(body) {
|
||||
return STEP.test(body.trim());
|
||||
}
|
||||
|
||||
// Only phrases that can be talking about the code's own past. Excluded because
|
||||
// each has an innocent reading that fires constantly:
|
||||
// "used to" alone - "Used to clamp the live line" means "is used to"
|
||||
// "previously" alone - "re-show even if previously dismissed" is runtime state
|
||||
// "was called" - collides with "verify getSession was called"
|
||||
// "left over from" - "no cards left over from the unfiltered grid"
|
||||
const DIFF_NARRATION = new RegExp(
|
||||
"\\b((this|it|we|they|that) used to|used to (be|live|sit)" +
|
||||
"|(this|it|that|which|the (class|method|field|code|file|module|palette|banner)) is no longer (needed|used)" +
|
||||
"|renamed from|was (previously|formerly) (called|named|known)" +
|
||||
"|instead of the old|has been (removed|replaced) )",
|
||||
"i",
|
||||
);
|
||||
const REMOVAL_SUFFIX = /(^|\s)[-(—]\s*(removed|deleted|dropped|no longer needed)\s*\)?\s*$/i;
|
||||
|
||||
export function isDiffNarration(body) {
|
||||
const t = body.trim();
|
||||
return DIFF_NARRATION.test(t) || REMOVAL_SUFFIX.test(t);
|
||||
}
|
||||
|
||||
const CODE_KEYWORD = new RegExp(
|
||||
"^(import|package|public|private|protected|static|final|abstract|class|interface|enum|record|extends|implements" +
|
||||
"|def|async|await|const|let|var|function|export|return|if|else|elif|for|while|do|try|catch|finally|switch|case" +
|
||||
"|throw|new|super|this|@[A-Za-z])\\b",
|
||||
);
|
||||
const STATEMENT_TAIL = /[;{}]\s*$/;
|
||||
const CALL_ONLY = /^[\w.$]+\s*\([^)]*\)\s*;?\s*$/;
|
||||
const ASSIGNMENT = /\S\s*=\s*\S/;
|
||||
|
||||
export function looksLikeCode(line) {
|
||||
const t = line.trim();
|
||||
if (t.length === 0) return false;
|
||||
if (CODE_KEYWORD.test(t)) return true;
|
||||
if (CALL_ONLY.test(t)) return true;
|
||||
return STATEMENT_TAIL.test(t) && ASSIGNMENT.test(t);
|
||||
}
|
||||
|
||||
export const MIN_DEAD_CODE_RUN = 3;
|
||||
const DEAD_CODE_SHARE = 2 / 3;
|
||||
|
||||
export function isDeadCodeRun(bodies) {
|
||||
if (bodies.length < MIN_DEAD_CODE_RUN) return false;
|
||||
const codeish = bodies.filter(looksLikeCode).length;
|
||||
return codeish / bodies.length >= DEAD_CODE_SHARE;
|
||||
}
|
||||
|
||||
// Every documented-parameter form this repo writes, so the rule is not quietly
|
||||
// Javadoc-only:
|
||||
// Javadoc / JSDoc @param blob The blob to download
|
||||
// Sphinx :param blob: The blob to download
|
||||
// Google docstring blob: The blob to download (under an Args: heading)
|
||||
// NumPy style is deliberately absent: it splits the name and the description
|
||||
// across two lines, and there is one instance of it in the tree.
|
||||
const PARAM_TAG = /^@param\s+(?:\{[^}]*\}\s+)?([\w$.]+)\s*-?\s*(.+)$/;
|
||||
const SPHINX_PARAM = /^:(?:param|arg|key)\s+(?:\S+\s+)?([\w.]+)\s*:\s*(.+)$/;
|
||||
const GOOGLE_PARAM = /^([a-z_][\w]*)\s*(?:\([^)]*\))?\s*:\s*(.+)$/;
|
||||
|
||||
const RETURN_TAG = /^@returns?\s+(.+)$/;
|
||||
const SPHINX_RETURN = /^:returns?\s*:\s*(.+)$/;
|
||||
|
||||
// Description adds nothing when every word in it already appears in the thing
|
||||
// being described. `@param blob - The blob to download` is the canonical case.
|
||||
//
|
||||
// No native linter covers this. eslint-plugin-jsdoc's require-param-description,
|
||||
// Checkstyle's NonEmptyAtclauseDescription and ruff's D-rules all check that a
|
||||
// description exists, not whether it says anything.
|
||||
export function docRestatesSignature(body, ownerName = "") {
|
||||
const t = body.trim();
|
||||
|
||||
for (const pattern of [PARAM_TAG, SPHINX_PARAM, GOOGLE_PARAM]) {
|
||||
const match = pattern.exec(t);
|
||||
if (!match) continue;
|
||||
const [, name, description] = match;
|
||||
// Google form is just `name: description`, which also matches ordinary prose
|
||||
// containing a colon. Require the description to be short and unpunctuated so
|
||||
// "Note: the cap is clamped" is not read as a parameter called "note".
|
||||
if (pattern === GOOGLE_PARAM && /[.;,]/.test(description)) return false;
|
||||
return addsNothing(description, name, 5);
|
||||
}
|
||||
|
||||
const returns = RETURN_TAG.exec(t) ?? SPHINX_RETURN.exec(t);
|
||||
if (returns && ownerName) return addsNothing(returns[1], ownerName, 4);
|
||||
return false;
|
||||
}
|
||||
|
||||
function addsNothing(description, subject, limit) {
|
||||
const words = contentWords(description);
|
||||
if (words.length === 0 || words.length > limit) return false;
|
||||
const known = identWords(subject);
|
||||
return known.length > 0 && words.every((w) => known.some((k) => k.startsWith(w) || w.startsWith(k)));
|
||||
}
|
||||
|
||||
// A TODO with no reference has nothing that will ever close it. An owner is not
|
||||
// accepted in its place: a username goes stale when someone leaves and means
|
||||
// nothing to an outside contributor, while an issue outlives both.
|
||||
//
|
||||
// Anchored at the start, so this catches a comment that *is* a TODO rather than
|
||||
// prose that mentions the word.
|
||||
const TODO_MARKER = /^(TODO|FIXME|HACK|XXX)\b/;
|
||||
|
||||
// What counts as something that will close it: an issue, a link, or a security
|
||||
// advisory. Checked across the whole comment run, so the reference can sit on a
|
||||
// continuation line.
|
||||
const HAS_REFERENCE = /(#\d+|https?:\/\/|CVE-\d|GHSA-|[A-Z]{2,}-\d+)/;
|
||||
|
||||
export function isUnownedTodo(body, runText = body) {
|
||||
return TODO_MARKER.test(body) && !HAS_REFERENCE.test(runText);
|
||||
}
|
||||
|
||||
// A rule id is silenced by `comment-lint-allow: CMT002`, on the comment itself or
|
||||
// on the line above it. There is deliberately no form that disables every rule.
|
||||
//
|
||||
// The whole comment must be the directive. Matching it anywhere in the text meant
|
||||
// prose that merely mentions the syntax silenced a rule, which this file's own
|
||||
// paragraph above did.
|
||||
const DIRECTIVE = /^comment-lint-allow:\s*(.+?)\s*$/i;
|
||||
|
||||
export function isDirective(body) {
|
||||
return DIRECTIVE.test(body.trim());
|
||||
}
|
||||
|
||||
// A directive that names nothing real, or that suppresses nothing, is dead
|
||||
// configuration: it reads as a silenced rule while silencing nothing, and it
|
||||
// blinds the line for whoever inherits it. Reported for the same reason ESLint
|
||||
// has --report-unused-disable-directives and ruff has RUF100.
|
||||
class Allowance {
|
||||
constructor(directives) {
|
||||
this.entries = [];
|
||||
for (const directive of directives) {
|
||||
for (const token of directiveTokens(directive.body)) {
|
||||
this.entries.push({ token, directive, known: token in RULES, used: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called only once a rule has decided it would report, so a directive counts
|
||||
// as used when it actually silenced something. Asking before the rule decided
|
||||
// marked every consulted directive as used, which hid the unused ones.
|
||||
suppresses(rule) {
|
||||
let allowed = false;
|
||||
for (const entry of this.entries) {
|
||||
if (entry.token !== rule) continue;
|
||||
entry.used = true;
|
||||
allowed = true;
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
reportUnused(report) {
|
||||
for (const entry of this.entries) {
|
||||
if (entry.used) continue;
|
||||
const detail = entry.known ? `${entry.token} is allowed here but nothing reported it` : `${entry.token} is not a rule`;
|
||||
report("CMT008", entry.directive.line, entry.directive.column, detail, entry.directive.body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every token a directive names, valid or not, so an unknown one is reported
|
||||
// rather than quietly ignored. Matching only real ids would let `CMT999` through
|
||||
// as a silent no-op: it looks like a rule and silences nothing.
|
||||
export function directiveTokens(body) {
|
||||
const match = DIRECTIVE.exec(body.trim());
|
||||
if (!match) return [];
|
||||
return match[1]
|
||||
.split(",")
|
||||
.map((token) => token.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Generated files carry whatever the generator emits, and editing them to
|
||||
// satisfy a lint rule would be undone on the next regeneration.
|
||||
const GENERATED_MARKER = /AUTO-?GENERATED|@generated|DO NOT EDIT|Code generated by/i;
|
||||
const GENERATED_HEADER_LINES = 10;
|
||||
|
||||
export function isGenerated(source) {
|
||||
return GENERATED_MARKER.test(source.split("\n", GENERATED_HEADER_LINES).join("\n"));
|
||||
}
|
||||
|
||||
export const EXCLUDED_PATHS = [
|
||||
/(^|\/)node_modules\//,
|
||||
/(^|\/)dist(-\w+)?\//,
|
||||
/(^|\/)build\//,
|
||||
/(^|\/)target\//,
|
||||
/(^|\/)vendor\//,
|
||||
/pdfjs/i,
|
||||
/thirdParty/i,
|
||||
/\.min\./,
|
||||
/src-tauri\/gen\//,
|
||||
/public\/locales\//,
|
||||
/\.d\.ts$/,
|
||||
/(^|\/)storybook-static\//,
|
||||
/(^|\/)playwright-report\//,
|
||||
/(^|\/)org\/apache\//,
|
||||
];
|
||||
|
||||
export function isExcludedPath(file) {
|
||||
const normalised = file.replace(/\\/g, "/");
|
||||
return EXCLUDED_PATHS.some((re) => re.test(normalised));
|
||||
}
|
||||
|
||||
// Comment text reduced to what a reader would call "the same comment": trimmed,
|
||||
// whitespace collapsed, comment markers and decoration stripped. Both sides of
|
||||
// the pre-existing check normalise through here so indentation and marker style
|
||||
// cannot make an unchanged comment look new.
|
||||
export function normaliseComment(text) {
|
||||
return String(text)
|
||||
.replace(/^[\s{]*(\/\/+|\/\*+|#+|\*+)/gm, " ")
|
||||
.replace(/\*+\/[\s}]*$/gm, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// Every comment in a source file, normalised. Deliberately permissive and
|
||||
// language-agnostic: it only ever decides whether a finding is pre-existing, so
|
||||
// over-matching suppresses a duplicate comment and under-matching just reports
|
||||
// something the author can look at.
|
||||
export function commentBodiesOf(source) {
|
||||
const bodies = new Set();
|
||||
for (const raw of source.split(/\r?\n/)) {
|
||||
const marker = /(\/\/+|\/\*+|^\s*\*+|#+)/.exec(raw);
|
||||
if (!marker) continue;
|
||||
const body = normaliseComment(raw.slice(marker.index));
|
||||
if (body.length > 0) bodies.add(body);
|
||||
}
|
||||
return bodies;
|
||||
}
|
||||
|
||||
export function ruleLabel(id) {
|
||||
return `${id} ${RULES[id].name}`;
|
||||
}
|
||||
|
||||
// Both engines funnel into this. They differ only in how they build `runs`: the
|
||||
// oxlint plugin reads real comment tokens, comment-lint.mjs scans lines. Keeping
|
||||
// the rule application here is what stops the two drifting apart.
|
||||
//
|
||||
// A "run" is a group of comment lines with no code between them, which is the
|
||||
// unit CMT005 and CMT006 judge. Shape:
|
||||
// { startLine, kind: "line" | "block" | "doc", lines: [{ line, column, body }] }
|
||||
// `line` is 1-based to match every editor and every diff.
|
||||
|
||||
export function analyse({ lines, runs, isTestFile = false }) {
|
||||
const findings = [];
|
||||
// `body` is the comment's own text, kept alongside the formatted detail so the
|
||||
// caller can ask whether this exact comment already existed before the change.
|
||||
// That is what stops a reindent or a code move reporting comments nobody wrote.
|
||||
const report = (rule, line, column, detail, body) => {
|
||||
if (isTestFile && SUPPRESSED_IN_TESTS.has(rule)) return;
|
||||
findings.push({ rule, line, column, detail, body: normaliseComment(body ?? detail), severity: RULES[rule].severity });
|
||||
};
|
||||
|
||||
for (const run of runs) {
|
||||
// A directive is scaffolding, not content. Leaving it in the run made it two
|
||||
// lines long, and CMT001 only judges a one-line run, so any directive
|
||||
// silenced CMT001 whatever rule it named.
|
||||
const directives = run.lines.filter((l) => isDirective(l.body));
|
||||
const content = run.lines.filter((l) => !isDirective(l.body));
|
||||
const allowed = new Allowance(directives);
|
||||
|
||||
if (content.length === 0) {
|
||||
allowed.reportUnused(report);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bodies = content.map((l) => l.body);
|
||||
const runText = bodies.join("\n");
|
||||
const first = content[0];
|
||||
|
||||
// Only CMT004 and CMT009 judge a trailing comment. The others depend on the
|
||||
// comment introducing the code below it, and a trailing comment sits beside
|
||||
// it: `0x25 // "%PDF"` overlaps in words while adding the decoding, which is
|
||||
// the kind of lower-altitude fact the standard asks for.
|
||||
if (run.trailing) {
|
||||
for (const entry of content) {
|
||||
const body = entry.body.trim();
|
||||
if (body.length === 0) continue;
|
||||
if (isDiffNarration(body) && !allowed.suppresses("CMT004")) {
|
||||
report("CMT004", entry.line, entry.column, truncate(body), body);
|
||||
continue;
|
||||
}
|
||||
if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) {
|
||||
report("CMT009", entry.line, entry.column, truncate(body), body);
|
||||
}
|
||||
}
|
||||
allowed.reportUnused(report);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDeadCodeRun(bodies) && !allowed.suppresses("CMT005")) {
|
||||
report("CMT005", run.startLine, first.column, `${bodies.length} commented-out lines`, runText);
|
||||
allowed.reportUnused(report);
|
||||
continue; // Every other rule would pile onto the same block of dead code.
|
||||
}
|
||||
|
||||
// A doc block is exempt: the standard asks for thorough contracts, so capping
|
||||
// their length would argue with itself. This judges runs of implementation
|
||||
// comment, where an essay means the code needs restructuring.
|
||||
const essay = run.kind !== "doc" && content.length > MAX_BLOCK_LINES && run.startLine > FILE_HEADER_LINES;
|
||||
if (essay && !allowed.suppresses("CMT006")) {
|
||||
report("CMT006", run.startLine, first.column, `${content.length} lines, limit ${MAX_BLOCK_LINES}`, runText);
|
||||
}
|
||||
|
||||
const owner = run.kind === "line" ? "" : nextCodeLine(lines, run);
|
||||
|
||||
for (const entry of content) {
|
||||
const body = entry.body.trim();
|
||||
if (body.length === 0) continue;
|
||||
|
||||
if (isBanner(body) && !allowed.suppresses("CMT002")) {
|
||||
report("CMT002", entry.line, entry.column, truncate(body), body);
|
||||
continue;
|
||||
}
|
||||
if (isStepNarration(body) && !allowed.suppresses("CMT003")) {
|
||||
report("CMT003", entry.line, entry.column, truncate(body), body);
|
||||
continue;
|
||||
}
|
||||
if (isDiffNarration(body) && !allowed.suppresses("CMT004")) {
|
||||
report("CMT004", entry.line, entry.column, truncate(body), body);
|
||||
continue;
|
||||
}
|
||||
if (docRestatesSignature(body, owner) && !allowed.suppresses("CMT007")) {
|
||||
report("CMT007", entry.line, entry.column, truncate(body), body);
|
||||
continue;
|
||||
}
|
||||
if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) {
|
||||
report("CMT009", entry.line, entry.column, truncate(body), body);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// CMT001 judges a whole single-line run against the code it introduces, so
|
||||
// a two-line comment that happens to echo one identifier is left alone.
|
||||
// A one-line `/* … */` counts, which is how JSX `{/* Cap editor */}` above
|
||||
// `<CapEditor …>` is caught. A doc block does not: it is a contract, and
|
||||
// CMT007 is the rule that judges those.
|
||||
if (run.kind !== "doc" && content.length === 1) {
|
||||
const entry = first;
|
||||
const body = entry.body.trim();
|
||||
const code = nextCodeLine(lines, run);
|
||||
if (code && !isBanner(body) && restatesCode(body, code) && !allowed.suppresses("CMT001")) {
|
||||
report("CMT001", entry.line, entry.column, `${truncate(body)} -> ${truncate(code)}`, body);
|
||||
}
|
||||
}
|
||||
|
||||
allowed.reportUnused(report);
|
||||
}
|
||||
|
||||
return findings.sort((a, b) => a.line - b.line || a.column - b.column);
|
||||
}
|
||||
|
||||
// A file header is allowed to be as long as it needs to be.
|
||||
const FILE_HEADER_LINES = 5;
|
||||
const DETAIL_WIDTH = 58;
|
||||
|
||||
// Both of these say something real in a test and nothing anywhere else. A
|
||||
// regression test explains itself by describing the old behaviour, and the e2e
|
||||
// specs number their comments to match a written manual test procedure.
|
||||
const SUPPRESSED_IN_TESTS = new Set(["CMT003", "CMT004"]);
|
||||
|
||||
function precedingLine(lines, startLine) {
|
||||
return lines[startLine - 2] ?? "";
|
||||
}
|
||||
|
||||
function nextCodeLine(lines, run) {
|
||||
const commentLines = new Set(run.lines.map((l) => l.line));
|
||||
for (let i = run.startLine; i < lines.length; i++) {
|
||||
const lineNumber = i + 1;
|
||||
if (commentLines.has(lineNumber)) continue;
|
||||
const text = lines[i]?.trim() ?? "";
|
||||
if (text.length === 0) continue;
|
||||
if (text.startsWith("//") || text.startsWith("#") || text.startsWith("*") || text.startsWith("/*")) continue;
|
||||
if (text === "}" || text === "};" || text === ")" || text === ");") return "";
|
||||
return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function truncate(text) {
|
||||
const flat = text.replace(/\s+/g, " ").trim();
|
||||
return flat.length > DETAIL_WIDTH ? `${flat.slice(0, DETAIL_WIDTH - 1)}…` : flat;
|
||||
}
|
||||
|
||||
export const TEST_FILE = /\.(test|spec)\.[jt]sx?$|(^|\/)src\/test\/|Test\.java$|Tests\.java$|(^|\/)test_[^/]+\.py$|_test\.py$/;
|
||||
|
||||
export function isTestPath(file) {
|
||||
return TEST_FILE.test(file.replace(/\\/g, "/"));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package fixtures;
|
||||
|
||||
class AaaTest {
|
||||
|
||||
void clampsToTierMaximum() {
|
||||
// Arrange
|
||||
var wallet = walletAt(500);
|
||||
|
||||
// Act
|
||||
var result = clamp(wallet);
|
||||
|
||||
// Assert
|
||||
assertEquals(100, result.cap());
|
||||
|
||||
// Assert the cap is clamped rather than rejected, because the tier
|
||||
// downgrade path relies on it.
|
||||
assertTrue(result.clamped());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# comment-lint fixtures
|
||||
|
||||
The contract between the two engines. Each file is a small, realistic example of
|
||||
what a rule fires on, or of something it must leave alone. `expected.json` records
|
||||
what every fixture should produce, down to the line and the severity.
|
||||
|
||||
Expectations live outside the fixtures deliberately: an in-file `EXPECT:` marker
|
||||
would sit inside the comment under test, changing its word count and its run
|
||||
length, so the fixture would stop being an example of the real thing.
|
||||
|
||||
```bash
|
||||
node scripts/lint/comment-lint.mjs --selftest # compare
|
||||
node scripts/lint/comment-lint.mjs --selftest --update # re-record, then review the diff
|
||||
```
|
||||
|
||||
Adding a rule means adding a fixture that fires it and a line in a `clean.*`
|
||||
fixture that must not. A rule with no fixture is a rule nobody can safely change.
|
||||
@@ -0,0 +1,32 @@
|
||||
package fixtures;
|
||||
|
||||
class Allow {
|
||||
|
||||
void keptOnPurpose() {
|
||||
// comment-lint-allow: CMT002
|
||||
// ---------- kept on purpose, this fixture proves the escape hatch ----------
|
||||
run();
|
||||
}
|
||||
|
||||
void unknownToken(Session session) {
|
||||
// comment-lint-allow: FAKE_RULE
|
||||
session.close();
|
||||
}
|
||||
|
||||
void looksLikeARuleButIsNot(Session session) {
|
||||
// comment-lint-allow: CMT999
|
||||
session.close();
|
||||
}
|
||||
|
||||
void allowedButNothingFires(Session session) {
|
||||
// comment-lint-allow: CMT001
|
||||
// Closed once the signing round trip has settled, not before.
|
||||
session.close();
|
||||
}
|
||||
|
||||
void directiveMustNotHideOtherRules(Registry registry) {
|
||||
// comment-lint-allow: CMT002
|
||||
// Clear the registry
|
||||
registry.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package fixtures;
|
||||
|
||||
class Apostrophes {
|
||||
|
||||
/** The approver's team, which must match the one this server already belongs to. */
|
||||
Long teamId;
|
||||
|
||||
// The caller's own retry budget applies here; this method does not retry.
|
||||
void submit() {}
|
||||
|
||||
void literals() {
|
||||
char quote = '\'';
|
||||
char newline = '\n';
|
||||
char slash = '/';
|
||||
String path = "a//b";
|
||||
}
|
||||
|
||||
/** Kept last: if the scanner desynchronises above, this stops being seen. */
|
||||
void canary() {
|
||||
// Build document
|
||||
document = build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package fixtures;
|
||||
|
||||
/**
|
||||
* Authority on which filesystem locations a policy may read or write. Fail-closed:
|
||||
* denied entirely under the saas profile, then Stirling's own config directory is
|
||||
* always rejected, then the path must resolve inside an allowed root.
|
||||
*
|
||||
* <p>Compared after normalisation so {@code ..} cannot escape a root. Symlink
|
||||
* escape is not defended: an operator who roots an allowlist on a symlink to a
|
||||
* sensitive location is trusted.
|
||||
*/
|
||||
class Clean {
|
||||
|
||||
/** Returns the normalised absolute path; throws if not permitted. */
|
||||
Path check(Path candidate) {
|
||||
// whenComplete runs on the worker thread after the run finishes, so the
|
||||
// terminal event never races the step events.
|
||||
return candidate.toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
void sizes() {
|
||||
// Bytes, not KiB: the API contract predates the unit change and callers
|
||||
// still send bytes.
|
||||
long limit = 5_242_880L;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Auth/session seam. saas keeps a Supabase web session; desktop keeps a JWT in
|
||||
* the Tauri secure store, and cloud code reads the token through here instead.
|
||||
* Default no-op; saas/ and desktop/ shadow it.
|
||||
*/
|
||||
export interface SessionSeam {
|
||||
/** Bearer access token for authenticated API calls, or null when signed out. */
|
||||
getAccessToken(): Promise<string | null>;
|
||||
}
|
||||
|
||||
export function createSeam(): SessionSeam {
|
||||
return {
|
||||
// Resolves null rather than throwing: a signed-out caller is an ordinary
|
||||
// state here, and every consumer already branches on null.
|
||||
getAccessToken: async () => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package fixtures;
|
||||
|
||||
class DeadCode {
|
||||
|
||||
// private void oldPath(PDDocument document) {
|
||||
// PDPage page = document.getPage(0);
|
||||
// page.setRotation(90);
|
||||
// document.save(target);
|
||||
// }
|
||||
|
||||
void currentPath(PDDocument document) {
|
||||
document.save(target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package fixtures;
|
||||
|
||||
class Docs {
|
||||
|
||||
/**
|
||||
* @param blob the blob
|
||||
* @param timeoutMs how long to wait before abandoning the read; the caller
|
||||
* owns retrying, because only it knows whether the operation is
|
||||
* idempotent
|
||||
*/
|
||||
void download(Blob blob, long timeoutMs) {}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Module docstring, which the scanner must see as documentation."""
|
||||
|
||||
|
||||
def download(blob, timeout_ms):
|
||||
"""Fetch the blob.
|
||||
|
||||
Args:
|
||||
blob: The blob
|
||||
timeout_ms: How long to wait before abandoning the read; the caller owns
|
||||
retrying, because only it knows whether the operation is idempotent.
|
||||
|
||||
Returns:
|
||||
The fetched bytes.
|
||||
"""
|
||||
return read(blob)
|
||||
|
||||
|
||||
def sphinx(blob):
|
||||
"""Fetch the blob.
|
||||
|
||||
:param blob: The blob
|
||||
:returns: the sphinx result
|
||||
"""
|
||||
return read(blob)
|
||||
|
||||
|
||||
def payload():
|
||||
# Not documentation: a triple-quoted value, so its contents are data.
|
||||
body = """{"key": "value", "note": "Types"}"""
|
||||
return body
|
||||
|
||||
|
||||
def canary():
|
||||
# Build document
|
||||
document = build()
|
||||
return document
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"AaaTest.java": [],
|
||||
"allow.java": [
|
||||
"12:CMT008:error",
|
||||
"17:CMT008:error",
|
||||
"22:CMT008:error",
|
||||
"28:CMT008:error",
|
||||
"29:CMT001:error"
|
||||
],
|
||||
"apostrophes.java": [
|
||||
"20:CMT001:error"
|
||||
],
|
||||
"clean.java": [],
|
||||
"clean.ts": [],
|
||||
"deadcode.java": [
|
||||
"5:CMT005:error"
|
||||
],
|
||||
"docs.java": [
|
||||
"6:CMT007:error"
|
||||
],
|
||||
"docstrings.py": [
|
||||
"8:CMT007:error",
|
||||
"21:CMT007:error",
|
||||
"34:CMT001:error"
|
||||
],
|
||||
"narration.java": [
|
||||
"6:CMT003:error",
|
||||
"9:CMT003:error"
|
||||
],
|
||||
"narration.tsx": [
|
||||
"2:CMT003:error",
|
||||
"5:CMT004:error",
|
||||
"13:CMT001:error"
|
||||
],
|
||||
"restates.java": [
|
||||
"6:CMT001:error",
|
||||
"9:CMT001:error",
|
||||
"17:CMT002:error",
|
||||
"19:CMT002:error"
|
||||
],
|
||||
"restates.py": [
|
||||
"2:CMT001:error",
|
||||
"5:CMT002:error"
|
||||
],
|
||||
"restates.ts": [
|
||||
"2:CMT001:error",
|
||||
"14:CMT002:error",
|
||||
"18:CMT002:error"
|
||||
],
|
||||
"strings.java": [],
|
||||
"templates.py": [
|
||||
"22:CMT001:error"
|
||||
],
|
||||
"todos.java": [
|
||||
"6:CMT009:error"
|
||||
],
|
||||
"trailing.java": [
|
||||
"13:CMT009:error",
|
||||
"14:CMT004:error",
|
||||
"19:CMT009:error",
|
||||
"20:CMT004:error"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package fixtures;
|
||||
|
||||
class Narration {
|
||||
|
||||
void export(Document document) {
|
||||
// Step 1: collect the annotations
|
||||
var annotations = document.annotations();
|
||||
|
||||
// Then, flatten them onto the page
|
||||
document.flatten(annotations);
|
||||
|
||||
// IMPORTANT: do not reorder these
|
||||
document.save();
|
||||
|
||||
// No longer needed after the storage migration
|
||||
legacyCleanup();
|
||||
|
||||
// Ordering matters: flatten() reads the annotation list that save()
|
||||
// clears, so a save first loses every annotation. See #6865.
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function Panel() {
|
||||
// Step 1: read the cap
|
||||
const cap = useCap();
|
||||
|
||||
// This used to be initialised by the footer, which mounted after the banner.
|
||||
useConsentBanner();
|
||||
|
||||
// CRITICAL: keep this above the early return
|
||||
useLayoutEffect(() => sync(cap), [cap]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Cap editor */}
|
||||
<CapEditor cap={cap} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package fixtures;
|
||||
|
||||
class Restates {
|
||||
|
||||
void run(Registry registry, Job job) {
|
||||
// Clear the registry
|
||||
registry.clear();
|
||||
|
||||
// Sanitize filename
|
||||
String safeFilename = sanitizeFilename(job.originalFilename());
|
||||
|
||||
// Wait for the worker to drain before clearing, or an in-flight job
|
||||
// re-registers its temp file after the sweep.
|
||||
registry.awaitQuiescence();
|
||||
}
|
||||
|
||||
// ---------- Internal helpers ----------
|
||||
|
||||
// Types
|
||||
private enum Mode {
|
||||
FAST,
|
||||
SAFE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
def build(request):
|
||||
# Get the current status
|
||||
status = request.current_status()
|
||||
|
||||
# ---- helpers ----
|
||||
|
||||
# Truncated to 200 chars because the audit column is varchar(200) and a
|
||||
# longer value fails the insert rather than being trimmed.
|
||||
return status[:200]
|
||||
@@ -0,0 +1,20 @@
|
||||
export function useGrid() {
|
||||
// Handle drag start
|
||||
const handleDragStart = (event: DragStartEvent) => event.active.id;
|
||||
|
||||
// Selection state
|
||||
const selection = new Set<string>();
|
||||
|
||||
// Debounced so a fast drag does not queue a layout pass per pointer move.
|
||||
const updateLayout = debounce(() => measure(), 16);
|
||||
|
||||
return { handleDragStart, selection, updateLayout };
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Gate = "OFFSITE_PROCESSING" | "AUTOMATION";
|
||||
|
||||
// Helpers
|
||||
|
||||
export function noop() {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package fixtures;
|
||||
|
||||
class Strings {
|
||||
|
||||
// A `//` inside a literal is not a comment, and neither is an escaped quote.
|
||||
void urls() {
|
||||
String docs = "https://example.com/guide";
|
||||
String quoted = "a \" then // not a comment";
|
||||
char slash = '/';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Templates that open mid-line, whose closing delimiter starts a line."""
|
||||
|
||||
_TOOL_IO = '''
|
||||
TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {{
|
||||
{declarations}
|
||||
}}
|
||||
'''
|
||||
|
||||
_HEADER = """
|
||||
# Header emitted into the output file.
|
||||
# Types
|
||||
"""
|
||||
|
||||
|
||||
def resolve(schema):
|
||||
if "$ref" in schema:
|
||||
return lookup(schema["$ref"])
|
||||
return schema
|
||||
|
||||
|
||||
def canary():
|
||||
# Build document
|
||||
document = build()
|
||||
return document
|
||||
@@ -0,0 +1,25 @@
|
||||
package fixtures;
|
||||
|
||||
class Todos {
|
||||
|
||||
void bare() {
|
||||
// TODO: re-enable once account syncing lands
|
||||
skip();
|
||||
}
|
||||
|
||||
void referenced() {
|
||||
// TODO(#1234): re-enable once account syncing lands
|
||||
skip();
|
||||
}
|
||||
|
||||
void linked() {
|
||||
// FIXME: the upstream fix is tracked at https://example.com/issues/9
|
||||
workaround();
|
||||
}
|
||||
|
||||
void mentioned() {
|
||||
// Image placeholders are not scored: their body text is a TODO marker
|
||||
// rather than prose, so scoring it would reward the placeholder.
|
||||
score();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package fixtures;
|
||||
|
||||
class Trailing {
|
||||
|
||||
void decodings() {
|
||||
byte[] header = {0x25, 0x50, 0x44, 0x46}; // "%PDF"
|
||||
long maxSize = 50L * 1024 * 1024; // 50 MB
|
||||
double buffer = 0.10; // 10% headroom
|
||||
int mode = 2; // MB
|
||||
}
|
||||
|
||||
void stillJudged() {
|
||||
boolean supportsSign = false; // TODO make Sign work
|
||||
cleanup(); // this used to run before the flush
|
||||
}
|
||||
|
||||
void blockFormToo() {
|
||||
int mode = 2; /* MB */
|
||||
boolean ready = false; /* TODO wire this up */
|
||||
reset(); /* this used to run before the flush */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user