diff --git a/.changeset/config.json b/.changeset/config.json index 37e67a5d32..75d9b0b271 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,7 +7,8 @@ "@redocly/cli", "@redocly/openapi-core", "@redocly/respect-core", - "@redocly/reunite-integration" + "@redocly/reunite-integration", + "@redocly/recheck" ] ], "linked": [], diff --git a/.changeset/recheck-command.md b/.changeset/recheck-command.md new file mode 100644 index 0000000000..bf8f095355 --- /dev/null +++ b/.changeset/recheck-command.md @@ -0,0 +1,11 @@ +--- +'@redocly/cli': minor +'@redocly/openapi-core': minor +'@redocly/recheck': minor +--- + +Added the `redocly recheck` command. +It lints Markdown prose and structure from the `recheck` block in `redocly.yaml`, with presets named in the root `extends` (for example `recheck/markdown`). +Presets merge with the `recheck` blocks in `extends` order, like other presets. +The engine's actions return data. +The CLI prints it. diff --git a/.changeset/recheck-core-presets.md b/.changeset/recheck-core-presets.md new file mode 100644 index 0000000000..4306c3c687 --- /dev/null +++ b/.changeset/recheck-core-presets.md @@ -0,0 +1,9 @@ +--- +'@redocly/openapi-core': minor +--- + +Added the `plugins` option to `loadConfig` and `createConfig`: ready plugin objects that are added next to the built-in plugin. +Resolved configs carry the merged `recheck` block in `Config.recheck`. +A `recheck/*` entry in `extends` resolves like any other preset when a plugin with the id `recheck` is passed, and is skipped otherwise. +`lint` reads no rules from the block. +A custom plugin cannot use the id `recheck`. diff --git a/.changeset/recheck-engine-relocation.md b/.changeset/recheck-engine-relocation.md new file mode 100644 index 0000000000..e448115734 --- /dev/null +++ b/.changeset/recheck-engine-relocation.md @@ -0,0 +1,10 @@ +--- +'@redocly/recheck': minor +--- + +Added the `@redocly/recheck` engine package to this repository. +It powers the upcoming `redocly recheck` command. +`@redocly/recheck/presets` exports the presets as a plugin for `@redocly/openapi-core`. + +**Note:** the standalone `recheck` binary and `recheck.yaml` are removed. +Configuration moves to the `recheck` block of `redocly.yaml`, read by the `redocly recheck` command in a later release. diff --git a/.oxfmtrc.json b/.oxfmtrc.json index c3005c873d..dd31c2fe15 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -9,6 +9,8 @@ "output/", "packages/respect-core/src/modules/runtime-expressions/abnf-parser.js", "packages/core/src/rules/common/__tests__/fixtures/invalid-yaml.yaml", + "packages/recheck/src/config/__tests__/fixtures/", + "tests/e2e/recheck/*/docs/", "tests/performance/api-definitions/", "tests/e2e/generate-client/examples/*/src/api/", "tests/e2e/generate-client/examples/*/generators/", diff --git a/.oxlintrc.json b/.oxlintrc.json index 3eb39de833..09a38a8749 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -91,7 +91,8 @@ "packages/cli/src/commands/score/**", "packages/cli/src/commands/generate-spec/**", "packages/cli/src/commands/drift/**", - "packages/client-generator/**" + "packages/client-generator/**", + "packages/recheck/**" ], "rules": { "sonarjs/cognitive-complexity": ["error", 82] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c30138fdf..82e3f08112 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,6 +139,25 @@ To make changes to documentation: 1. Add the link to the rule page to the [built-in rules list](docs/@v2/rules/built-in-rules.md) and the [sidebar](docs/@v2/v2.sidebars.yaml). 1. Update the rulesets pages and [ruleset templates](docs/@v2/rules/ruleset-templates.md). +### Recheck engine + +Recheck is the markdown and prose linting engine in `packages/recheck`. +To add or change a lint rule, see [`packages/recheck/src/rules/CONTRIBUTING.md`](packages/recheck/src/rules/CONTRIBUTING.md). + +The example configs in `packages/recheck/examples/*.yaml` are generated from the presets. +When you change a preset, the generator, or an appendix in `examples/appendices/`, regenerate them and commit the result: + +```bash +npm run compile +node packages/recheck/scripts/generate-examples.mjs +``` + +The generator reads the built `lib/`, so compile first. +If you skip this step, `examples-drift.test.ts` fails. + +`packages/recheck/src/data/markdoc-realm-schema.ts` is generated from the Realm theme source in the Redocly monorepo. +Regenerate it there with `scripts/generate-markdoc-schema.mjs` pointed at this checkout, and copy the result over. + ### Update Redoc When updating Redoc, recompute the subresource integrity [SRI](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) (`redocStandaloneSri` in [package.ts](./packages/cli/src/utils/package.ts)): @@ -415,7 +434,7 @@ To add an entry: - **`docs`**: contains the documentation source files. When changes to the documentation are merged, they automatically get published on the [Redocly docs website](https://redocly.com/docs/cli/). -- **`packages`**: contains the source code. It consists of five packages - CLI, core, respect-core, reunite-integration, and client-generator. The codebase is written in Typescript. +- **`packages`**: contains the source code. It consists of six packages - CLI, core, respect-core, reunite-integration, client-generator, and recheck. The codebase is written in Typescript. - **`packages/cli`**: contains Redocly CLI commands and utils. More details [in the README](./README.md) file. - **`packages/cli/src`**: contains CLI package source code. - **`packages/cli/src/commands`**: contains CLI commands functions. @@ -436,6 +455,8 @@ To add an entry: - **`packages/client-generator`**: contains the client and SDK generators. + - **`packages/recheck`**: contains the Recheck markdown and prose linting engine. + - **`resources`**: contains some example API descriptions and configuration files that might be useful for testing. - **`cookbook`**: contains the community cookbook — shared rulesets, configuration, custom plugins, and other Redocly CLI additions. It is not part of the build or test suite. diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index 2ebca466ba..fa93d04a3e 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -17,6 +17,10 @@ Docs rendering commands: - [`translate`](translate.md) Generate translation keys for a Redocly Realm, Reef, or Revel project. - [`eject`](eject.md) Eject and modify components from the core theme in a Redocly Realm, Reef, or Revel project. +Docs linting commands: + +- [`recheck`](recheck.md) Lint Markdown prose and structure from the `recheck` block. + CI and delivery commands: - [`bundle`](bundle.md) Bundle an API description. diff --git a/docs/@v2/commands/recheck.md b/docs/@v2/commands/recheck.md new file mode 100644 index 0000000000..cbfcbb54f9 --- /dev/null +++ b/docs/@v2/commands/recheck.md @@ -0,0 +1,126 @@ +# `recheck` + +## Introduction + +The `recheck` command lints Markdown files for prose and structure problems. +It checks headings, sentences, links, images, and Markdoc tags against a set of rules. + +Rules come from presets, such as `recheck/markdown`, that you add to the root `extends` in `redocly.yaml`. +The [`recheck` block](../configuration/reference/recheck.md) adjusts those rules. +With no `redocly.yaml`, the command uses `recheck/markdown`. +With a `redocly.yaml` that has neither, the command checks nothing and says so. +The command reads the root `recheck` config; settings under `apis.` are not used. + +## Usage + +```bash +redocly recheck +redocly recheck ... +redocly recheck [--fix] +redocly recheck [--readability] +redocly recheck [--generate-baseline] +redocly recheck [--generate-markdoc-schema] [--from=...] [--output=] [--check] +redocly recheck --help +``` + +{% admonition type="info" name="One action per run" %} +`--readability`, `--generate-baseline`, and `--generate-markdoc-schema` each replace the default lint action. +Use at most one of them in a run. +`--fix` works with the default lint action only. +{% /admonition %} + +## Options + +| Option | Type | Description | +| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| paths | [string] | Files or directories to lint. Default value is the current directory. | +| --check | boolean | Fail when the generated schema differs from the file in `--output`. Use with `--generate-markdoc-schema`. | +| --config | string | Path to the [configuration file](../configuration/index.md). | +| --fix | boolean | Apply fixes to the Markdown files. Alias: `-f`. | +| --format | string | Format for the report.
**Possible values:** `table`, `json`, `sarif`, `github-actions`. Default value is `table`. | +| --from | [string] | Module paths to read Markdoc tags from. Use with `--generate-markdoc-schema`. | +| --generate-baseline | boolean | Write a baseline file from the current errors. | +| --generate-markdoc-schema | boolean | Generate a Markdoc tag schema from theme modules. Needs `--from` and `--output`. | +| --help | boolean | Show help. | +| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --max-problems | number | Maximum number of problems in the report; applies to every format. | +| --output | string | Output file for the generated schema. | +| --readability | boolean | Report readability scores instead of lint findings. | +| --rule | [string] | Run only these rules. Alias: `-r`. | +| --skip-rule | [string] | Skip these rules. | +| --stats | boolean | Print statistics per rule. Alias: `-s`. | +| --summary | string | Print a summary of the run.
**Possible values:** `json`, `text`. | +| --summary-path | string | Write the summary to this file. | +| --tags | [string] | Run only rules with these tags. | +| --version | boolean | Show version number. | + +## Examples + +### Lint a folder + +Add a preset to the root `extends`: + +```yaml +extends: + - recheck/markdown +``` + +Then run the command on a folder: + +```bash +redocly recheck docs +``` + +The command lints every Markdown file under `docs` with the rules from `recheck/markdown`. +The report goes to stdout and progress messages go to stderr. + +### Turn off a rule + +Set the rule to `off` in the `recheck` block: + +```yaml +extends: + - recheck/markdown +recheck: + rules: + recheck/line-length: off +``` + +The run applies every rule from `recheck/markdown` except `recheck/line-length`. + +### Annotate a pull request + +```bash +redocly recheck docs --format=github-actions +``` + +In a GitHub Actions workflow, this format adds each finding as an annotation on the changed line. +An error becomes `::error`, a warning `::warning`, and an info finding `::notice`. +Each finding is one line of output: + +```text +::error title=recheck/single-h1,file=docs/index.md,line=2,endLine=2,col=1,endColumn=1::Multiple top-level headings in the same document +``` + +### Use a baseline + +A baseline records the current errors. +Later runs report only errors that the baseline does not list. + +```bash +redocly recheck docs --generate-baseline +``` + +The command writes `.redocly.recheck-baseline.yaml` next to `redocly.yaml`. +Commit the file. +Later runs pick it up automatically. + +After you fix errors, generate the baseline again and commit the smaller file. + +### Check readability + +```bash +redocly recheck docs --readability +``` + +The command prints readability scores for each Markdown file instead of lint findings. diff --git a/docs/@v2/configuration/reference/extends.md b/docs/@v2/configuration/reference/extends.md index 5719c7d4e7..64e2528fe7 100644 --- a/docs/@v2/configuration/reference/extends.md +++ b/docs/@v2/configuration/reference/extends.md @@ -9,6 +9,16 @@ Multiple values are supported, and can include: - configuration defined in a [custom plugin](../../custom-plugins/index.md) - a path or URL to another `redocly.yaml` file +Entries that start with `recheck/`, such as `recheck/markdown`, are Recheck presets for [`redocly recheck`](../../commands/recheck.md). +They merge like any other entry, in the order listed: a later entry overrides an earlier one. +Other commands skip them. +A custom plugin cannot use the id `recheck`. + +The [`recheck` block](recheck.md) of the file that lists the presets merges on top of them. +The `recheck` command reads the presets in the root `extends` and in the files that the root `extends` lists. +It does not read them from an API's `extends`. +The `lint` command reads no rules from them. + Extends is useful if you use a common ruleset across multiple projects. Define a ruleset in one location, and each project can `extend` it, with or without modification. diff --git a/docs/@v2/configuration/reference/recheck.md b/docs/@v2/configuration/reference/recheck.md new file mode 100644 index 0000000000..f2415d5a36 --- /dev/null +++ b/docs/@v2/configuration/reference/recheck.md @@ -0,0 +1,232 @@ +# `recheck` + +## Introduction + +The `recheck` block configures the [`recheck`](../../commands/recheck.md) command. +It adjusts the rules that the Recheck presets provide, and it sets file excludes and Markdoc parsing. + +The block does not accept `extends`. +Add Recheck presets, such as `recheck/markdown`, to the root `extends` of `redocly.yaml`. +The block merges on top of the presets that the same file lists in `extends`. +Presets and shared config files merge in `extends` order, so a preset listed after a shared file overrides the `recheck` block of that file. +An unknown preset name, such as `recheck/nope`, is a configuration error. + +## Options + +{% table %} + +- Option +- Type +- Description + +--- + +- rules +- [Rules object](#rules-object) +- Rule names mapped to a severity or to a [rule object](#rule-object). + +--- + +- excludes +- [string] +- File globs that every rule in this block skips. + Recheck adds this list to each rule's own `excludes`. + +--- + +- markdoc +- boolean or [Markdoc object](#markdoc-object) +- Turn on Markdoc-aware parsing. + `true` is the same as `{ schema: realm }`. + +{% /table %} + +### Rules object + +Each key is a rule name. +Each value is a severity string (`off`, `info`, `warn`, or `error`) or a [rule object](#rule-object). +Use a severity string to change the severity of a preset rule or to turn it off. + +### Rule object + +{% table %} + +- Option +- Type +- Description + +--- + +- severity +- string +- **REQUIRED**. + One of `off`, `info`, `warn`, or `error`. + +--- + +- message +- string +- The message that the rule reports. + **REQUIRED** for a scope rule, which uses an assertion such as `pattern` or `metric`. + A token rule, which uses an assertion such as `line-length`, takes its default message when the entry has none. + +--- + +- assertions +- object +- **REQUIRED**. + The checks that the rule runs. + Each assertion type has its own options. + +--- + +- fix +- boolean +- Let `--fix` apply this rule's fix. + Only some assertion types offer a fix. + +--- + +- tags +- [string] +- Labels for the `--tags` option. + +--- + +- description +- string +- A note about the purpose of the rule. + +--- + +- link +- string +- A URL with more detail about the rule. + +--- + +- scope +- string or [string] +- Where the rule runs: `all`, `heading`, `sentence`, `paragraph`, `code`, or `raw`. + +--- + +- appliesTo +- [string] +- File globs that the rule runs on. + Default value is every Markdown file. + +--- + +- excludes +- [string] +- File globs that the rule skips. + +--- + +- exceptions +- [Exceptions object](#exceptions-object) +- Files and lines that the rule does not check. + +{% /table %} + +### Exceptions object + +{% table %} + +- Option +- Type +- Description + +--- + +- files +- [string] +- File globs that the rule skips. + +--- + +- lines +- [string] +- Line patterns that the rule skips. + +{% /table %} + +### Markdoc object + +{% table %} + +- Option +- Type +- Description + +--- + +- schema +- `realm` or `false` +- **REQUIRED**. + `realm` validates tags against the built-in Realm schema. + `false` parses and pairs Markdoc tags without a schema check. + +--- + +- extend +- [Extend object](#extend-object) +- Add your own tags on top of the chosen schema. + +{% /table %} + +### Extend object + +{% table %} + +- Option +- Type +- Description + +--- + +- tags +- object +- Tag names mapped to tag schemas. + Set `tags`, `tagsFile`, or both. + +--- + +- tagsFile +- string +- Path to a YAML file that maps tag names to tag schemas. + +{% /table %} + +## Example + +```yaml +extends: + - recheck/markdown +recheck: + excludes: + - CHANGELOG.md + markdoc: true + rules: + recheck/line-length: off + recheck/readability-floor: + severity: warn + message: 'Readability (%s) is %s; expected between %s and %s.' + assertions: + metric: + formula: flesch-reading-ease + min: 30 +``` + +This config adds the `recheck/markdown` preset in the root `extends`. +The `recheck` block skips `CHANGELOG.md`, turns on Markdoc-aware parsing, turns off one rule, and adds one rule. + +## Related options + +- [extends](./extends.md) lists the Recheck presets at the root of `redocly.yaml`. +- [rules](./rules.md) configures API linting rules, which are separate from `recheck.rules`. + +## Resources + +- Command reference for [`recheck`](../../commands/recheck.md). diff --git a/docs/@v2/custom-plugins/index.md b/docs/@v2/custom-plugins/index.md index 08ea354164..2645acc25e 100644 --- a/docs/@v2/custom-plugins/index.md +++ b/docs/@v2/custom-plugins/index.md @@ -53,6 +53,9 @@ export default function myPlugin() { } ``` +[Recheck presets](../commands/recheck.md) use the id `recheck`. +A custom plugin with that id fails to load. + ## Supported formats Everything that is exported from a plugin relates to one of the supported document formats, such as OpenAPI v3. Plugins work by exporting an object containing a key-value mapping from a document format and version (`oas2` or `oas3` are supported) to an extension object (rules, preprocessors, decorators). diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 4fc5e07074..baebda623a 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -14,6 +14,8 @@ page: commands/bundle.md - label: check-config page: commands/check-config.md + - label: recheck + page: commands/recheck.md - label: drift page: commands/drift.md - label: eject @@ -233,6 +235,7 @@ - page: configuration/reference/extends.md - page: configuration/reference/plugins.md - page: configuration/reference/preprocessors.md + - page: configuration/reference/recheck.md - page: configuration/reference/resolve.md - page: configuration/reference/rules.md - group: Custom plugins diff --git a/package-lock.json b/package-lock.json index ddd9c4759d..8d9feb9656 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1757,6 +1757,32 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@markdoc/markdoc": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@markdoc/markdoc/-/markdoc-0.5.10.tgz", + "integrity": "sha512-5HwkG2cLG/ivffeJlxGmraJ9erYfsaYz9rjG1KCf46PLy+yhrBQWw26h69GewNUHbyKNW6FrXuDYgbl2kvIYdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.7.0" + }, + "optionalDependencies": { + "@types/linkify-it": "^3.0.1", + "@types/markdown-it": "12.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -3728,6 +3754,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@redocly/recheck": { + "resolved": "packages/recheck", + "link": true + }, "node_modules/@redocly/respect-core": { "resolved": "packages/respect-core", "link": true @@ -4429,6 +4459,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4464,6 +4503,13 @@ "integrity": "sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g==", "dev": true }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-pointer": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/@types/json-pointer/-/json-pointer-1.0.34.tgz", @@ -4477,6 +4523,32 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, "node_modules/@types/mdast": { "version": "3.0.12", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.12.tgz", @@ -4486,6 +4558,20 @@ "@types/unist": "^2" } }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", @@ -4563,8 +4649,7 @@ "node_modules/@types/unist": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.7.tgz", - "integrity": "sha512-cputDpIbFgLUaGQn6Vqg3/YsJwxUwHLO13v3i5ouxT4lat0khip9AEWxtERujXV9wxIB1EyF97BSJFt6vpdI8g==", - "dev": true + "integrity": "sha512-cputDpIbFgLUaGQn6Vqg3/YsJwxUwHLO13v3i5ouxT4lat0khip9AEWxtERujXV9wxIB1EyF97BSJFt6vpdI8g==" }, "node_modules/@types/yargs": { "version": "17.0.32", @@ -5769,6 +5854,15 @@ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==" }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -5992,7 +6086,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6019,6 +6112,29 @@ "integrity": "sha512-m8FnyHXV1QX+S1cl+KPFDIl6NMkxtKsy6+U/aYyjrOqWMuwAwYWu7ePqrsUHtDR5Y8Yk2pi/KIDSgF+vT4cPOQ==", "dev": true }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decode-named-character-reference/node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6041,7 +6157,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6067,6 +6182,30 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dictionary-en": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/dictionary-en/-/dictionary-en-4.0.0.tgz", + "integrity": "sha512-3NHnE1uq33ZE/CIwaZ6gqxa4BnglHnxeAcTM0GJ7cmtRGcvX9InMK/IqLtYcUMFCUpUNgybH+DzkqdgAo3F1zg==", + "dev": true, + "license": "(MIT AND BSD)", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -6944,6 +7083,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fd-package-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", @@ -7044,6 +7196,14 @@ "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/formatly": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.7.1.tgz", @@ -7155,10 +7315,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "dev": true, + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", "license": "MIT", "engines": { "node": ">=18" @@ -7838,7 +7997,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, "funding": [ { "type": "github", @@ -8110,6 +8268,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9330,6 +9504,159 @@ "parse-entities": "^2.0.0" } }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-directive/node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/micromark-extension-directive/node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/micromark-extension-directive/node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/micromark-extension-directive/node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/micromark-extension-directive/node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/micromark-extension-directive/node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/micromark-extension-directive/node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/micromark-extension-frontmatter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", + "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", + "license": "MIT", + "dependencies": { + "fault": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-extension-gfm": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-0.3.3.tgz", @@ -9361,6 +9688,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-extension-gfm-strikethrough": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-0.6.5.tgz", @@ -9410,47 +9757,436 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/milliparsec": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/milliparsec/-/milliparsec-4.0.0.tgz", - "integrity": "sha512-/wk9d4Z6/9ZvoEH/6BI4TrTCgmkpZPuSRN/6fI9aUHOfXdNTuj/VhLS7d+NqG26bi6L9YmGXutVYvWC8zQ0qtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.4.tgz", - "integrity": "sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==", - "dev": true, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", "funding": [ - "https://github.com/sponsors/broofa" + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], "license": "MIT", - "bin": { - "mime": "bin/cli.js" - }, - "engines": { - "node": ">=16" + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/mime-db": { - "version": "1.54.0", + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/milliparsec": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/milliparsec/-/milliparsec-4.0.0.tgz", + "integrity": "sha512-/wk9d4Z6/9ZvoEH/6BI4TrTCgmkpZPuSRN/6fI9aUHOfXdNTuj/VhLS7d+NqG26bi6L9YmGXutVYvWC8zQ0qtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/mime": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.4.tgz", + "integrity": "sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, @@ -9630,7 +10366,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/msw": { @@ -9859,6 +10594,16 @@ "node": ">=18" } }, + "node_modules/nspell": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/nspell/-/nspell-2.1.5.tgz", + "integrity": "sha512-PSStyugKMiD9mHmqI/CR5xXrSIGejUXPlo88FBRq5Og1kO5QwQ5Ilu8D8O5I/SHpoS+mibpw6uKA8rd3vXd2Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^2.0.0" + } + }, "node_modules/oas-kit-common": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz", @@ -13086,6 +13831,7 @@ "@redocly/cli-otel": "0.3.7", "@redocly/client-generator": "0.4.20", "@redocly/openapi-core": "2.57.0", + "@redocly/recheck": "2.51.0", "@redocly/respect-core": "2.57.0", "@redocly/reunite-integration": "2.57.0", "@types/cookie": "0.6.0", @@ -13250,6 +13996,183 @@ "integrity": "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA==", "license": "MIT" }, + "packages/recheck": { + "name": "@redocly/recheck", + "version": "2.51.0", + "license": "MIT", + "dependencies": { + "@redocly/ajv": "^8.18.3", + "@redocly/openapi-core": "2.57.0", + "ajv-formats": "^3.0.1", + "js-yaml": "^4.3.1", + "micromark": "^4.0.2", + "micromark-extension-directive": "^4.0.0", + "micromark-extension-frontmatter": "^2.0.0", + "micromark-extension-gfm-autolink-literal": "^2.1.0", + "micromark-extension-gfm-footnote": "^2.1.0", + "micromark-extension-gfm-table": "^2.1.1", + "micromark-extension-math": "^3.1.0", + "micromark-util-symbol": "^2.0.0", + "picomatch": "^4.0.4", + "string-width": "^8.2.1" + }, + "devDependencies": { + "@markdoc/markdoc": "^0.5.2", + "@types/js-yaml": "^4.0.9", + "@types/picomatch": "^2.3.4", + "dictionary-en": "^4.0.0", + "micromark-util-types": "^2.0.2", + "nspell": "^2.1.5", + "typescript": "6.0.2" + }, + "engines": { + "node": ">=22.12.0 || >=20.19.0 <21.0.0", + "npm": ">=10" + }, + "peerDependencies": { + "dictionary-en": "^4.0.0", + "nspell": "^2.1.5" + }, + "peerDependenciesMeta": { + "dictionary-en": { + "optional": true + }, + "nspell": { + "optional": true + } + } + }, + "packages/recheck/node_modules/@types/picomatch": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.4.tgz", + "integrity": "sha512-0so8lU8O5zatZS/2Fi4zrwks+vZv7e0dygrgEZXljODXBig97l4cPQD+9LabXfGJOWwoRkTVz6Q4edZvD12UOA==", + "dev": true, + "license": "MIT" + }, + "packages/recheck/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "packages/recheck/node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "packages/recheck/node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "packages/recheck/node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "packages/recheck/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/recheck/node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/recheck/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "packages/respect-core": { "name": "@redocly/respect-core", "version": "2.57.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index a22d7b0839..0c97fe3017 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,6 +48,7 @@ "@redocly/cli-otel": "0.3.7", "@redocly/client-generator": "0.4.20", "@redocly/openapi-core": "2.57.0", + "@redocly/recheck": "2.51.0", "@redocly/respect-core": "2.57.0", "@redocly/reunite-integration": "2.57.0", "@types/cookie": "0.6.0", diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index db420a8936..8731490043 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -99,6 +99,30 @@ cpSync( { recursive: true } ); +// `dictionary-en` reads its Hunspell data with `new URL(..., import.meta.url)`, +// so the two data files must sit next to the chunk esbuild bundled it into. +const dictionaryEntry = 'node_modules/dictionary-en/index.js'; +let dictionaryOutput; +let dictionaryInput; +for (const [outputPath, chunk] of Object.entries(result.metafile.outputs)) { + const match = Object.keys(chunk.inputs).find((inputPath) => + inputPath.replace(/\\/g, '/').endsWith(dictionaryEntry) + ); + if (match) { + dictionaryOutput = outputPath; + dictionaryInput = match; + break; + } +} +if (!dictionaryOutput) { + throw new Error('dictionary-en is no longer bundled — drop the data-file copy below'); +} +const dictionaryRoot = path.dirname(path.resolve(packageDir, dictionaryInput)); +const dictionaryOutDir = path.dirname(path.resolve(packageDir, dictionaryOutput)); +for (const dataFile of ['index.aff', 'index.dic']) { + cpSync(path.join(dictionaryRoot, dataFile), path.join(dictionaryOutDir, dataFile)); +} + function findLicenseText(pkgRoot) { for (const filename of ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENCE', 'LICENCE.md']) { const licensePath = path.join(pkgRoot, filename); diff --git a/packages/cli/src/__tests__/commands/lint.test.ts b/packages/cli/src/__tests__/commands/lint.test.ts index 55d81e1d78..a856db0f6e 100644 --- a/packages/cli/src/__tests__/commands/lint.test.ts +++ b/packages/cli/src/__tests__/commands/lint.test.ts @@ -93,7 +93,8 @@ describe('handleLint', () => { format: 'codeframe', 'lint-config': 'off', }, - '2.0.0' + '2.0.0', + undefined ); expect(getFallbackApisOrExit).toHaveBeenCalled(); }); @@ -112,7 +113,8 @@ describe('handleLint', () => { format: 'codeframe', 'lint-config': 'off', }, - '2.0.0' + '2.0.0', + undefined ); }); diff --git a/packages/cli/src/__tests__/fixtures/config.ts b/packages/cli/src/__tests__/fixtures/config.ts index 595ce5e477..a871382e36 100644 --- a/packages/cli/src/__tests__/fixtures/config.ts +++ b/packages/cli/src/__tests__/fixtures/config.ts @@ -58,6 +58,7 @@ export const configFixture: Config = { openrpc1: {}, graphql: {}, }, + recheck: { rules: {} }, resolveIgnore: vi.fn(), addProblemToIgnore: vi.fn(), extendTypes: vi.fn(), diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index bd0e4ae4ba..89d1b275e1 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -146,7 +146,12 @@ export async function handleLintConfig(argv: Exact, version: string return; } - if (argv.format === 'json' || argv.format === 'junit' || argv.format === 'checkstyle') { + if ( + argv.format === 'json' || + argv.format === 'junit' || + argv.format === 'checkstyle' || + argv.format === 'sarif' + ) { // these are single-document formats, so a separate config-lint document would break the output return; } @@ -161,7 +166,8 @@ export async function handleLintConfig(argv: Exact, version: string const fileTotals = getTotals(problems); formatProblems(problems, { - format: argv.format, + // These are config problems, and config output has no `table` format (the `recheck` default). + format: argv.format === 'table' ? undefined : argv.format, maxProblems: argv['max-problems'], totals: fileTotals, version, diff --git a/packages/cli/src/commands/recheck/__tests__/capture-logger.ts b/packages/cli/src/commands/recheck/__tests__/capture-logger.ts new file mode 100644 index 0000000000..7243c0b635 --- /dev/null +++ b/packages/cli/src/commands/recheck/__tests__/capture-logger.ts @@ -0,0 +1,13 @@ +import { logger } from '@redocly/openapi-core'; +import { vi } from 'vitest'; + +// `stderr` collects the info, warn, and error lines; `stdout` collects the output lines. +export function captureLogger(): { stderr: string[]; stdout: string[] } { + const stderr: string[] = []; + const stdout: string[] = []; + vi.spyOn(logger, 'info').mockImplementation((line) => void stderr.push(line)); + vi.spyOn(logger, 'warn').mockImplementation((line) => void stderr.push(line)); + vi.spyOn(logger, 'error').mockImplementation((line) => void stderr.push(line)); + vi.spyOn(logger, 'output').mockImplementation((line) => void stdout.push(line)); + return { stderr, stdout }; +} diff --git a/packages/cli/src/commands/recheck/__tests__/index.test.ts b/packages/cli/src/commands/recheck/__tests__/index.test.ts new file mode 100644 index 0000000000..39f39f8b25 --- /dev/null +++ b/packages/cli/src/commands/recheck/__tests__/index.test.ts @@ -0,0 +1,216 @@ +import { AbortFlowError, createConfig, type Config } from '@redocly/openapi-core'; +import type { RecheckBlock } from '@redocly/recheck'; +import { recheckPresetsPlugin } from '@redocly/recheck/presets'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { configFixture } from '../../../__tests__/fixtures/config.js'; +import { handleRecheck } from '../index.js'; +import type { RecheckArgv } from '../types.js'; +import { captureLogger } from './capture-logger.js'; + +const NO_CONFIG_NOTICE = + 'No recheck configuration in redocly.yaml; nothing to check. Add a recheck/* preset to extends or a recheck block.\n'; +const PER_API_WARNING = + 'Recheck settings under apis.main are not used; the command reads the root config.\n'; + +// Two adjacent top-level headings break `single-h1` and `blanks-around-headings`. +const DOCUMENT = '# One\n# Two\n'; + +describe('handleRecheck', () => { + let dir: string; + let output: { stderr: string[]; stdout: string[] }; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'recheck-handler-')); + await fs.mkdir(path.join(dir, 'docs')); + await fs.writeFile(path.join(dir, 'docs', 'index.md'), DOCUMENT); + output = captureLogger(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + function run( + recheck: RecheckBlock, + configPath: string | undefined, + parsed?: unknown + ): Promise { + const argv: RecheckArgv = { + format: 'table', + paths: [path.join(dir, 'docs')], + }; + const document = parsed === undefined ? undefined : { parsed }; + const config = { + ...configFixture, + recheck, + configPath, + document, + } as Config; + return handleRecheck({ argv, config, version: 'test' }); + } + + it('runs the rules of a preset from the root extends', async () => { + const config = await createConfig( + { extends: ['recheck/markdown'] }, + { configPath: path.join(dir, 'redocly.yaml'), plugins: [recheckPresetsPlugin] } + ); + const argv: RecheckArgv = { format: 'table', paths: [path.join(dir, 'docs')] }; + await expect(handleRecheck({ argv, config, version: 'test' })).rejects.toThrow(AbortFlowError); + const report = output.stdout.join(''); + expect(report).toContain('single-h1'); + expect(report).toContain('blanks-around-headings'); + }); + + it('runs only the rules of the block', async () => { + const rules = { + 'recheck/single-h1': { + severity: 'error' as const, + assertions: { 'single-h1': {} }, + }, + }; + await expect(run({ rules }, path.join(dir, 'redocly.yaml'))).rejects.toThrow(AbortFlowError); + const report = output.stdout.join(''); + expect(report).toContain('single-h1'); + expect(report).not.toContain('blanks-around-headings'); + }); + + it('runs a block that has settings and no rules', async () => { + await run({ rules: {}, excludes: ['**/skip.md'] }, path.join(dir, 'redocly.yaml')); + expect(output.stderr).not.toContain(NO_CONFIG_NOTICE); + expect(output.stderr.join('')).toContain('Running 0 rule(s)'); + }); + + it('checks nothing when redocly.yaml has no recheck configuration', async () => { + await run({ rules: {} }, path.join(dir, 'redocly.yaml')); + expect(output.stderr).toEqual([NO_CONFIG_NOTICE]); + expect(output.stdout).toEqual([]); + }); + + it('uses recheck/markdown when there is no redocly.yaml', async () => { + await expect(run({ rules: {} }, undefined)).rejects.toThrow(AbortFlowError); + expect(output.stderr[0]).toBe('No redocly.yaml found; using recheck/markdown.\n'); + const report = output.stdout.join(''); + expect(report).toContain('single-h1'); + expect(report).toContain('blanks-around-headings'); + }); + + it('checks nothing when `rules` is null and the block has no settings', async () => { + const block = { rules: null } as unknown as RecheckBlock; + await run(block, path.join(dir, 'redocly.yaml')); + expect(output.stderr).toEqual([NO_CONFIG_NOTICE]); + expect(output.stdout).toEqual([]); + }); + + it('reports a block that is not an object', async () => { + const block = 5 as unknown as RecheckBlock; + await expect(run(block, path.join(dir, 'redocly.yaml'))).rejects.toThrow(AbortFlowError); + expect(output.stderr).toEqual([ + 'The recheck configuration is not valid:\n', + ' recheck: `recheck` must be an object\n', + ]); + }); + + describe('run options', () => { + async function runWith(argvOverrides: Partial): Promise { + const argv: RecheckArgv = { + format: 'table', + paths: [path.join(dir, 'docs')], + ...argvOverrides, + }; + const config = await createConfig( + { extends: ['recheck/markdown'] }, + { configPath: path.join(dir, 'redocly.yaml'), plugins: [recheckPresetsPlugin] } + ); + return handleRecheck({ argv, config, version: 'test' }); + } + + it('runs only the rules named by --rule', async () => { + await expect(runWith({ rule: ['single-h1'] })).rejects.toThrow(AbortFlowError); + const report = output.stdout.join(''); + expect(report).toContain('single-h1'); + expect(report).not.toContain('blanks-around-headings'); + }); + + it('skips the rules named by --skip-rule', async () => { + await expect(runWith({ 'skip-rule': ['single-h1'] })).rejects.toThrow(AbortFlowError); + const report = output.stdout.join(''); + expect(report).toContain('blanks-around-headings'); + expect(report).not.toContain('single-h1'); + }); + + it('rewrites the files and reports the fixes with --fix', async () => { + const file = path.join(dir, 'docs', 'index.md'); + // The run still reports the findings it fixed, so it exits 1. + await expect(runWith({ fix: true, rule: ['blanks-around-headings'] })).rejects.toThrow( + AbortFlowError + ); + expect(await fs.readFile(file, 'utf8')).toBe('# One\n\n# Two\n'); + expect(output.stderr.join('')).toContain('Auto-fixed'); + }); + + it('fails the run with the engine message for an unknown --rule', async () => { + await expect(runWith({ rule: ['no-such-rule'] })).rejects.toThrow(AbortFlowError); + expect(output.stderr.join('')).toContain( + 'no rule in this configuration matches "no-such-rule"' + ); + }); + + it('skips an API description and warns', async () => { + const api = path.join(dir, 'openapi.yaml'); + await fs.writeFile(api, 'openapi: 3.0.0\ninfo:\n title: T\n version: 1.0.0\npaths: {}\n'); + await runWith({ paths: [api] }); + expect(output.stderr).toEqual([ + `API descriptions are linted from the next release; skipped ${api}\n`, + ]); + expect(output.stdout).toEqual([]); + }); + + it('applies the baseline file next to redocly.yaml once generated', async () => { + await runWith({ 'generate-baseline': true }); + expect(await fs.readFile(path.join(dir, '.redocly.recheck-baseline.yaml'), 'utf8')).toContain( + 'recheck/single-h1' + ); + output.stderr.length = 0; + await expect(runWith({})).resolves.toBeUndefined(); + expect(output.stderr.join('')).toMatch(/Baseline: \d+ matched, 0 new, 0 stale/); + }); + }); + + describe('per-API recheck settings', () => { + const SETTINGS_ONLY: RecheckBlock = { rules: {}, excludes: ['**/skip.md'] }; + + function runWithApi(api: Record): Promise { + const parsed = { apis: { main: { root: 'openapi.yaml', ...api } } }; + return run(SETTINGS_ONLY, path.join(dir, 'redocly.yaml'), parsed); + } + + it('warns once about a recheck block under an API', async () => { + await runWithApi({ recheck: { rules: {} } }); + expect(output.stderr.filter((line) => line === PER_API_WARNING)).toHaveLength(1); + }); + + it('warns about a recheck preset in the extends of an API', async () => { + await runWithApi({ extends: ['recheck/markdown'] }); + expect(output.stderr.filter((line) => line === PER_API_WARNING)).toHaveLength(1); + }); + + it('warns about a per-API preset when the root config has no recheck settings', async () => { + const parsed = { + apis: { main: { root: 'openapi.yaml', extends: ['recheck/markdown'] } }, + }; + await run({ rules: {} }, path.join(dir, 'redocly.yaml'), parsed); + expect(output.stderr).toEqual([PER_API_WARNING, NO_CONFIG_NOTICE]); + expect(output.stdout).toEqual([]); + }); + + it('does not warn about an API with no recheck settings', async () => { + await runWithApi({ extends: ['recommended'] }); + expect(output.stderr.join('')).not.toContain('Recheck settings under apis.'); + }); + }); +}); diff --git a/packages/cli/src/commands/recheck/__tests__/print.test.ts b/packages/cli/src/commands/recheck/__tests__/print.test.ts new file mode 100644 index 0000000000..1db25ac24e --- /dev/null +++ b/packages/cli/src/commands/recheck/__tests__/print.test.ts @@ -0,0 +1,439 @@ +import { + Timer, + type BaselineRunResult, + type Fix, + type LintRunReport, + type MarkdocSchemaResult, + type Problem, + type ReadabilityRunResult, +} from '@redocly/recheck'; +import { green, yellow } from 'colorette'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + printBaselineRun, + printLintRun, + printMarkdocSchemaRun, + printReadabilityRun, +} from '../print.js'; +import { captureLogger } from './capture-logger.js'; + +const EMPTY_REPORT: LintRunReport = { + roots: ['docs'], + ruleCount: 3, + disabledRuleCount: 0, + filesFound: 0, + unreadableFiles: [], + scannedFileCount: 0, + empty: true, + problems: [], +}; + +const LINTED_REPORT: LintRunReport = { + ...EMPTY_REPORT, + filesFound: 2, + scannedFileCount: 2, + empty: false, +}; + +const WARNING: Problem = { + file: 'docs/index.md', + line: 3, + column: 1, + text: '', + match: '', + ruleName: 'recheck/no-todos', + severity: 'warn', + message: 'TODO found.', +}; + +const ERROR: Problem = { + file: 'docs/index.md', + line: 3, + column: 1, + text: '', + match: '', + ruleName: 'recheck/line-length', + severity: 'error', + message: 'Line too long.', +}; + +const FIX: Fix = { + file: 'docs/index.md', + ruleName: 'recheck/no-trailing-spaces', + lineNumber: 2, + editColumn: 24, + deleteCount: 3, +}; + +afterEach(() => vi.restoreAllMocks()); + +describe('printLintRun', () => { + it('prints a completed run with one error and exits 1', async () => { + const { stderr, stdout } = captureLogger(); + const code = await printLintRun( + { status: 'completed', ...LINTED_REPORT, problems: [ERROR] }, + { format: 'table' }, + new Timer() + ); + expect(code).toBe(1); + expect(stderr.join('')).toContain('Running 3 rule(s)'); + expect(stderr.join('')).toContain('Found 1 error(s). Exiting with code 1.'); + expect(stdout.join('')).toContain('line-length'); + }); + + it('prints the unknown-rule result and exits 1', async () => { + const { stderr } = captureLogger(); + const code = await printLintRun( + { status: 'unknown-rule', message: 'no rule matches x', available: ['recheck/line-length'] }, + { format: 'table' }, + new Timer() + ); + expect(code).toBe(1); + expect(stderr.join('')).toContain('no rule matches x'); + expect(stderr.join('')).toContain('Available: recheck/line-length'); + }); + + it('prints an empty run with an empty report and exits 0', async () => { + const { stderr } = captureLogger(); + const code = await printLintRun( + { status: 'completed', ...EMPTY_REPORT }, + { format: 'table' }, + new Timer() + ); + expect(code).toBe(0); + const printed = stderr.join(''); + expect(printed).toContain('No markdown files found in: docs'); + expect(printed).toContain('\n Annotations prepared: 0\n'); + expect(printed).toMatch(/ {3}Completed in \d+ms\n$/); + expect(printed).not.toContain('Found 0 markdown file(s)'); + }); + + it('prints a failed result with the elapsed time and exits 1', async () => { + const { stderr } = captureLogger(); + const code = await printLintRun( + { status: 'failed', message: 'baseline file is not valid', report: EMPTY_REPORT }, + { format: 'table' }, + new Timer() + ); + expect(code).toBe(1); + expect(stderr.join('')).toContain('💥 Error running recheck: baseline file is not valid'); + expect(stderr.join('')).toMatch(/ {3}Failed after \d+ms\n$/); + }); + + it('prints the applied fixes and the count of skipped fixes', async () => { + const { stderr } = captureLogger(); + const code = await printLintRun( + { status: 'completed', ...LINTED_REPORT, fixes: { applied: [FIX], skippedCount: 2 } }, + { format: 'table' }, + new Timer() + ); + expect(code).toBe(0); + const printed = stderr.join(''); + expect(printed).toContain('✅ Auto-fixed 1 issue(s)!'); + expect(printed).toContain('Line 2 (recheck/no-trailing-spaces)'); + expect(printed).toContain('⚠️ 2 proposed fix(es) were not applied'); + }); + + it('warns about each unreadable file and the count of skipped files', async () => { + const { stderr } = captureLogger(); + await printLintRun( + { + status: 'completed', + ...LINTED_REPORT, + filesFound: 3, + scannedFileCount: 1, + unreadableFiles: ['docs/a.md', 'docs/b.md'], + }, + { format: 'table' }, + new Timer() + ); + const printed = stderr.join(''); + expect(printed).toContain('Warning: Could not read file docs/a.md'); + expect(printed).toContain('Warning: Could not read file docs/b.md'); + expect(printed).toContain('Warning: Skipped 2 unreadable file(s); linting 1 file(s)'); + }); + + it('prints the baseline counts before the result line', async () => { + const { stderr } = captureLogger(); + await printLintRun( + { status: 'completed', ...LINTED_REPORT, baseline: { matched: 2, new: 1, stale: 0 } }, + { format: 'table' }, + new Timer() + ); + const printed = stderr.join(''); + const baselineAt = printed.indexOf(' Baseline: 2 matched, 1 new, 0 stale\n'); + expect(baselineAt).toBeGreaterThan(-1); + expect(baselineAt).toBeLessThan(printed.indexOf('✅ No errors found!')); + }); + + it('prints a run with only warnings as a success and exits 0', async () => { + const { stderr } = captureLogger(); + const code = await printLintRun( + { status: 'completed', ...LINTED_REPORT, problems: [WARNING] }, + { format: 'table' }, + new Timer() + ); + expect(code).toBe(0); + const printed = stderr.join(''); + expect(printed).toContain('✅ No errors found!'); + expect(printed).toContain(' Found 1 warning(s) and info message(s).\n'); + }); + + it('prints the fix report of a failed run before the error', async () => { + const { stderr } = captureLogger(); + const code = await printLintRun( + { + status: 'failed', + message: 'baseline file is not valid', + report: { ...LINTED_REPORT, fixes: { applied: [FIX], skippedCount: 0 } }, + }, + { format: 'table' }, + new Timer() + ); + expect(code).toBe(1); + const printed = stderr.join(''); + const fixedAt = printed.indexOf('✅ Auto-fixed 1 issue(s)!'); + expect(fixedAt).toBeGreaterThan(-1); + expect(fixedAt).toBeLessThan( + printed.indexOf('💥 Error running recheck: baseline file is not valid') + ); + }); + + it('prints the files found before a missing baseline file and exits 1', async () => { + const { stderr } = captureLogger(); + const code = await printLintRun( + { + status: 'baseline-missing', + baselinePath: 'docs/.redocly.recheck-baseline.yaml', + report: LINTED_REPORT, + }, + { format: 'table' }, + new Timer() + ); + expect(code).toBe(1); + const printed = stderr.join(''); + const foundAt = printed.indexOf(' Found 2 markdown file(s)\n'); + expect(foundAt).toBeGreaterThan(-1); + expect(foundAt).toBeLessThan( + printed.indexOf('Baseline file not found: docs/.redocly.recheck-baseline.yaml') + ); + }); + + it('prints a summary that cannot be written as a failed run and exits 1', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'recheck-print-')); + try { + const { stderr } = captureLogger(); + const code = await printLintRun( + { status: 'completed', ...LINTED_REPORT }, + { + format: 'table', + summary: 'json', + summaryPath: path.join(dir, 'missing', 'summary.json'), + }, + new Timer() + ); + expect(code).toBe(1); + expect(stderr.join('')).toContain('💥 Error running recheck: ENOENT'); + expect(stderr.join('')).toMatch(/ {3}Failed after \d+ms\n$/); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('caps the SARIF results with maxProblems', async () => { + const problems = [5, 4, 3, 2, 1].map((line) => ({ ...ERROR, line })); + const { stderr, stdout } = captureLogger(); + const code = await printLintRun( + { status: 'completed', ...LINTED_REPORT, problems }, + { format: 'sarif', maxProblems: 2 }, + new Timer() + ); + expect(code).toBe(1); + const sarif = JSON.parse(stdout.join('')); + expect( + sarif.runs[0].results.map( + (result: { locations: { physicalLocation: { region: { startLine: number } } }[] }) => + result.locations[0].physicalLocation.region.startLine + ) + ).toEqual([1, 2]); + expect(stderr.join('')).toContain('\n Annotations prepared: 2 (limit 2)\n'); + }); +}); + +describe('printLintRun summary file', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'recheck-print-files-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('writes the JSON summary to the summary path', async () => { + const summaryPath = path.join(tempDir, 'summary.json'); + const { stderr } = captureLogger(); + const code = await printLintRun( + { status: 'completed', ...LINTED_REPORT, problems: [WARNING] }, + { format: 'table', summary: 'json', summaryPath }, + new Timer() + ); + expect(code).toBe(0); + const summary = JSON.parse(await fs.readFile(summaryPath, 'utf8')); + expect(summary.totalIssues).toBe(1); + expect(summary.totalWarnings).toBe(1); + expect(summary.breakdown['recheck/no-todos'].total).toBe(1); + expect(stderr.join('')).toContain(`\n Wrote summary to ${summaryPath}\n`); + }); +}); + +const READABILITY_RESULT: ReadabilityRunResult = { + roots: ['docs'], + filesFound: 1, + unreadableFiles: [], + rows: [ + { + file: 'docs/index.md', + words: 4, + sentences: 1, + fleschReadingEase: 97.03, + fleschKincaidGrade: 0.72, + automatedReadabilityIndex: -2.94, + }, + ], + summary: { + files: 1, + scored: 1, + medianFleschReadingEase: 97.03, + medianFleschKincaidGrade: 0.72, + medianAutomatedReadabilityIndex: -2.94, + }, +}; + +describe('printReadabilityRun', () => { + it('prints the table on stdout and the scored count on stderr', () => { + const { stderr, stdout } = captureLogger(); + const code = printReadabilityRun(READABILITY_RESULT, { format: 'table' }); + expect(code).toBe(0); + expect(stdout.join('')).toContain(' FRE Grade ARI Words Sentences File\n'); + expect(stdout.join('')).toContain('docs/index.md\n'); + const printed = stderr.join(''); + expect(printed).toContain(' Scoring 1 markdown file(s)\n'); + expect(printed).toContain('1 of 1 file(s) scored'); + }); + + it('prints the JSON report on stdout and no scored count', () => { + const { stderr, stdout } = captureLogger(); + const code = printReadabilityRun( + { ...READABILITY_RESULT, unreadableFiles: ['docs/secret.md'] }, + { format: 'json' } + ); + expect(code).toBe(0); + const report = JSON.parse(stdout.join('')); + expect(report.summary.scored).toBe(1); + expect(report.files[0].file).toBe('docs/index.md'); + const printed = stderr.join(''); + expect(printed).toContain(' Warning: Could not read file docs/secret.md'); + expect(printed).not.toContain('file(s) scored'); + }); +}); + +const BASELINE_RESULT: BaselineRunResult = { + roots: ['docs', 'guides'], + filesFound: 2, + unreadableFiles: [], + outPath: '/project/.redocly.recheck-baseline.yaml', + errorCount: 3, + baselinedFileCount: 1, +}; + +describe('printBaselineRun', () => { + it('prints a warning for each unreadable file before the written path', () => { + const { stderr } = captureLogger(); + printBaselineRun({ ...BASELINE_RESULT, unreadableFiles: ['docs/a.md', 'docs/b.md'] }); + expect(stderr.slice(1, 4)).toEqual([ + `${yellow(' Warning: Could not read file docs/a.md')}\n`, + `${yellow(' Warning: Could not read file docs/b.md')}\n`, + `${green('✅ Wrote /project/.redocly.recheck-baseline.yaml')}\n`, + ]); + }); +}); + +describe('printMarkdocSchemaRun', () => { + it.each([ + [ + 'written', + { status: 'written', outPath: '/project/tags.yaml' }, + 0, + ['Wrote /project/tags.yaml\n'], + ], + [ + 'up-to-date', + { status: 'up-to-date', outPath: '/project/tags.yaml' }, + 0, + ['/project/tags.yaml is up to date.\n'], + ], + [ + 'missing', + { status: 'missing', outPath: '/project/tags.yaml' }, + 1, + [ + '/project/tags.yaml does not exist — run `redocly recheck --generate-markdoc-schema` without --check to create it.\n', + ], + ], + [ + 'stale', + { status: 'stale', outPath: '/project/tags.yaml' }, + 1, + [ + '/project/tags.yaml is stale — run `redocly recheck --generate-markdoc-schema` to regenerate it.\n', + ], + ], + [ + 'conflicts, one prefixed line each', + { + status: 'conflicts', + conflicts: [ + 'tag "widget" differs between "a.ts" and "b.ts"', + 'tag "card" differs between "a.ts" and "c.ts"', + ], + }, + 1, + [ + 'redocly recheck --generate-markdoc-schema: tag "widget" differs between "a.ts" and "b.ts"\n', + 'redocly recheck --generate-markdoc-schema: tag "card" differs between "a.ts" and "c.ts"\n', + ], + ], + [ + 'load-error, message as-is', + { status: 'load-error', message: 'could not import "schema.ts". Compile it first.' }, + 1, + ['could not import "schema.ts". Compile it first.\n'], + ], + [ + 'write-error', + { + status: 'write-error', + outPath: '/project/tags.yaml', + message: 'ENOENT: no such file or directory', + }, + 1, + [ + 'redocly recheck --generate-markdoc-schema: could not write /project/tags.yaml — ENOENT: no such file or directory\n', + ], + ], + ] satisfies [string, MarkdocSchemaResult, number, string[]][])( + 'prints %s and exits with the matching code', + (_name, result, expectedCode, expectedStderr) => { + const { stderr, stdout } = captureLogger(); + expect(printMarkdocSchemaRun(result)).toBe(expectedCode); + expect(stdout).toEqual([]); + expect(stderr).toEqual(expectedStderr); + } + ); +}); diff --git a/packages/cli/src/commands/recheck/__tests__/select-action.test.ts b/packages/cli/src/commands/recheck/__tests__/select-action.test.ts new file mode 100644 index 0000000000..2178e15ffc --- /dev/null +++ b/packages/cli/src/commands/recheck/__tests__/select-action.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { selectAction } from '../select-action.js'; +import type { RecheckArgv } from '../types.js'; + +const base: RecheckArgv = { format: 'table' }; + +describe('selectAction', () => { + it('defaults to lint', () => { + expect(selectAction(base)).toEqual({ action: 'lint' }); + }); + + it('picks one action flag', () => { + expect(selectAction({ ...base, readability: true })).toEqual({ action: 'readability' }); + expect(selectAction({ ...base, 'generate-baseline': true })).toEqual({ action: 'baseline' }); + }); + + it('rejects --fix outside lint', () => { + expect(selectAction({ ...base, readability: true, fix: true })).toEqual({ + error: '--fix applies to linting only.', + }); + }); + + it('rejects unsupported readability formats', () => { + expect(selectAction({ ...base, readability: true, format: 'sarif' })).toEqual({ + error: '--readability supports --format table or json.', + }); + }); + + it('requires --from and --output for the markdoc schema action', () => { + expect(selectAction({ ...base, 'generate-markdoc-schema': true })).toEqual({ + error: '--generate-markdoc-schema requires --from and --output.', + }); + expect( + selectAction({ + ...base, + 'generate-markdoc-schema': true, + from: ['./theme.ts'], + output: 'schema.json', + }) + ).toEqual({ action: 'markdoc-schema' }); + }); +}); diff --git a/packages/cli/src/commands/recheck/formatters/__tests__/fixes.test.ts b/packages/cli/src/commands/recheck/formatters/__tests__/fixes.test.ts new file mode 100644 index 0000000000..a1dbfb885d --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/__tests__/fixes.test.ts @@ -0,0 +1,45 @@ +import type { Fix } from '@redocly/recheck'; +import { stripVTControlCharacters } from 'node:util'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { captureLogger } from '../../__tests__/capture-logger.js'; +import { reportFixes } from '../fixes.js'; + +const fix = (overrides: Partial): Fix => ({ + file: 'docs/a.md', + ruleName: 'recheck/rule', + lineNumber: 1, + editColumn: 1, + ...overrides, +}); + +afterEach(() => vi.restoreAllMocks()); + +describe('reportFixes', () => { + it('groups the fixes by file and describes each kind of edit', () => { + const { stderr } = captureLogger(); + + reportFixes([ + fix({ lineNumber: 1, deleteCount: -1 }), + fix({ lineNumber: 2, deleteCount: -1, insertText: 'new' }), + fix({ lineNumber: 3, deleteCount: 2, insertText: 'x' }), + fix({ lineNumber: 4, insertText: 'y' }), + fix({ lineNumber: 6, deleteCount: 3 }), + fix({ file: 'docs/b.md', lineNumber: 5 }), + ]); + + expect(stripVTControlCharacters(stderr.join(''))).toBe( + [ + '\n🔧 Auto-fix Summary:\n', + '\n docs/a.md:\n', + ' ✓ Line 1 (recheck/rule): removed line\n', + ' ✓ Line 2 (recheck/rule): replaced line with "new"\n', + ' ✓ Line 3 (recheck/rule): replaced 2 character(s) with "x"\n', + ' ✓ Line 4 (recheck/rule): inserted "y"\n', + ' ✓ Line 6 (recheck/rule): removed 3 character(s)\n', + '\n docs/b.md:\n', + ' ✓ Line 5 (recheck/rule): applied fix\n', + ].join('') + ); + }); +}); diff --git a/packages/cli/src/commands/recheck/formatters/__tests__/github-actions.test.ts b/packages/cli/src/commands/recheck/formatters/__tests__/github-actions.test.ts new file mode 100644 index 0000000000..3faba97020 --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/__tests__/github-actions.test.ts @@ -0,0 +1,29 @@ +import type { Problem } from '@redocly/recheck'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { captureLogger } from '../../__tests__/capture-logger.js'; +import { outputGitHubActionsFormat } from '../github-actions.js'; + +afterEach(() => vi.restoreAllMocks()); + +describe('outputGitHubActionsFormat', () => { + it('escapes the properties and the message like core', () => { + const { stdout } = captureLogger(); + const problem: Problem = { + file: 'x,y.md', + line: 3, + column: 1, + text: '', + match: '', + ruleName: 'a:b,c', + severity: 'error', + message: '100% done\r\nnext', + }; + + outputGitHubActionsFormat([problem]); + + expect(stdout).toEqual([ + '::error title=a%3Ab%2Cc,file=x%2Cy.md,line=3,endLine=3,col=1,endColumn=2::100%25 done%0D%0Anext\n', + ]); + }); +}); diff --git a/packages/cli/src/commands/recheck/formatters/__tests__/index.test.ts b/packages/cli/src/commands/recheck/formatters/__tests__/index.test.ts new file mode 100644 index 0000000000..63a7696905 --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/__tests__/index.test.ts @@ -0,0 +1,137 @@ +import type { Problem } from '@redocly/recheck'; +import { stripVTControlCharacters } from 'node:util'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { captureLogger } from '../../__tests__/capture-logger.js'; +import { generateReport } from '../index.js'; + +const ERROR: Problem = { + file: 'docs/index.md', + line: 5, + column: 1, + text: '', + match: '', + ruleName: 'recheck/line-length', + severity: 'error', + message: 'Line too long.', +}; + +const WARNING: Problem = { + ...ERROR, + line: 1, + ruleName: 'recheck/no-todos', + severity: 'warn', + message: 'TODO found.', +}; + +const INFO: Problem = { + ...ERROR, + line: 2, + ruleName: 'technical-english/passive-voice', + severity: 'info', + message: 'Prefer the active voice.', +}; + +function problem(overrides: Partial = {}): Problem { + return { ...ERROR, line: 1, ...overrides }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('generateReport', () => { + it('prints the annotations count without the limit when no problem is prepared', () => { + const { stderr, stdout } = captureLogger(); + + generateReport([], 0, { format: 'sarif', maxProblems: 5 }); + + expect(JSON.parse(stdout.join('')).runs[0].results).toEqual([]); + expect(stderr).toEqual(['\n Annotations prepared: 0\n']); + }); + + it('prints GitHub Actions annotations on stdout with errors first', () => { + const { stderr, stdout } = captureLogger(); + + generateReport([INFO, WARNING, { ...ERROR, message: 'a::b\nc' }], 1, { + format: 'github-actions', + maxProblems: 10, + }); + + expect(stdout).toEqual([ + '::error title=recheck/line-length,file=docs/index.md,line=5,endLine=5,col=1,endColumn=2::a::b%0Ac\n', + '::warning title=recheck/no-todos,file=docs/index.md,line=1,endLine=1,col=1,endColumn=2::TODO found.\n', + '::notice title=technical-english/passive-voice,file=docs/index.md,line=2,endLine=2,col=1,endColumn=2::Prefer the active voice.\n', + ]); + expect(stderr).toEqual(['\n Annotations prepared: 3 (limit 10)\n']); + }); + + it('prints the JSON report with the baseline counts on stdout', () => { + const { stdout } = captureLogger(); + const baseline = { matched: 1, new: 1, stale: 0 }; + + generateReport([ERROR], 3, { format: 'json', baseline }); + + expect(stdout).toHaveLength(1); + expect(stdout[0].endsWith('}\n')).toBe(true); + expect(JSON.parse(stdout[0])).toEqual({ + summary: { + filesScanned: 3, + totalIssues: 1, + baseline, + breakdown: { 'recheck/line-length': { errors: 1, warnings: 0, info: 0, total: 1 } }, + }, + issues: [ERROR], + }); + }); +}); + +describe('generateReport with the table format', () => { + const HIDDEN = 'more problems hidden > increase with `--max-problems N`\n'; + + function plain(lines: string[]): string { + return stripVTControlCharacters(lines.join('')); + } + + it('counts every problem, prints the rows up to maxProblems, and reports the rest on stderr', () => { + const { stderr, stdout } = captureLogger(); + + generateReport([problem(), problem({ line: 2 })], 1, { + format: 'table', + maxProblems: 1, + }); + + const printed = plain(stdout); + expect(printed).toContain('docs/index.md:1:1'); + expect(printed).not.toContain('docs/index.md:2:1'); + expect(printed).toContain('Found 2 issue(s):'); + expect(printed).toContain('\n 2 error(s)\n'); + expect(printed).not.toContain('more problems hidden'); + expect(plain(stderr)).toContain(`< ... 1 ${HIDDEN}`); + }); + + it('prints the counts and no rows when maxProblems is 0', () => { + const { stderr, stdout } = captureLogger(); + + generateReport([problem(), problem({ line: 2 })], 1, { + format: 'table', + maxProblems: 0, + }); + + const printed = plain(stdout); + expect(printed).not.toContain('No issues found!'); + expect(printed).not.toContain('docs/index.md:'); + expect(printed).toContain('Found 2 issue(s):'); + expect(printed).toContain('\n 2 error(s)\n'); + expect(plain(stderr)).toContain(`< ... 2 ${HIDDEN}`); + }); + + it('prints every problem and no hidden count without maxProblems', () => { + const { stderr, stdout } = captureLogger(); + + generateReport([problem(), problem({ line: 2 })], 1, { format: 'table' }); + + const printed = plain(stdout); + expect(printed).toContain('docs/index.md:1:1'); + expect(printed).toContain('docs/index.md:2:1'); + expect(plain([...stdout, ...stderr])).not.toContain('more problems hidden'); + }); +}); diff --git a/packages/cli/src/commands/recheck/formatters/__tests__/readability.test.ts b/packages/cli/src/commands/recheck/formatters/__tests__/readability.test.ts new file mode 100644 index 0000000000..a25444002c --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/__tests__/readability.test.ts @@ -0,0 +1,43 @@ +import type { ReadabilityRunResult } from '@redocly/recheck'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { captureLogger } from '../../__tests__/capture-logger.js'; +import { outputReadabilityTable } from '../readability.js'; + +afterEach(() => vi.restoreAllMocks()); + +describe('outputReadabilityTable', () => { + it('prints a dash for each score a file could not get', () => { + const { stdout } = captureLogger(); + const result = { + rows: [ + { + file: 'docs/scored.md', + words: 120, + sentences: 8, + fleschReadingEase: 61.234, + fleschKincaidGrade: 8.06, + automatedReadabilityIndex: 7.5, + }, + { + file: 'docs/empty.md', + words: 0, + sentences: 0, + fleschReadingEase: null, + fleschKincaidGrade: null, + automatedReadabilityIndex: null, + }, + ], + } as ReadabilityRunResult; + + outputReadabilityTable(result); + + expect(stdout.join('')).toBe( + '\n' + + ' FRE Grade ARI Words Sentences File\n' + + ' 61.2 8.1 7.5 120 8 docs/scored.md\n' + + ' — — — 0 0 docs/empty.md\n' + + '\n' + ); + }); +}); diff --git a/packages/cli/src/commands/recheck/formatters/__tests__/summary.test.ts b/packages/cli/src/commands/recheck/formatters/__tests__/summary.test.ts new file mode 100644 index 0000000000..eacc61add8 --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/__tests__/summary.test.ts @@ -0,0 +1,45 @@ +import { buildSummary, type Problem } from '@redocly/recheck'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { captureLogger } from '../../__tests__/capture-logger.js'; +import { printSummary } from '../summary.js'; + +const ERROR: Problem = { + file: 'docs/index.md', + line: 5, + column: 1, + text: '', + match: '', + ruleName: 'recheck/line-length', + severity: 'error', + message: 'Line too long.', +}; + +const WARNING: Problem = { + ...ERROR, + line: 1, + ruleName: 'recheck/no-todos', + severity: 'warn', + message: 'TODO found.', +}; + +afterEach(() => vi.restoreAllMocks()); + +describe('printSummary', () => { + it('prints a text summary on stderr', async () => { + const { stderr, stdout } = captureLogger(); + + await printSummary(buildSummary([ERROR, WARNING], 2), 'text', undefined); + + expect(stderr.join('')).toBe( + '\nFiles scanned: 2\n' + + 'Total issues: 2\n' + + 'Errors: 1, Warnings: 1, Info: 0\n' + + '\n' + + 'Breakdown by rule:\n' + + 'recheck/line-length: 1 (errors: 1, warnings: 0, info: 0)\n' + + 'recheck/no-todos: 1 (errors: 0, warnings: 1, info: 0)\n' + ); + expect(stdout).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/recheck/formatters/__tests__/table.test.ts b/packages/cli/src/commands/recheck/formatters/__tests__/table.test.ts new file mode 100644 index 0000000000..50fb14897c --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/__tests__/table.test.ts @@ -0,0 +1,55 @@ +import type { Problem } from '@redocly/recheck'; +import { stripVTControlCharacters } from 'node:util'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { captureLogger } from '../../__tests__/capture-logger.js'; +import { outputTableFormat } from '../table.js'; + +function problem(overrides: Partial = {}): Problem { + return { + file: 'docs/index.md', + line: 1, + column: 1, + text: '', + match: '', + ruleName: 'recheck/line-length', + severity: 'error', + message: 'Line too long.', + ...overrides, + }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('outputTableFormat', () => { + it('prints the rule breakdown, largest first, when stats are on', () => { + const { stdout } = captureLogger(); + + outputTableFormat( + [ + problem({ severity: 'warn', ruleName: 'recheck/no-todos' }), + problem(), + problem({ line: 2, severity: 'info' }), + ], + 2, + true + ); + + const printed = stripVTControlCharacters(stdout.join('')); + expect(printed).toContain('\n📊 Summary Statistics:\n'); + expect(printed).toContain(' 2 markdown file(s) scanned\n 3 total issue(s) detected\n'); + expect(printed).toContain( + '\n Breakdown by rule:\n line-length: 2 (1 error, 1 info)\n no-todos: 1 (1 warning)\n' + ); + }); + + it('prints the scanned file count of a clean run when stats are on', () => { + const { stdout } = captureLogger(); + + outputTableFormat([], 4, true); + + expect(stripVTControlCharacters(stdout.join(''))).toBe( + '\n🎉 No issues found!\n\n📊 Summary: 4 file(s) scanned, 0 issues found.\n' + ); + }); +}); diff --git a/packages/cli/src/commands/recheck/formatters/fixes.ts b/packages/cli/src/commands/recheck/formatters/fixes.ts new file mode 100644 index 0000000000..d413fea9d1 --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/fixes.ts @@ -0,0 +1,45 @@ +import { logger } from '@redocly/openapi-core'; +import type { Fix } from '@redocly/recheck'; +import { cyan, green } from 'colorette'; + +// Describes a fix in words, built from its insert text and delete count. +function describeFix(fix: Fix): string { + if (fix.deleteCount === -1) { + return fix.insertText === undefined ? 'removed line' : `replaced line with "${fix.insertText}"`; + } + + const deleteCount = fix.deleteCount ?? 0; + const insertText = fix.insertText ?? ''; + + if (deleteCount > 0 && insertText) { + return `replaced ${deleteCount} character(s) with "${insertText}"`; + } + if (deleteCount > 0) { + return `removed ${deleteCount} character(s)`; + } + if (insertText) { + return `inserted "${insertText}"`; + } + return 'applied fix'; +} + +export function reportFixes(fixes: Fix[]): void { + logger.info(`${cyan('\n🔧 Auto-fix Summary:')}\n`); + + const fixesByFile: Record = {}; + for (const fix of fixes) { + if (!fixesByFile[fix.file]) { + fixesByFile[fix.file] = []; + } + fixesByFile[fix.file].push(fix); + } + + for (const [file, fileFixes] of Object.entries(fixesByFile)) { + logger.info(`\n ${file}:\n`); + for (const fix of fileFixes) { + logger.info( + `${green(` ✓ Line ${fix.lineNumber} (${fix.ruleName}): ${describeFix(fix)}`)}\n` + ); + } + } +} diff --git a/packages/cli/src/commands/recheck/formatters/github-actions.ts b/packages/cli/src/commands/recheck/formatters/github-actions.ts new file mode 100644 index 0000000000..1b63080a2a --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/github-actions.ts @@ -0,0 +1,39 @@ +import { logger } from '@redocly/openapi-core'; +import type { Problem } from '@redocly/recheck'; + +const COMMAND_BY_SEVERITY: Record = { + error: 'error', + warn: 'warning', + info: 'notice', + off: 'notice', +}; + +function escapeProperty(value: string): string { + return value + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A') + .replace(/:/g, '%3A') + .replace(/,/g, '%2C'); +} + +export function outputGitHubActionsFormat(problems: Problem[]): void { + for (const problem of problems) { + const command = COMMAND_BY_SEVERITY[problem.severity]; + const properties = [ + `title=${escapeProperty(problem.ruleName)}`, + `file=${escapeProperty(problem.file)}`, + `line=${problem.line}`, + `endLine=${problem.line}`, + `col=${problem.column}`, + `endColumn=${problem.column + (problem.match?.length || 1)}`, + ].join(','); + + const message = problem.message + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A'); + + logger.output(`::${command} ${properties}::${message}\n`); + } +} diff --git a/packages/cli/src/commands/recheck/formatters/index.ts b/packages/cli/src/commands/recheck/formatters/index.ts new file mode 100644 index 0000000000..a0c56dab76 --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/index.ts @@ -0,0 +1,56 @@ +import { logger } from '@redocly/openapi-core'; +import type { Problem } from '@redocly/recheck'; +import { gray } from 'colorette'; + +import { outputGitHubActionsFormat } from './github-actions.js'; +import { outputJsonFormat } from './json.js'; +import { prioritizeProblems } from './prioritize-problems.js'; +import { outputSarifFormat } from './sarif.js'; +import { outputTableFormat } from './table.js'; + +export interface ReportOptions { + format: 'table' | 'json' | 'sarif' | 'github-actions'; + showStats?: boolean; + maxProblems?: number; + baseline?: { matched: number; new: number; stale: number }; +} + +export function generateReport( + problems: Problem[], + fileCount: number, + options: ReportOptions +): void { + const { format, showStats, maxProblems, baseline } = options; + const limited = typeof maxProblems === 'number'; + // The stats list tied rules in this order, so the table rows must use the same order. + const ordered = limited ? prioritizeProblems(problems) : problems; + const prioritized = limited && maxProblems >= 0 ? ordered.slice(0, maxProblems) : ordered; + + switch (format) { + case 'sarif': + outputSarifFormat(prioritized); + break; + case 'github-actions': + outputGitHubActionsFormat(prioritized); + break; + case 'json': + outputJsonFormat(prioritized, fileCount, baseline); + break; + default: + outputTableFormat(ordered, fileCount, showStats, prioritized); + break; + } + + if (problems.length > prioritized.length) { + logger.info( + `< ... ${problems.length - prioritized.length} more problems hidden > ${gray( + 'increase with `--max-problems N`' + )}\n` + ); + } + + const limitInfoEnd = limited ? ` (limit ${maxProblems})` : ''; + logger.info( + `\n Annotations prepared: ${prioritized.length}${prioritized.length > 0 ? limitInfoEnd : ''}\n` + ); +} diff --git a/packages/cli/src/commands/recheck/formatters/json.ts b/packages/cli/src/commands/recheck/formatters/json.ts new file mode 100644 index 0000000000..c177c20af5 --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/json.ts @@ -0,0 +1,20 @@ +import { logger } from '@redocly/openapi-core'; +import { getBreakdownStats, type Problem } from '@redocly/recheck'; + +export function outputJsonFormat( + problems: Problem[], + fileCount: number, + baseline?: { matched: number; new: number; stale: number } +): void { + const report = { + summary: { + filesScanned: fileCount, + totalIssues: problems.length, + ...(baseline === undefined ? {} : { baseline }), + breakdown: getBreakdownStats(problems), + }, + issues: problems, + }; + + logger.output(`${JSON.stringify(report, null, 2)}\n`); +} diff --git a/packages/cli/src/commands/recheck/formatters/prioritize-problems.ts b/packages/cli/src/commands/recheck/formatters/prioritize-problems.ts new file mode 100644 index 0000000000..27fe704b84 --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/prioritize-problems.ts @@ -0,0 +1,20 @@ +import type { Problem } from '@redocly/recheck'; + +const SEVERITY_ORDER: Record = { + error: 2, + warn: 1, + info: 0, +}; + +// Sorts by severity (error, warn, info), then by file, line and column. +export function prioritizeProblems(problems: Problem[]): Problem[] { + return [...problems].sort((left, right) => { + const leftSeverity = SEVERITY_ORDER[left.severity] ?? 0; + const rightSeverity = SEVERITY_ORDER[right.severity] ?? 0; + if (rightSeverity !== leftSeverity) return rightSeverity - leftSeverity; + + if (left.file !== right.file) return left.file.localeCompare(right.file); + if (left.line !== right.line) return left.line - right.line; + return left.column - right.column; + }); +} diff --git a/packages/cli/src/commands/recheck/formatters/readability.ts b/packages/cli/src/commands/recheck/formatters/readability.ts new file mode 100644 index 0000000000..c8f9fa18fd --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/readability.ts @@ -0,0 +1,26 @@ +import { logger } from '@redocly/openapi-core'; +import type { ReadabilityRunResult } from '@redocly/recheck'; + +export function outputReadabilityJson(result: ReadabilityRunResult): void { + const report = JSON.stringify({ summary: result.summary, files: result.rows }, null, 2); + logger.output(`${report}\n`); +} + +export function outputReadabilityTable(result: ReadabilityRunResult): void { + logger.output('\n'); + logger.output(' FRE Grade ARI Words Sentences File\n'); + for (const row of result.rows) { + const fre = + row.fleschReadingEase === null ? ' —' : row.fleschReadingEase.toFixed(1).padStart(6); + const grade = + row.fleschKincaidGrade === null ? ' —' : row.fleschKincaidGrade.toFixed(1).padStart(5); + const ari = + row.automatedReadabilityIndex === null + ? ' —' + : row.automatedReadabilityIndex.toFixed(1).padStart(6); + logger.output( + `${fre} ${grade} ${ari} ${String(row.words).padStart(6)} ${String(row.sentences).padStart(9)} ${row.file}\n` + ); + } + logger.output('\n'); +} diff --git a/packages/cli/src/commands/recheck/formatters/sarif.ts b/packages/cli/src/commands/recheck/formatters/sarif.ts new file mode 100644 index 0000000000..67aada007e --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/sarif.ts @@ -0,0 +1,61 @@ +import { logger } from '@redocly/openapi-core'; +import type { Problem } from '@redocly/recheck'; + +const SEVERITY_TO_LEVEL: Record = { + error: 'error', + warn: 'warning', + info: 'note', +}; + +interface SarifRule { + id: string; + shortDescription: { text: string }; + helpUri?: string; +} + +function buildSarif(problems: Problem[]): Record { + const rulesMap = new Map(); + for (const problem of problems) { + if (!rulesMap.has(problem.ruleName)) { + rulesMap.set(problem.ruleName, { + id: problem.ruleName, + shortDescription: { text: problem.ruleName.replace('recheck/', '') }, + }); + } + } + + const results = problems.map((problem) => ({ + ruleId: problem.ruleName, + level: SEVERITY_TO_LEVEL[problem.severity] ?? 'warning', + message: { text: problem.message }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: problem.file }, + region: { startLine: problem.line, startColumn: problem.column }, + }, + }, + ], + })); + + return { + $schema: 'https://json.schemastore.org/sarif-2.1.0.json', + version: '2.1.0', + runs: [ + { + tool: { + driver: { + name: 'recheck', + informationUri: 'https://redocly.com', + rules: Array.from(rulesMap.values()), + }, + }, + results, + }, + ], + }; +} + +export function outputSarifFormat(problems: Problem[]): void { + logger.output(`${JSON.stringify(buildSarif(problems), null, 2)}\n`); +} diff --git a/packages/cli/src/commands/recheck/formatters/statistics.ts b/packages/cli/src/commands/recheck/formatters/statistics.ts new file mode 100644 index 0000000000..e6941e726a --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/statistics.ts @@ -0,0 +1,36 @@ +import { logger } from '@redocly/openapi-core'; +import { getBreakdownStats, type Problem } from '@redocly/recheck'; +import { red, yellow, cyan } from 'colorette'; + +function output(line: string): void { + logger.output(`${line}\n`); +} + +export function showDetailedStats(fileCount: number, problems: Problem[]): void { + output(cyan('\n📊 Summary Statistics:')); + output(` ${fileCount} markdown file(s) scanned`); + output(` ${problems.length} total issue(s) detected`); + + const breakdown = getBreakdownStats(problems); + + if (Object.keys(breakdown).length > 0) { + output('\n Breakdown by rule:'); + + const sortedRules = Object.entries(breakdown).sort( + ([, left], [, right]) => right.total - left.total + ); + + for (const [ruleName, stats] of sortedRules) { + const parts: string[] = []; + if (stats.errors > 0) + parts.push(red(`${stats.errors} error${stats.errors !== 1 ? 's' : ''}`)); + if (stats.warnings > 0) + parts.push(yellow(`${stats.warnings} warning${stats.warnings !== 1 ? 's' : ''}`)); + if (stats.info > 0) parts.push(cyan(`${stats.info} info`)); + + const statsText = parts.length > 0 ? ` (${parts.join(', ')})` : ''; + const displayName = ruleName.replace('recheck/', ''); + output(` ${displayName}: ${stats.total}${statsText}`); + } + } +} diff --git a/packages/cli/src/commands/recheck/formatters/summary.ts b/packages/cli/src/commands/recheck/formatters/summary.ts new file mode 100644 index 0000000000..a00107e338 --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/summary.ts @@ -0,0 +1,36 @@ +import { logger } from '@redocly/openapi-core'; +import type { Summary } from '@redocly/recheck'; +import * as fs from 'node:fs/promises'; + +export async function printSummary( + summary: Summary, + format: 'json' | 'text', + path: string | undefined +): Promise { + let content = ''; + if (format === 'json') { + content = JSON.stringify(summary, null, 2); + } else { + const lines: string[] = []; + lines.push(`Files scanned: ${summary.filesScanned}`); + lines.push(`Total issues: ${summary.totalIssues}`); + lines.push( + `Errors: ${summary.totalErrors}, Warnings: ${summary.totalWarnings}, Info: ${summary.totalInfo}` + ); + lines.push(''); + lines.push('Breakdown by rule:'); + for (const [rule, ruleStats] of Object.entries(summary.breakdown)) { + lines.push( + `${rule}: ${ruleStats.total} (errors: ${ruleStats.errors}, warnings: ${ruleStats.warnings}, info: ${ruleStats.info})` + ); + } + content = lines.join('\n'); + } + + if (path && path.length > 0) { + await fs.writeFile(path, content, 'utf8'); + logger.info(`\n Wrote summary to ${path}\n`); + } else { + logger.info(`\n${content}\n`); + } +} diff --git a/packages/cli/src/commands/recheck/formatters/table.ts b/packages/cli/src/commands/recheck/formatters/table.ts new file mode 100644 index 0000000000..985553b628 --- /dev/null +++ b/packages/cli/src/commands/recheck/formatters/table.ts @@ -0,0 +1,54 @@ +import { logger } from '@redocly/openapi-core'; +import type { Problem } from '@redocly/recheck'; +import { red, green, yellow, cyan } from 'colorette'; + +import { showDetailedStats } from './statistics.js'; + +// The header, counts and stats cover all `problems`. The table lists only the rows in `shown`. +export function outputTableFormat( + problems: Problem[], + fileCount: number, + showStats: boolean | undefined, + shown: Problem[] = problems +): void { + if (problems.length === 0) { + logger.output(`${green('\n🎉 No issues found!')}\n`); + if (showStats) { + logger.output(`\n📊 Summary: ${fileCount} file(s) scanned, 0 issues found.\n`); + } + return; + } + + logger.output(`${cyan(`\n📋 Found ${problems.length} issue(s):\n`)}\n`); + + for (const problem of shown) { + const severityColor = + problem.severity === 'error' ? red : problem.severity === 'warn' ? yellow : cyan; + const location = `${problem.file}:${problem.line}:${problem.column}`; + const ruleDisplay = problem.ruleName.replace('recheck/', ''); + + const fixMark = problem.fixable ? green(' [fixable]') : ''; + + logger.output( + `${severityColor(ruleDisplay.padEnd(25))} ${location.padEnd(40)} ${problem.message}${fixMark}\n` + ); + } + + const fixableCount = problems.filter((problem) => problem.fixable).length; + if (fixableCount > 0) { + logger.output(`${green(`\n ${fixableCount} of ${problems.length} fixable with --fix`)}\n`); + } + + const errorCount = problems.filter((problem) => problem.severity === 'error').length; + const warnCount = problems.filter((problem) => problem.severity === 'warn').length; + const infoCount = problems.filter((problem) => problem.severity === 'info').length; + + logger.output('\n'); + if (errorCount > 0) logger.output(`${red(` ${errorCount} error(s)`)}\n`); + if (warnCount > 0) logger.output(`${yellow(` ${warnCount} warning(s)`)}\n`); + if (infoCount > 0) logger.output(`${cyan(` ${infoCount} info message(s)`)}\n`); + + if (showStats) { + showDetailedStats(fileCount, problems); + } +} diff --git a/packages/cli/src/commands/recheck/index.ts b/packages/cli/src/commands/recheck/index.ts new file mode 100644 index 0000000000..2af16c15df --- /dev/null +++ b/packages/cli/src/commands/recheck/index.ts @@ -0,0 +1,173 @@ +import { + AbortFlowError, + detectSpec, + isPlainObject, + isString, + logger, + parseYaml, + type Config, +} from '@redocly/openapi-core'; +import { + generateBaseline, + generateMarkdocSchema, + resolveRecheckConfig, + runLint, + runReadability, + Timer, + type LintOptions, + type ResolvedRecheckConfig, +} from '@redocly/recheck'; +import { presets } from '@redocly/recheck/presets'; +import { readFileSync, statSync } from 'node:fs'; +import { dirname, extname } from 'node:path'; + +import type { CommandArgs } from '../../wrapper.js'; +import { + printBaselineRun, + printBaselineStart, + printLintRun, + printLintStart, + printMarkdocSchemaRun, + printReadabilityRun, + printReadabilityStart, + type LintPresentation, +} from './print.js'; +import { selectAction } from './select-action.js'; +import type { RecheckAction, RecheckArgv } from './types.js'; + +const DEFAULT_PRESET = 'recheck/markdown'; +const API_EXTENSIONS = new Set(['.yaml', '.yml', '.json']); + +function isApiDescription(path: string): boolean { + if (!API_EXTENSIONS.has(extname(path).toLowerCase())) return false; + try { + if (!statSync(path).isFile()) return false; + detectSpec(parseYaml(readFileSync(path, 'utf8'))); + return true; + } catch { + return false; + } +} + +// A block of the wrong type counts as configured, so the engine can report the error. +function hasRecheckConfig(block: Config['recheck']): boolean { + if (!isPlainObject(block)) return true; + const { rules, ...settings } = block; + return Object.keys(settings).length > 0 || Object.keys(rules ?? {}).length > 0; +} + +// The command reads the root config only; per-API recheck settings do nothing. +function warnAboutPerApiRecheck(config: Config): void { + const raw = config.document?.parsed; + if (!isPlainObject(raw) || !isPlainObject(raw.apis)) return; + for (const [alias, api] of Object.entries(raw.apis)) { + if (!isPlainObject(api)) continue; + const recheckPresets = Array.isArray(api.extends) + ? api.extends.filter(isString).filter((name) => name.startsWith('recheck/')) + : []; + if ('recheck' in api || recheckPresets.length > 0) { + logger.warn( + `Recheck settings under apis.${alias} are not used; the command reads the root config.\n` + ); + } + } +} + +function toLintOptions(argv: RecheckArgv): LintOptions { + return { + tags: argv.tags, + rules: argv.rule, + excludeRules: argv['skip-rule'], + fix: argv.fix, + }; +} + +function toLintPresentation(argv: RecheckArgv): LintPresentation { + return { + format: argv.format ?? 'table', + showStats: argv.stats, + maxProblems: argv['max-problems'], + summary: argv.summary, + summaryPath: argv['summary-path'], + }; +} + +export async function handleRecheck({ argv, config }: CommandArgs): Promise { + const selected = selectAction(argv); + if ('error' in selected) { + logger.error(`${selected.error}\n`); + throw new AbortFlowError('Recheck failed.'); + } + + if (selected.action === 'markdoc-schema') { + const code = printMarkdocSchemaRun( + await generateMarkdocSchema({ + from: argv.from ?? [], + out: argv.output ?? '', + check: argv.check, + }) + ); + if (code !== 0) throw new AbortFlowError('Recheck failed.'); + return; + } + + warnAboutPerApiRecheck(config); + let block = config.recheck; + if (!hasRecheckConfig(block)) { + if (config.configPath) { + logger.info( + 'No recheck configuration in redocly.yaml; nothing to check. Add a recheck/* preset to extends or a recheck block.\n' + ); + return; + } + logger.info(`No redocly.yaml found; using ${DEFAULT_PRESET}.\n`); + block = { rules: presets[DEFAULT_PRESET] }; + } + const configDir = dirname(config.configPath ?? 'redocly.yaml'); + const resolved = await resolveRecheckConfig({ + block, + configDir, + warn: (message) => logger.warn(`${message}\n`), + }); + if (!resolved.success) { + logger.error('The recheck configuration is not valid:\n'); + for (const error of resolved.errors) { + logger.error(` ${error.path ? `${error.path}: ` : ''}${error.message}\n`); + } + throw new AbortFlowError('Recheck failed.'); + } + + const exitCode = await runAction(selected.action, argv, resolved.config); + if (exitCode !== 0) throw new AbortFlowError('Recheck failed.'); +} + +async function runAction( + action: Exclude, + argv: RecheckArgv, + resolved: ResolvedRecheckConfig +): Promise { + const requested = argv.paths && argv.paths.length > 0 ? argv.paths : ['.']; + const roots: string[] = []; + for (const path of requested) { + if (isApiDescription(path)) { + logger.warn(`API descriptions are linted from the next release; skipped ${path}\n`); + } else { + roots.push(path); + } + } + if (roots.length === 0) return 0; + + if (action === 'readability') { + printReadabilityStart(roots); + const result = await runReadability(roots, resolved, {}); + return printReadabilityRun(result, { format: argv.format === 'json' ? 'json' : 'table' }); + } + if (action === 'baseline') { + printBaselineStart(roots); + return printBaselineRun(await generateBaseline(roots, resolved)); + } + const timer = new Timer(); + printLintStart(roots); + const result = await runLint(roots, resolved, toLintOptions(argv)); + return printLintRun(result, toLintPresentation(argv), timer); +} diff --git a/packages/cli/src/commands/recheck/print.ts b/packages/cli/src/commands/recheck/print.ts new file mode 100644 index 0000000000..bd2c38b868 --- /dev/null +++ b/packages/cli/src/commands/recheck/print.ts @@ -0,0 +1,246 @@ +import { logger } from '@redocly/openapi-core'; +import { + buildSummary, + type BaselineRunResult, + type LintRunReport, + type LintRunResult, + type MarkdocSchemaResult, + type ReadabilityRunResult, + type Timer, +} from '@redocly/recheck'; +import { cyan, green, red, yellow } from 'colorette'; + +import { reportFixes } from './formatters/fixes.js'; +import { generateReport } from './formatters/index.js'; +import { outputReadabilityJson, outputReadabilityTable } from './formatters/readability.js'; +import { printSummary } from './formatters/summary.js'; + +export interface LintPresentation { + format: 'table' | 'json' | 'sarif' | 'github-actions'; + showStats?: boolean; + maxProblems?: number; + summary?: 'json' | 'text'; + summaryPath?: string; +} + +export function printLintStart(roots: string[]): void { + logger.info(`${cyan(`🏃 Running recheck on: ${roots.join(', ')}`)}\n`); +} + +export function printReadabilityStart(roots: string[]): void { + logger.info(`${cyan(`📖 Measuring readability of: ${roots.join(', ')}`)}\n`); +} + +export function printBaselineStart(roots: string[]): void { + logger.info(`${cyan(`📋 Building recheck baseline from: ${roots.join(', ')}`)}\n`); +} + +function printFailure(message: string, timer: Timer): number { + logger.error(red(`💥 Error running recheck: ${message}`) + '\n'); + logger.info(` Failed after ${timer.elapsedString()}\n`); + return 1; +} + +async function printEmptyReport(presentation: LintPresentation): Promise { + generateReport([], 0, { + format: presentation.format, + showStats: presentation.showStats, + maxProblems: presentation.maxProblems, + }); + if (presentation.summary) { + await printSummary(buildSummary([], 0), presentation.summary, presentation.summaryPath); + } +} + +function printPreamble(report: LintRunReport): void { + if (report.disabledRuleCount > 0) { + logger.info(` Disabled ${report.disabledRuleCount} rule(s) (severity: off)\n`); + } + logger.info(`${cyan(`\n🔧 Running ${report.ruleCount} rule(s)...`)}\n`); + if (report.empty) return; + if (report.filesFound === 0) { + logger.info(`${yellow(`⚠️ No markdown files found in: ${report.roots.join(', ')}`)}\n`); + } + logger.info(` Found ${report.filesFound} markdown file(s)\n`); + for (const filePath of report.unreadableFiles) { + logger.info(`${yellow(` Warning: Could not read file ${filePath}`)}\n`); + } + if (report.unreadableFiles.length > 0) { + logger.info( + `${yellow( + ` Warning: Skipped ${report.unreadableFiles.length} unreadable file(s); linting ${report.scannedFileCount} file(s)` + )}\n` + ); + } + printFixBlock(report); +} + +function printFixBlock(report: LintRunReport): void { + if (!report.fixes) return; + logger.info(`${cyan(`\n🔧 Auto-fixing issues...`)}\n`); + if (report.fixes.applied.length > 0) { + logger.info(`${green(`✅ Auto-fixed ${report.fixes.applied.length} issue(s)!`)}\n`); + reportFixes(report.fixes.applied); + } else { + logger.info(`${yellow(`⚠️ No auto-fixable issues found.`)}\n`); + } + if (report.fixes.skippedCount > 0) { + logger.info( + `${yellow( + `⚠️ ${report.fixes.skippedCount} proposed fix(es) were not applied — either the edits ` + + `still conflicted after repeated passes, or the fix was withheld to avoid ` + + `rewriting a Markdoc tag — fix the reported issue(s) manually.` + )}\n` + ); + } +} + +export async function printLintRun( + result: LintRunResult, + presentation: LintPresentation, + timer: Timer +): Promise { + if (result.status === 'unknown-rule') { + logger.info(`${red(`❌ ${result.message}`)}\n`); + logger.info(` Available: ${result.available.join(', ')}\n`); + return 1; + } + if (result.status === 'baseline-missing') { + printPreamble(result.report); + logger.info(`${red(`❌ Baseline file not found: ${result.baselinePath}`)}\n`); + logger.info(` Run \`redocly recheck --generate-baseline\` to create it.\n`); + return 1; + } + if (result.status === 'failed') { + printPreamble(result.report); + return printFailure(result.message, timer); + } + + // A summary that cannot be written fails the run like an engine error. + try { + return await printCompletedRun(result, presentation, timer); + } catch (error) { + return printFailure(error instanceof Error ? error.message : 'Unknown error', timer); + } +} + +async function printEmptyRun( + result: LintRunReport, + presentation: LintPresentation, + timer: Timer +): Promise { + logger.info(`${yellow(`⚠️ No markdown files found in: ${result.roots.join(', ')}`)}\n`); + await printEmptyReport(presentation); + logger.info(` Completed in ${timer.elapsedString()}\n`); + return 0; +} + +async function printCompletedRun( + result: LintRunReport, + presentation: LintPresentation, + timer: Timer +): Promise { + printPreamble(result); + if (result.empty) return printEmptyRun(result, presentation, timer); + + if (result.baseline) { + logger.info( + ` Baseline: ${result.baseline.matched} matched, ${result.baseline.new} new, ${result.baseline.stale} stale\n` + ); + } + + generateReport(result.problems, result.scannedFileCount, { + format: presentation.format, + showStats: presentation.showStats, + maxProblems: presentation.maxProblems, + baseline: result.baseline, + }); + if (presentation.summary) { + const summary = buildSummary(result.problems, result.scannedFileCount, result.baseline); + await printSummary(summary, presentation.summary, presentation.summaryPath); + } + + const errorCount = result.problems.filter((problem) => problem.severity === 'error').length; + if (errorCount > 0) { + logger.info(`${red(`\n❌ Found ${errorCount} error(s). Exiting with code 1.`)}\n`); + logger.info(` Completed in ${timer.elapsedString()}\n`); + return 1; + } + logger.info(`${green(`\n✅ No errors found!`)}\n`); + if (result.problems.length > 0) { + logger.info(` Found ${result.problems.length} warning(s) and info message(s).\n`); + } + logger.info(` Completed in ${timer.elapsedString()}\n`); + return 0; +} + +export function printReadabilityRun( + result: ReadabilityRunResult, + presentation: { format: 'table' | 'json' } +): number { + logger.info(` Scoring ${result.filesFound} markdown file(s)\n`); + for (const file of result.unreadableFiles) { + logger.info(`${yellow(` Warning: Could not read file ${file}`)}\n`); + } + if (presentation.format === 'json') { + outputReadabilityJson(result); + return 0; + } + outputReadabilityTable(result); + const { summary } = result; + logger.info( + `${green( + ` ${summary.scored} of ${summary.files} file(s) scored` + + (summary.medianFleschReadingEase === null + ? '' + : ` • median FRE ${summary.medianFleschReadingEase} • median grade ${summary.medianFleschKincaidGrade} • median ARI ${summary.medianAutomatedReadabilityIndex}`) + )}\n` + ); + return 0; +} + +export function printBaselineRun(result: BaselineRunResult): number { + logger.info(` Found ${result.filesFound} markdown file(s)\n`); + for (const file of result.unreadableFiles) { + logger.info(`${yellow(` Warning: Could not read file ${file}`)}\n`); + } + logger.info(`${green(`✅ Wrote ${result.outPath}`)}\n`); + logger.info( + ` ${result.errorCount} error finding(s) across ${result.baselinedFileCount} file(s) baselined.\n` + ); + return 0; +} + +export function printMarkdocSchemaRun(result: MarkdocSchemaResult): number { + switch (result.status) { + case 'written': + logger.info(`Wrote ${result.outPath}\n`); + return 0; + case 'up-to-date': + logger.info(`${result.outPath} is up to date.\n`); + return 0; + case 'missing': + logger.error( + `${result.outPath} does not exist — run \`redocly recheck --generate-markdoc-schema\` without --check to create it.\n` + ); + return 1; + case 'stale': + logger.error( + `${result.outPath} is stale — run \`redocly recheck --generate-markdoc-schema\` to regenerate it.\n` + ); + return 1; + case 'conflicts': + for (const conflict of result.conflicts) { + logger.error(`redocly recheck --generate-markdoc-schema: ${conflict}\n`); + } + return 1; + case 'load-error': + logger.error(`${result.message}\n`); + return 1; + case 'write-error': + logger.error( + `redocly recheck --generate-markdoc-schema: could not write ${result.outPath} — ${result.message}\n` + ); + return 1; + } +} diff --git a/packages/cli/src/commands/recheck/select-action.ts b/packages/cli/src/commands/recheck/select-action.ts new file mode 100644 index 0000000000..9ec9af9049 --- /dev/null +++ b/packages/cli/src/commands/recheck/select-action.ts @@ -0,0 +1,22 @@ +import type { RecheckAction, RecheckArgv } from './types.js'; + +export function selectAction(argv: RecheckArgv): { action: RecheckAction } | { error: string } { + const flags: RecheckAction[] = []; + if (argv.readability) flags.push('readability'); + if (argv['generate-baseline']) flags.push('baseline'); + if (argv['generate-markdoc-schema']) flags.push('markdoc-schema'); + if (flags.length > 1) { + return { + error: 'Use one of --readability, --generate-baseline, or --generate-markdoc-schema.', + }; + } + const action = flags[0] ?? 'lint'; + if (argv.fix && action !== 'lint') return { error: '--fix applies to linting only.' }; + if (action === 'readability' && argv.format !== 'table' && argv.format !== 'json') { + return { error: '--readability supports --format table or json.' }; + } + if (action === 'markdoc-schema' && ((argv.from ?? []).length === 0 || !argv.output)) { + return { error: '--generate-markdoc-schema requires --from and --output.' }; + } + return { action }; +} diff --git a/packages/cli/src/commands/recheck/types.ts b/packages/cli/src/commands/recheck/types.ts new file mode 100644 index 0000000000..b3b503bb8f --- /dev/null +++ b/packages/cli/src/commands/recheck/types.ts @@ -0,0 +1,24 @@ +import type { VerifyConfigOptions } from '../../types.js'; + +export type RecheckFormat = 'table' | 'json' | 'sarif' | 'github-actions'; + +export type RecheckArgv = { + paths?: string[]; + format: RecheckFormat; + tags?: string[]; + rule?: string[]; + 'skip-rule'?: string[]; + stats?: boolean; + fix?: boolean; + 'max-problems'?: number; + summary?: 'json' | 'text'; + 'summary-path'?: string; + readability?: boolean; + 'generate-baseline'?: boolean; + 'generate-markdoc-schema'?: boolean; + from?: string[]; + output?: string; + check?: boolean; +} & VerifyConfigOptions; + +export type RecheckAction = 'lint' | 'readability' | 'baseline' | 'markdoc-schema'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d98d7d68ae..ebcecc0a93 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -28,6 +28,7 @@ import { type GenerateSpecArgv } from './commands/generate-spec/index.js'; import type { IntrospectMcpCommandArgv } from './commands/introspect-mcp/index.js'; import { PRODUCT_PLANS } from './commands/preview-project/constants.js'; import { type ProxyArgv } from './commands/proxy/index.js'; +import { type RecheckFormat } from './commands/recheck/types.js'; import type { RespectArgv } from './commands/respect/index.js'; import { validateMtlsCommandOption } from './commands/respect/mtls/validate-mtls-command-option.js'; import type { @@ -417,6 +418,73 @@ yargs(hideBin(process.argv)) commandWrapper(handlePush)(argv); } ) + .command( + 'recheck [paths..]', + 'Lint Markdown prose and structure from the recheck block in redocly.yaml.', + (yargs) => + yargs + .env('REDOCLY_CLI_RECHECK') + .positional('paths', { + array: true, + type: 'string', + describe: 'Files or directories to lint. Default: the current directory.', + }) + .option({ + config: { description: 'Path to the config file.', type: 'string' }, + 'lint-config': { + description: 'Severity level for config file linting.', + choices: ['warn', 'error', 'off'] as ReadonlyArray, + default: 'warn' as RuleSeverity, + }, + format: { + description: 'Use a specific output format.', + choices: ['table', 'json', 'sarif', 'github-actions'] as ReadonlyArray, + default: 'table' as RecheckFormat, + }, + tags: { description: 'Run only rules with these tags.', array: true, type: 'string' }, + rule: { description: 'Run only these rules.', alias: 'r', array: true, type: 'string' }, + 'skip-rule': { description: 'Skip these rules.', array: true, type: 'string' }, + stats: { description: 'Print rule statistics.', alias: 's', type: 'boolean' }, + fix: { description: 'Apply fixes to Markdown files.', alias: 'f', type: 'boolean' }, + 'max-problems': { + description: 'Maximum number of problems in the report.', + type: 'number', + }, + summary: { + description: 'Print a run summary.', + choices: ['json', 'text'] as ReadonlyArray<'json' | 'text'>, + type: 'string', + }, + 'summary-path': { description: 'Write the summary to a file.', type: 'string' }, + readability: { + description: 'Report readability scores instead of linting.', + type: 'boolean', + }, + 'generate-baseline': { + description: 'Write the baseline file from the current findings.', + type: 'boolean', + }, + 'generate-markdoc-schema': { + description: 'Generate a Markdoc tag schema from theme modules.', + type: 'boolean', + }, + from: { + description: 'Module paths to read Markdoc tags from (with --generate-markdoc-schema).', + array: true, + type: 'string', + }, + output: { description: 'Output file for the generated schema.', type: 'string' }, + check: { + description: 'Fail if the generated schema differs from --output.', + type: 'boolean', + }, + }), + async (argv) => { + const { handleRecheck } = await import('./commands/recheck/index.js'); + const { recheckPresetsPlugin } = await import('@redocly/recheck/presets'); + commandWrapper(handleRecheck, { plugins: [recheckPresetsPlugin] })(argv); + } + ) .command( 'lint [apis...]', 'Lint an API or Arazzo description.', diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index e1149d5a52..ee8da93f4a 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -15,6 +15,7 @@ import type { PreviewProjectArgv } from './commands/preview-project/types.js'; import type { ProxyArgv } from './commands/proxy/index.js'; import type { PushStatusArgv } from './commands/push-status.js'; import type { PushArgv } from './commands/push.js'; +import type { RecheckArgv } from './commands/recheck/types.js'; import type { RespectArgv } from './commands/respect/index.js'; import type { ScorecardClassicArgv } from './commands/scorecard-classic/types.js'; import type { SplitArgv } from './commands/split/types.js'; @@ -53,6 +54,7 @@ export type CommandArgv = ( | GenerateArazzoCommandArgv | EjectGeneratorCommandArgv | IntrospectMcpCommandArgv + | RecheckArgv | ScorecardClassicArgv ) & VerifyConfigOptions; diff --git a/packages/cli/src/utils/miscellaneous.ts b/packages/cli/src/utils/miscellaneous.ts index 174a522d17..f8fac06622 100644 --- a/packages/cli/src/utils/miscellaneous.ts +++ b/packages/cli/src/utils/miscellaneous.ts @@ -18,6 +18,7 @@ import { type Exact, type Async3Definition, type Async2Definition, + type Plugin, } from '@redocly/openapi-core'; import { blue, gray, green, red, yellow } from 'colorette'; import * as fs from 'node:fs'; @@ -432,12 +433,14 @@ export function printUnusedWarnings(config: Config) { export async function loadConfigAndHandleErrors( argv: Exact, - version: string + version: string, + plugins?: Plugin[] ): Promise { try { const config = await loadConfig({ configPath: argv.config, customExtends: argv.extends as string[] | undefined, + plugins, }); await handleLintConfig(argv, version, config); return config; diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts index d3d4d64ac0..73fd60f794 100644 --- a/packages/cli/src/wrapper.ts +++ b/packages/cli/src/wrapper.ts @@ -9,6 +9,7 @@ import { type CollectSpecData, type Exact, type NormalizedProblem, + type Plugin, type SpecVersion, AbortFlowError, HandledError, @@ -41,7 +42,8 @@ export type CommandArgs = { }; export function commandWrapper( - commandHandler?: (wrapperArgs: CommandArgs) => Promise + commandHandler?: (wrapperArgs: CommandArgs) => Promise, + options: { plugins?: Plugin[] } = {} ) { return async (argv: Arguments) => { const startedAt = performance.now(); @@ -111,7 +113,7 @@ export function commandWrapper( if (argv.config && !doesYamlFileExist(argv.config)) { throw new HandledError('Please provide a valid path to the configuration file.'); } - config = await loadConfigAndHandleErrors(argv as Exact, version); + config = await loadConfigAndHandleErrors(argv as Exact, version, options.plugins); telemetry = config.resolvedConfig.telemetry; code = 1; if (typeof commandHandler === 'function') { diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap index 2b8fe1afb3..7d7c3f1a73 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap @@ -373,6 +373,9 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "spec-ref-siblings": "warn", }, "preprocessors": {}, + "recheck": { + "rules": {}, + }, "root": "some/path", "rules": { "boolean-parameter-prefixes": "error", @@ -830,6 +833,9 @@ exports[`resolveConfig > should resolve extends with local file config which con "api/plugin.js", ], "preprocessors": {}, + "recheck": { + "rules": {}, + }, "rules": { "boolean-parameter-prefixes": "error", "local/operation-id-not-test": "error", diff --git a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap index 7d3f61444e..764a4fb8cc 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap @@ -47,6 +47,9 @@ Config { "openrpc1": {}, "overlay1": {}, }, + "recheck": { + "rules": {}, + }, "resolve": { "http": { "customFetch": undefined, diff --git a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap index e78cb20d75..449604b428 100644 --- a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap @@ -36,6 +36,9 @@ Config { "openrpc1": {}, "overlay1": {}, }, + "recheck": { + "rules": {}, + }, "resolve": { "http": { "customFetch": undefined, @@ -78,6 +81,9 @@ Config { "overlay1Preprocessors": {}, "overlay1Rules": {}, "preprocessors": {}, + "recheck": { + "rules": {}, + }, "root": "resources/pets.yaml", "rules": { "no-empty-servers": "error", diff --git a/packages/core/src/config/__tests__/config-resolvers.test.ts b/packages/core/src/config/__tests__/config-resolvers.test.ts index fcf52acd70..790f19f6f8 100644 --- a/packages/core/src/config/__tests__/config-resolvers.test.ts +++ b/packages/core/src/config/__tests__/config-resolvers.test.ts @@ -355,7 +355,7 @@ describe('resolveConfig', () => { it('should resolve `recommended-strict` ruleset correctly', async () => { const expectedStrict = JSON.parse(JSON.stringify(recommended)) as Omit< RawGovernanceConfig, - 'extends' | 'plugins' + 'extends' | 'plugins' | 'recheck' >; for (const section of Object.values(expectedStrict)) { for (let ruleName in section) { diff --git a/packages/core/src/config/__tests__/config.test.ts b/packages/core/src/config/__tests__/config.test.ts index c9db1cd261..5b4b2b2ecf 100644 --- a/packages/core/src/config/__tests__/config.test.ts +++ b/packages/core/src/config/__tests__/config.test.ts @@ -86,6 +86,9 @@ describe('Config.forAlias', () => { "openrpc1": {}, "overlay1": {}, }, + "recheck": { + "rules": {}, + }, "resolve": { "http": { "customFetch": undefined, @@ -127,6 +130,9 @@ describe('Config.forAlias', () => { "overlay1Rules": {}, "plugins": undefined, "preprocessors": {}, + "recheck": { + "rules": {}, + }, "resolve": { "http": { "headers": [], diff --git a/packages/core/src/config/__tests__/fixtures/recheck-presets/block-merge/block-base.yaml b/packages/core/src/config/__tests__/fixtures/recheck-presets/block-merge/block-base.yaml new file mode 100644 index 0000000000..aec4197363 --- /dev/null +++ b/packages/core/src/config/__tests__/fixtures/recheck-presets/block-merge/block-base.yaml @@ -0,0 +1,3 @@ +recheck: + rules: + recheck/line-length: warn diff --git a/packages/core/src/config/__tests__/fixtures/recheck-presets/block-merge/redocly.yaml b/packages/core/src/config/__tests__/fixtures/recheck-presets/block-merge/redocly.yaml new file mode 100644 index 0000000000..a8ddef1e9e --- /dev/null +++ b/packages/core/src/config/__tests__/fixtures/recheck-presets/block-merge/redocly.yaml @@ -0,0 +1,9 @@ +extends: + - ./block-base.yaml +recheck: + rules: + recheck/line-length: + assertions: + line-length: + max: 120 + markdoc: true diff --git a/packages/core/src/config/__tests__/fixtures/recheck-presets/extends-order/redocly.yaml b/packages/core/src/config/__tests__/fixtures/recheck-presets/extends-order/redocly.yaml new file mode 100644 index 0000000000..365ffeb3bb --- /dev/null +++ b/packages/core/src/config/__tests__/fixtures/recheck-presets/extends-order/redocly.yaml @@ -0,0 +1,3 @@ +extends: + - ./shared.yaml + - recheck/markdown diff --git a/packages/core/src/config/__tests__/fixtures/recheck-presets/extends-order/shared.yaml b/packages/core/src/config/__tests__/fixtures/recheck-presets/extends-order/shared.yaml new file mode 100644 index 0000000000..ebeb8fe7be --- /dev/null +++ b/packages/core/src/config/__tests__/fixtures/recheck-presets/extends-order/shared.yaml @@ -0,0 +1,3 @@ +recheck: + rules: + recheck/single-h1: off diff --git a/packages/core/src/config/__tests__/fixtures/recheck-presets/folder-file/recheck/shared.yaml b/packages/core/src/config/__tests__/fixtures/recheck-presets/folder-file/recheck/shared.yaml new file mode 100644 index 0000000000..97e9403540 --- /dev/null +++ b/packages/core/src/config/__tests__/fixtures/recheck-presets/folder-file/recheck/shared.yaml @@ -0,0 +1,3 @@ +recheck: + rules: + recheck/line-length: off diff --git a/packages/core/src/config/__tests__/fixtures/recheck-presets/folder-file/redocly.yaml b/packages/core/src/config/__tests__/fixtures/recheck-presets/folder-file/redocly.yaml new file mode 100644 index 0000000000..1657ef6a6e --- /dev/null +++ b/packages/core/src/config/__tests__/fixtures/recheck-presets/folder-file/redocly.yaml @@ -0,0 +1,2 @@ +extends: + - recheck/shared.yaml diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts index 03c79efda7..b40067b24c 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -505,6 +505,9 @@ describe('loadConfig', () => { "spec-ref-siblings": "off", }, "preprocessors": {}, + "recheck": { + "rules": {}, + }, "rules": { "boolean-parameter-prefixes": { "severity": "off", @@ -886,6 +889,9 @@ describe('loadConfig', () => { "spec-ref-siblings": "warn", }, "preprocessors": {}, + "recheck": { + "rules": {}, + }, "rules": { "no-unresolved-refs": "error", "rule/operation-summary": { @@ -1280,6 +1286,9 @@ describe('loadConfig', () => { "spec-ref-siblings": "off", }, "preprocessors": {}, + "recheck": { + "rules": {}, + }, "rules": { "no-ambiguous-paths": "error", "no-invalid-schema-examples": "error", @@ -1766,6 +1775,9 @@ describe('loadConfig', () => { "spec-ref-siblings": "off", }, "preprocessors": {}, + "recheck": { + "rules": {}, + }, "rules": { "boolean-parameter-prefixes": { "severity": "off", @@ -2147,6 +2159,9 @@ describe('loadConfig', () => { "spec-ref-siblings": "warn", }, "preprocessors": {}, + "recheck": { + "rules": {}, + }, "rules": { "no-unresolved-refs": "error", "rule/operation-summary": { @@ -2541,6 +2556,9 @@ describe('loadConfig', () => { "spec-ref-siblings": "off", }, "preprocessors": {}, + "recheck": { + "rules": {}, + }, "rules": { "no-ambiguous-paths": "error", "no-invalid-schema-examples": "error", diff --git a/packages/core/src/config/__tests__/recheck-config.test.ts b/packages/core/src/config/__tests__/recheck-config.test.ts new file mode 100644 index 0000000000..a557f0dc91 --- /dev/null +++ b/packages/core/src/config/__tests__/recheck-config.test.ts @@ -0,0 +1,148 @@ +import { join } from 'node:path'; +import { outdent } from 'outdent'; +import { describe, expect, it } from 'vitest'; + +import { lintConfig } from '../../lint.js'; +import { createConfig, loadConfig } from '../load.js'; +import type { Plugin } from '../types.js'; + +const fixtures = join(__dirname, 'fixtures', 'recheck-presets'); + +const lineLength = { severity: 'error' as const, assertions: { 'line-length': {} } }; +const singleH1 = { severity: 'error' as const, assertions: { 'single-h1': {} } }; +const repetition = { + severity: 'warn' as const, + message: 'Repeated', + assertions: { repetition: {} }, +}; + +// A stand-in for the plugin that `@redocly/recheck/presets` exports. +const recheckPlugin: Plugin = { + id: 'recheck', + configs: { + markdown: { + recheck: { rules: { 'recheck/line-length': lineLength, 'recheck/single-h1': singleH1 } }, + }, + prose: { recheck: { rules: { 'recheck/repetition': repetition } } }, + }, +}; + +const withPreset = outdent` + extends: + - recommended + - recheck/markdown + recheck: + rules: + recheck/line-length: off +`; + +describe('recheck presets in extends', () => { + it('resolves a preset of the passed plugin and applies the block on top', async () => { + const config = await createConfig(withPreset, { plugins: [recheckPlugin] }); + expect(config.recheck).toEqual({ + rules: { + 'recheck/line-length': { ...lineLength, severity: 'off' }, + 'recheck/single-h1': singleH1, + }, + }); + expect(config.resolvedConfig.rules?.['no-unresolved-refs']).toBeDefined(); + }); + + it('merges in extends order', async () => { + // The shared file turns `single-h1` off; the preset comes later and turns it back on. + const config = await loadConfig({ + configPath: join(fixtures, 'extends-order', 'redocly.yaml'), + plugins: [recheckPlugin], + }); + expect(config.recheck.rules?.['recheck/single-h1']).toEqual(singleH1); + }); + + it('adds the presets of an api on top of the root presets', async () => { + const config = await createConfig( + outdent` + extends: + - recheck/markdown + apis: + main: + root: ./openapi.yaml + extends: + - recheck/prose + `, + { plugins: [recheckPlugin] } + ); + expect(Object.keys(config.recheck.rules ?? {})).toEqual([ + 'recheck/line-length', + 'recheck/single-h1', + ]); + expect(Object.keys(config.forAlias('main').recheck.rules ?? {})).toEqual([ + 'recheck/line-length', + 'recheck/single-h1', + 'recheck/repetition', + ]); + }); + + it('skips recheck presets when no recheck plugin is passed', async () => { + const config = await createConfig(withPreset); + expect(config.recheck).toEqual({ rules: { 'recheck/line-length': 'off' } }); + expect(config.resolvedConfig.rules?.['no-unresolved-refs']).toBeDefined(); + }); + + it('rejects an unknown preset name', async () => { + await expect( + createConfig('extends:\n - recheck/nope\n', { plugins: [recheckPlugin] }) + ).rejects.toThrow("plugin recheck doesn't export config with name nope"); + }); + + it('reads a file in a recheck folder as a shared config file', async () => { + const config = await loadConfig({ configPath: join(fixtures, 'folder-file', 'redocly.yaml') }); + expect(config.recheck.rules).toEqual({ 'recheck/line-length': 'off' }); + }); + + it('has an empty block by default', async () => { + const config = await createConfig('extends:\n - recommended\n'); + expect(config.recheck).toEqual({ rules: {} }); + }); +}); + +describe('recheck block merge', () => { + it('keeps the block rules out of the lint rules', async () => { + const config = await createConfig(withPreset, { plugins: [recheckPlugin] }); + expect(config.resolvedConfig.rules).not.toHaveProperty('recheck/line-length'); + for (const rules of Object.values(config.rules)) { + expect(rules).not.toHaveProperty('recheck/line-length'); + } + }); + + it('merges a shared block by rule key and assigns settings', async () => { + const config = await loadConfig({ configPath: join(fixtures, 'block-merge', 'redocly.yaml') }); + expect(config.recheck).toEqual({ + rules: { + 'recheck/line-length': { severity: 'warn', assertions: { 'line-length': { max: 120 } } }, + }, + markdoc: true, + }); + }); + + it('carries a block that is not an object to the engine', async () => { + const config = await createConfig('extends:\n - recommended\nrecheck: 5\n'); + expect(config.recheck as unknown).toBe(5); + }); +}); + +describe('recheck plugin id', () => { + it('rejects a user plugin that takes the reserved id', async () => { + await expect(createConfig({ plugins: [{ id: 'recheck' }] })).rejects.toThrow('is reserved'); + }); +}); + +describe('check-config', () => { + it('accepts the block and rejects extends inside it', async () => { + const accepted = await createConfig(withPreset); + expect(await lintConfig({ config: accepted })).toEqual([]); + const rejected = await createConfig('recheck:\n extends: [recheck/markdown]\n'); + const problems = await lintConfig({ config: rejected }); + expect(problems.map((problem) => problem.message)).toEqual([ + expect.stringContaining('Property `extends` is not expected here'), + ]); + }); +}); diff --git a/packages/core/src/config/__tests__/recheck.test.ts b/packages/core/src/config/__tests__/recheck.test.ts new file mode 100644 index 0000000000..fc040fd2f9 --- /dev/null +++ b/packages/core/src/config/__tests__/recheck.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import { + isRecheckPreset, + mergeRecheckBlocks, + mergeRecheckRule, + mergeRecheckRules, +} from '../recheck.js'; + +const preset = { + severity: 'error' as const, + message: 'Line length', + assertions: { 'line-length': { max: 80 } }, +}; + +describe('isRecheckPreset', () => { + it('matches the recheck/ prefix only', () => { + expect(isRecheckPreset('recheck/markdown')).toBe(true); + expect(isRecheckPreset('recommended')).toBe(false); + expect(isRecheckPreset('my-plugin/recheck')).toBe(false); + }); +}); + +describe('mergeRecheckRule', () => { + it('sets the severity from a string and keeps the rest', () => { + expect(mergeRecheckRule(preset, 'off')).toEqual({ ...preset, severity: 'off' }); + }); + + it('merges assertions per assertion id', () => { + const merged = mergeRecheckRule(preset, { + assertions: { 'line-length': { max: 120 }, other: {} }, + }); + expect(merged).toEqual({ + severity: 'error', + message: 'Line length', + assertions: { 'line-length': { max: 120 }, other: {} }, + }); + }); + + it('keeps a severity string base under an object override', () => { + expect(mergeRecheckRule('off', { severity: 'warn' })).toEqual({ severity: 'warn' }); + expect(mergeRecheckRule('warn', { assertions: { x: {} } })).toEqual({ + severity: 'warn', + assertions: { x: {} }, + }); + }); + + it('replaces the entry for any other value', () => { + expect(mergeRecheckRule(preset, null)).toBeNull(); + expect(mergeRecheckRule(null, 'warn')).toBe('warn'); + }); +}); + +describe('mergeRecheckRules', () => { + it('merges by key, adds new keys, and changes neither input', () => { + const base = { a: preset }; + const override = { a: 'warn', b: { severity: 'info' } }; + const merged = mergeRecheckRules(base, override); + expect(merged.a).toEqual({ ...preset, severity: 'warn' }); + expect(merged.b).toEqual({ severity: 'info' }); + expect(base.a.severity).toBe('error'); + expect(override.a).toBe('warn'); + }); + + it('treats missing inputs as empty', () => { + expect(mergeRecheckRules(undefined, undefined)).toEqual({}); + }); +}); + +describe('mergeRecheckBlocks', () => { + it('merges rules by key and assigns the other keys', () => { + const merged = mergeRecheckBlocks( + { rules: { a: preset }, excludes: ['x/**'] }, + { rules: { a: 'off' }, markdoc: true } + ); + expect(merged).toEqual({ + rules: { a: { ...preset, severity: 'off' } }, + excludes: ['x/**'], + markdoc: true, + }); + }); + + it('keeps the base rules when the override has no usable rules', () => { + expect(mergeRecheckBlocks({ rules: { a: preset } }, { rules: null })).toEqual({ + rules: { a: preset }, + }); + expect(mergeRecheckBlocks({ rules: { a: preset } }, undefined)).toEqual({ + rules: { a: preset }, + }); + }); +}); diff --git a/packages/core/src/config/__tests__/utils.test.ts b/packages/core/src/config/__tests__/utils.test.ts index d4a82c0dda..f08048dc90 100644 --- a/packages/core/src/config/__tests__/utils.test.ts +++ b/packages/core/src/config/__tests__/utils.test.ts @@ -19,4 +19,9 @@ describe('mergeExtends', () => { 'rule/abc': { severity: 'warn', subject: 'Operation' }, }); }); + + it('keeps a recheck block that is not an object under a later block', () => { + const merged = utils.mergeExtends([{ recheck: 5 as never }, { recheck: { rules: {} } }]); + expect(merged.recheck as unknown).toBe(5); + }); }); diff --git a/packages/core/src/config/bundle-extends.ts b/packages/core/src/config/bundle-extends.ts index 64149425e0..e867875a96 100644 --- a/packages/core/src/config/bundle-extends.ts +++ b/packages/core/src/config/bundle-extends.ts @@ -4,6 +4,7 @@ import { isAbsoluteUrl } from '../ref-utils.js'; import { isTruthy } from '../utils/is-truthy.js'; import { type UserContext } from '../walk.js'; import { resolvePreset } from './config-resolvers.js'; +import { isRecheckPreset, RECHECK_PLUGIN_ID } from './recheck.js'; import { type Plugin, type RawGovernanceConfig } from './types.js'; import { mergeExtends } from './utils.js'; @@ -20,10 +21,14 @@ export function bundleExtends({ return node; } + const hasRecheckPlugin = plugins.some((plugin) => plugin.id === RECHECK_PLUGIN_ID); const resolvedExtends = (node.extends || []) .filter(isTruthy) .map((presetItem: string) => { if (!isAbsoluteUrl(presetItem) && !path.extname(presetItem)) { + // The caller passes the Recheck presets as a plugin. Without it they are skipped, + // so a config that uses Recheck still loads everywhere else. + if (isRecheckPreset(presetItem) && !hasRecheckPlugin) return null; return resolvePreset(presetItem, plugins); } diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts index 76499efb03..0830b21f46 100644 --- a/packages/core/src/config/config-resolvers.ts +++ b/packages/core/src/config/config-resolvers.ts @@ -28,6 +28,7 @@ import { loadPluginModule, setCachedPlugins, } from './plugins-cache.js'; +import { RECHECK_PLUGIN_ID } from './recheck.js'; import type { Plugin, RawUniversalConfig, @@ -56,6 +57,7 @@ export type ConfigOptions = { externalRefResolver?: BaseResolver; customExtends?: string[]; skipPluginEval?: boolean; + plugins?: Plugin[]; }; export async function resolveConfig({ @@ -64,6 +66,7 @@ export async function resolveConfig({ externalRefResolver, customExtends, skipPluginEval, + plugins: extraPlugins = [], }: ConfigOptions): Promise<{ resolvedConfig: ResolvedConfig; resolvedRefMap: ResolvedRefMap; @@ -103,7 +106,7 @@ export async function resolveConfig({ const instantiatedPlugins = ((config as RawUniversalConfig)?.plugins || []).filter( (p) => !isString(p) ) as Plugin[]; - resolvedPlugins = [...instantiatedPlugins, defaultPlugin]; + resolvedPlugins = [...instantiatedPlugins, defaultPlugin, ...extraPlugins]; } else { rootConfigDir = path.dirname(configPath ?? ''); pluginsOrPaths = collectConfigPlugins(rootDocument, resolvedRefMap, rootConfigDir); @@ -112,7 +115,7 @@ export async function resolveConfig({ rootConfigDir, skipPluginEval ); - resolvedPlugins = [...plugins, defaultPlugin]; + resolvedPlugins = [...plugins, defaultPlugin, ...extraPlugins]; } const bundledConfig = bundleConfig( @@ -338,6 +341,9 @@ export async function resolvePlugins( ) ); } + if (id === RECHECK_PLUGIN_ID) { + throw new Error(`Plugin id "${id}" is reserved for Recheck presets.`); + } const pluginPath = pluginInstance.absolutePath ?? p.toString(); const existingPluginPath = seenPluginIds.get(id); if (existingPluginPath) { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 8f9ed20f67..ebe4ba0be0 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1,3 +1,4 @@ +import type { RecheckConfig } from '@redocly/config'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -50,6 +51,7 @@ export class Config { rules: Record>; preprocessors: Record>; decorators: Record>; + recheck: RecheckConfig; private _usedRules: Set = new Set(); private _usedVersions: Set = new Set(); @@ -157,6 +159,7 @@ export class Config { }, graphql: {}, }; + this.recheck = resolvedConfig.recheck ?? { rules: {} }; this.ignore = opts.ignore ?? {}; } diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 423f6a1386..3b5fd508fe 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -4,5 +4,6 @@ export * from './rules.js'; export * from './builtIn.js'; export * from './load.js'; export * from './utils.js'; +export * from './recheck.js'; export * from './config-resolvers.js'; export { clearPluginsCache, getPluginCacheVersion } from './plugins-cache.js'; diff --git a/packages/core/src/config/load.ts b/packages/core/src/config/load.ts index 67ffbf6c02..cea27ec0e5 100644 --- a/packages/core/src/config/load.ts +++ b/packages/core/src/config/load.ts @@ -12,7 +12,7 @@ import { import { resolveConfig } from './config-resolvers.js'; import { Config } from './config.js'; import { CONFIG_FILE_NAME, IGNORE_FILE } from './constants.js'; -import type { RawUniversalConfig, IgnoreConfig } from './types.js'; +import type { RawUniversalConfig, IgnoreConfig, Plugin } from './types.js'; type IgnoreFileContent = Record>; @@ -60,9 +60,17 @@ export async function loadConfig( customExtends?: string[]; externalRefResolver?: BaseResolver; skipPluginEval?: boolean; + /** Ready plugin objects to add next to the built-in plugin, for example extra presets. */ + plugins?: Plugin[]; } = {} ): Promise { - const { configPath = findConfig(), customExtends, externalRefResolver, skipPluginEval } = options; + const { + configPath = findConfig(), + customExtends, + externalRefResolver, + skipPluginEval, + plugins: extraPlugins, + } = options; const resolver = externalRefResolver ?? new BaseResolver(); @@ -80,6 +88,7 @@ export async function loadConfig( configPath, externalRefResolver, skipPluginEval, + plugins: extraPlugins, }); const ignore = await loadIgnoreConfig(configPath, resolver); @@ -106,11 +115,13 @@ type CreateConfigOptions = { externalRefResolver?: BaseResolver; resolvedRefMap?: ResolvedRefMap; ignore?: IgnoreConfig; + /** Ready plugin objects to add next to the built-in plugin, for example extra presets. */ + plugins?: Plugin[]; }; export async function createConfig( config?: string | RawUniversalConfig, - { configPath, externalRefResolver, ignore }: CreateConfigOptions = {} + { configPath, externalRefResolver, ignore, plugins: extraPlugins }: CreateConfigOptions = {} ): Promise { const rawConfigSource = typeof config === 'string' ? config : ''; const rawConfigDocument = makeDocumentFromString( @@ -126,6 +137,7 @@ export async function createConfig( rawConfigDocument: cloneConfigDocument(rawConfigDocument), configPath, externalRefResolver, + plugins: extraPlugins, }); return new Config(resolvedConfig, { diff --git a/packages/core/src/config/recheck.ts b/packages/core/src/config/recheck.ts new file mode 100644 index 0000000000..b906f26f5f --- /dev/null +++ b/packages/core/src/config/recheck.ts @@ -0,0 +1,55 @@ +import type { RecheckConfig } from '@redocly/config'; + +import { isPlainObject } from '../utils/is-plain-object.js'; + +/** The plugin id of Recheck presets in `extends`. A custom plugin cannot use it. */ +export const RECHECK_PLUGIN_ID = 'recheck'; + +export type RecheckRuleEntry = NonNullable[string]; +export type RecheckRules = Record; + +/** Checks if an `extends` entry is a Recheck preset, such as `recheck/markdown`. */ +export function isRecheckPreset(name: string): boolean { + return name.startsWith(`${RECHECK_PLUGIN_ID}/`); +} + +/** + * Merges one rule entry on top of another. A severity string sets `severity`. + * An object sets its own keys and merges `assertions` by id. + * Any other value replaces the entry. + */ +export function mergeRecheckRule(base: unknown, override: unknown): unknown { + const baseRule = typeof base === 'string' && isPlainObject(override) ? { severity: base } : base; + if (!isPlainObject(baseRule)) return override; + if (typeof override === 'string') return { ...baseRule, severity: override }; + if (!isPlainObject(override)) return override; + const merged: Record = { ...baseRule, ...override }; + if (isPlainObject(baseRule.assertions) && isPlainObject(override.assertions)) { + merged.assertions = { ...baseRule.assertions, ...override.assertions }; + } + return merged; +} + +/** Merges `override` on top of `base` by rule key. */ +export function mergeRecheckRules(base?: unknown, override?: unknown): RecheckRules { + const merged: Record = isPlainObject(base) ? { ...base } : {}; + if (isPlainObject(override)) { + for (const [key, entry] of Object.entries(override)) { + merged[key] = mergeRecheckRule(merged[key], entry); + } + } + return merged as RecheckRules; +} + +/** + * Merges one `recheck` block on top of another: `rules` by rule key, other keys + * are replaced. If the override is not an object, it replaces the block so the + * engine can report the wrong type. + */ +export function mergeRecheckBlocks(base: RecheckConfig, override: unknown): RecheckConfig { + if (!isPlainObject(base)) return base; + if (override === undefined || override === null) return base; + if (!isPlainObject(override)) return override as RecheckConfig; + const { rules, ...settings } = override; + return { ...base, ...settings, rules: mergeRecheckRules(base.rules, rules) }; +} diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 15c2e6319f..5cd93f7faa 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -1,4 +1,4 @@ -import type { ApiConfig, RedoclyConfig } from '@redocly/config'; +import type { ApiConfig, RecheckConfig, RedoclyConfig } from '@redocly/config'; import type { JSONSchema } from 'json-schema-to-ts'; import type { @@ -142,6 +142,8 @@ export type RawGovernanceConfig = arazzo1_1Decorators?: Record; overlay1Decorators?: Record; openrpc1Decorators?: Record; + + recheck?: RecheckConfig; }; export type ResolvedGovernanceConfig = Omit; diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts index a99d4dcf52..e3429b1fd7 100644 --- a/packages/core/src/config/utils.ts +++ b/packages/core/src/config/utils.ts @@ -1,3 +1,5 @@ +import type { RecheckConfig } from '@redocly/config'; + import type { Oas3RuleSet, Oas2RuleSet, @@ -10,6 +12,7 @@ import type { } from '../oas-types.js'; import { assignOnlyExistingConfig, assignConfig } from '../utils/assign-config.js'; import { isPlainObject } from '../utils/is-plain-object.js'; +import { mergeRecheckBlocks } from './recheck.js'; import type { ImportedPlugin, ResolvedGovernanceConfig, Plugin, PluginCreator } from './types.js'; export function parsePresetName(presetName: string): { pluginId: string; configName: string } { @@ -43,6 +46,7 @@ export function prefixRules< } export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { + let recheck: RecheckConfig = { rules: {} }; const result: Required = { rules: {}, oas2Rules: {}, @@ -80,6 +84,8 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { arazzo1_1Decorators: {}, overlay1Decorators: {}, openrpc1Decorators: {}, + + recheck, }; for (const rulesConf of rulesConfList) { @@ -156,7 +162,10 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { assignOnlyExistingConfig(result.overlay1Decorators, rulesConf.decorators); assignConfig(result.openrpc1Decorators, rulesConf.openrpc1Decorators); assignOnlyExistingConfig(result.openrpc1Decorators, rulesConf.decorators); + + recheck = mergeRecheckBlocks(recheck, rulesConf.recheck); } + result.recheck = recheck; return result; } diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 4bb2fac678..ecab4839f2 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -264,7 +264,7 @@ const builtInDecorators = [...builtInOas3Decorators, ...builtInOas2Decorators] a type BuiltInDecoratorId = (typeof builtInDecorators)[number]; const configGovernanceProperties: Record< - keyof RawGovernanceConfig, + Exclude, NodeType['properties'][string] > = { extends: { diff --git a/packages/recheck/CHANGELOG.md b/packages/recheck/CHANGELOG.md new file mode 100644 index 0000000000..d8597097b3 --- /dev/null +++ b/packages/recheck/CHANGELOG.md @@ -0,0 +1,125 @@ +# @redocly/recheck + +From version 2.x this package lives in the `redocly-cli` repository and releases with Redocly CLI. +Entries below 2.x come from its previous home. + +## 0.12.0 + +### Minor Changes + +- f564df1f13f: Added an embedded mode for markdown that lives inside another document, such as an OpenAPI `description` field. + The engine's `embedded` option parses a leading `---` as content instead of front matter, and drops the rules embedded markdown cannot support, even when a config names them. + Added the `recheck/api-descriptions` preset: the markdown rules that make sense for description fields, curated from measurements over two large API definitions. + +## 0.11.1 + +### Patch Changes + +- ecf6b33ae2b: Fixed `link-fragments` reporting valid deep links to `accordion` Markdoc tags as broken. + The rule resolves accordion anchors from the `title` attribute, the same way the theme generates ids for `details` elements, in the same file and across files with `crossFile`. + +## 0.11.0 + +### Minor Changes + +- 074add45843: Added a built-in Realm front matter schema, selected with `schema: realm`, that validates the options a Realm page accepts without vendoring a copy that goes stale. + Added a `strict: true` option that turns a misspelled front matter option into a finding. + +## 0.10.0 + +### Minor Changes + +- 0c017e5da0b: Unified Recheck into one command, matching the interface planned for `redocly recheck`: linting runs by default, and the other actions moved to flags. + `recheck run ` becomes `recheck `, `recheck readability ` becomes `recheck --readability`, `recheck baseline ` becomes `recheck --generate-baseline`, `recheck validate` becomes `recheck --validate-config`, and `recheck markdoc-schema` becomes `recheck --generate-markdoc-schema`. + A removed subcommand fails with the exact replacement invocation. + +### Patch Changes + +- 04f17f80cb7: Fixed sentence detection in blockquotes: quote continuation markers no longer glue sentences together, and a blank line (quoted or not) now always ends a sentence, even without terminal punctuation. + A leading bold label (`**Label:** Description.`) no longer counts toward a sentence's word count in `length` rules such as `technical-english/sentence-length`. +- fa0297e8cf2: Fenced code, html blocks, and comments nested inside blockquotes or list items no longer count toward sentence-scoped rules: sentences now derive only from the prose around them. + The `run` command now reports an error when given more than one path instead of silently scanning only the first. +- b9771145bba: Fixed sentence splitting at: indented continuation lines, terminators inside closing emphasis or quotes, next sentences that open with emphasis markers, and Markdown hard breaks. + Sentences inside multi-line list items, blockquotes, and Markdoc tag bodies were joined into one span. + +## 0.9.0 + +### Minor Changes + +- 5bb15bcbb85: Added `recheck/technical-english`: a preset that helps writers follow the principles of ASD-STE100 Simplified Technical English — sentence length, paragraph length, and passive voice. + ASD-STE100 Simplified Technical English is a Copyright and a Trade Mark of ASD, Brussels, Belgium. + + The preset is an independent work, not endorsed by ASD or the STEMG, and reproduces no part of the standard. + +### Patch Changes + +- b1ae95e5f84: Updated the built-in Realm Markdoc schema with additional attributes for the `card` tag: `linkIcon`, `cta`, `iconColor`, `badge`, `badgeColor`, and `badgeIcon`. + +## 0.8.0 + +### Minor Changes + +- 1c5d579c05a: Added two agent skills to the npm package under `skills/`: + - `recheck-lint` tells an agent to run recheck on touched Markdown before committing or outputting it + - `recheck-config` guides writing and tuning `recheck.yaml` from measured counts + Copy them into `.claude/skills/` (or an equivalent) to make every agent a recheck user. +- f1bc475d0e7: Cross-file link validation was improved to correctly resolve links to `
` sections, prevent Markdoc tags in headings from changing anchors, and support an `ignoredTargets` option to skip renderer-generated routes by glob. + +## 0.7.0 + +### Minor Changes + +- 50a631cb634: Added the `front-matter` rule: it validates front matter against JSON Schema with `@redocly/ajv`, the validator Redocly CLI uses. + Map file patterns to inline schemas or schema files; findings point at the offending key's line, a file with no front matter validates as an empty object, and invalid YAML is reported at the block start. + +## 0.6.0 + +### Minor Changes + +- 17ffa8b9b99: Added `rootDir` to the `link-fragments` assertion: with `crossFile: true`, site-root absolute links (`/x/y`) resolve against it, with the same existence and fragment checks as relative links. + For a monorepo with several docs projects, `rootDir` also takes a map from source-directory prefix to that directory's site root, and the longest matching prefix wins. + Paths are relative to the working directory, and absolute links stay skipped when the option is not set. + +## 0.5.0 + +### Minor Changes + +- b8b29053f50: Added a baseline: `recheck baseline` records the error findings that exist today as one count per file per rule, and `recheck run` with a `baseline` config key then fails only on new findings, suppresses matched ones, and fails when the baseline goes stale so counts only step down. +- 39767b060b8: Added `crossFile: true` to the `link-fragments` assertion: relative link and image targets must exist on disk, and `file.md#anchor` fragments must exist in the target file. + Extensionless links resolve like the Realm router (`./page` tries `page.md`; a directory link reads its `index.md`), targets are cached by modification time, and the option is off by default. +- 89302ace3c3: Added `recheck readability`: per-file Flesch reading ease, Flesch-Kincaid grade, Automated Readability Index, words, and sentences, with medians, as a table or JSON. + It reads the same prose and formulas as the `metric` assertion, so reported scores and gated bounds can never disagree, and it replaces the always-report threshold workaround previously needed to get scores out of a lint run. +- 1d404e664ee: The `semantic-line-breaks` assertion checks list items. It reports bullet or numbered items with two sentences on one line and reflows them with `--fix` to align continuation lines under the list content. Nested pseudo-marker lines (e.g., `- 1. Text`, `- a. Text`) remain excluded. +- 73e4213006c: Added `accordion` and `accordion-group` Markdoc tags to the built-in `realm` schema. + +## 0.4.1 + +### Patch Changes + +- 66ea4ea7448: Fixed readability metric scores to match standard readability tools: headings are excluded from the score, and a block with no end punctuation, such as a list item or a table cell, now ends a sentence, so list-heavy pages no longer score far below zero. + +## 0.4.0 + +### Minor Changes + +- 34637eddbc0: Added a top-level `excludes` option so a shared ignore list is declared once instead of repeated on every rule, and marked which findings `--fix` can repair with a `[fixable]` tag in the table output and a `fixable` field in JSON output. + +### Patch Changes + +- 34637eddbc0: Fixed an issue where a period inside a link, as in `See [Step 1. Configure](#step-1) for details.`, was read as the end of a sentence and caused false `semantic-line-breaks` reports. + +## 0.3.0 + +### Minor Changes + +- 45993f8f036: Adds `--rule` and `--exclude-rule` to `recheck run`, so you can work through findings one rule at a time instead of facing the whole list at once. Both take the short rule name shown in the report or the full config key, and both can be repeated. A name that matches no rule in your config is an error rather than an empty run. + +### Patch Changes + +- ddd48125fc2: Fixed five false positives in `capitalization` rules, including incorrect lowercasing of the first word and improper capitalization after inline code. The rule now better distinguishes sentence boundaries, preserving correct casing for version tokens like `v2.0`, vendor extensions such as `x-codeSamples`, and technical terms including `curl`. + +## 0.2.0 + +### Minor Changes + +- 888cb06d233: Rebuilt on a new parser, with breaking changes since 0.1.0: config validation is now strict, so rule options that were previously ignored now fail validation. Adds markdownlint-equivalent rules with autofix, prose checks (word swaps, patterns, repetition, capitalization, readability, spelling), ready-made style presets, and opt-in Markdoc tag linting. diff --git a/packages/recheck/README.md b/packages/recheck/README.md new file mode 100644 index 0000000000..2a1d0ba804 --- /dev/null +++ b/packages/recheck/README.md @@ -0,0 +1,74 @@ +# @redocly/recheck + +The Markdown and prose linting engine behind `redocly recheck`. + +Use it through Redocly CLI. +Configure it in the `recheck` block of `redocly.yaml`, and name presets in the root `extends`: + +```yaml +extends: + - recommended + - recheck/markdown +recheck: + rules: + recheck/line-length: off +``` + +Run it with `npx @redocly/cli recheck`. +Its documentation is the [recheck command page](../../docs/@v2/commands/recheck.md). + +## Programmatic use + +The package exports the engine for tools that embed it: + +- `@redocly/recheck/presets` exports the presets as a plugin, `recheckPresetsPlugin`. + Pass it in `plugins` to `loadConfig` or `createConfig` of `@redocly/openapi-core`, and read the merged block from `config.recheck`. +- `resolveRecheckConfig({ block, configDir })` turns a resolved `recheck` block into normalized rules. +- `lintFiles` and `lintContent` run those rules over files or strings. +- `runLint`, `generateBaseline`, `runReadability`, and `generateMarkdocSchema` are the actions the CLI command calls. + Each action returns a result object. + The CLI prints it. + `runLint`, `generateBaseline`, and `runReadability` accept one path or a list of paths. +- `parseMarkdown`, `extractScopes`, and `applyFixesToContent` expose the parser, the scope extractor, and the fixer. + +The package depends on `@redocly/openapi-core` for the `Plugin` type of the presets entry. + +The standalone `recheck` binary and the `recheck.yaml` file are not part of this package. + +## Configuration + +### Opt-in prose assertions + +`conditional`, `metric`, and `spelling` have no single right default. +Add them explicitly under `recheck.rules`: + +```yaml +extends: + - recheck/markdown +recheck: + rules: + recheck/tbd-needs-tracking-link: + severity: warn + message: '"%s" appears but "%s" was never introduced.' + assertions: + conditional: + first: '\bTBD\b' + second: 'https://github\.com/\S+/issues/\d+' + recheck/readability-floor: + severity: warn + message: 'Readability (%s) is %s; expected between %s and %s.' + assertions: + metric: + formula: flesch-reading-ease + min: 30 + recheck/us-spelling-check: + severity: warn + message: 'Unknown word "%s"%s' + assertions: + spelling: + vocab: [Redocly, Reunite] +``` + +## How to contribute + +See the [Recheck engine](../../CONTRIBUTING.md#recheck-engine) section of the root contributing guide. diff --git a/packages/recheck/examples/appendices/google.appendix.yaml b/packages/recheck/examples/appendices/google.appendix.yaml new file mode 100644 index 0000000000..2ebaef8c9b --- /dev/null +++ b/packages/recheck/examples/appendices/google.appendix.yaml @@ -0,0 +1,91 @@ +# ============================================================================== +# Appendix — candidates NOT shipped by recheck/google (hand-maintained) +# ============================================================================== +# This file is appended verbatim to the generated example above by +# `node scripts/generate-examples.mjs`. Editing it +# makes examples/google.yaml stale until you regenerate — the drift test's +# failure message says so, so this doesn't read as a generator bug. +# +# Full detail, every candidate, and why: packages/recheck/presets/google/PROVENANCE.md +# +# ------------------------------------------------------------------------------ +# NOISY — real, guide-confirmed content, judged too broad to enforce +# mechanically. Shown here as the rule it WOULD be if shipped, commented out, +# so you can see the shape being avoided rather than just a name. +# ------------------------------------------------------------------------------ + +# NOISY: a heading starting with an "-ing" word would also flag ordinary +# tech-noun headings used as topics, not verb-form imperatives (Networking, +# Logging, Caching, Monitoring, Testing) — Google names only two exceptions +# (Billing, Pricing), itself evidence this needs more context than a +# heading's first word. +# google/no-gerund-headings: +# severity: warn +# scope: heading +# message: 'Avoid starting a heading with a gerund (Google).' +# assertions: +# pattern: +# ignoreCase: true +# tokens: ['^\w*ing\b'] + +# NOISY: table cells often legitimately contain short labels, proper nouns, +# or numeric/code values that don't fit sentence-case cleanly. +# google/table-sentence-case: +# severity: warn +# scope: table.cell +# message: 'Use sentence case for table cells (Google).' +# assertions: +# capitalization: +# match: $sentence + +# NOISY: slashes appear constantly in dates, paths, fractions, and URLs — a +# blind "avoid slashes" pattern would flag nearly all of them. +# google/no-slashes-general: +# severity: warn +# message: 'Avoid using slashes, except in code (Google).' +# assertions: +# pattern: +# tokens: ['(?'] + +# ------------------------------------------------------------------------------ +# NEEDS HUMAN REVIEW — real guide content with no safe mechanical detection +# (NOT-ENFORCEABLE). Recheck cannot check these; review them yourself: +# ------------------------------------------------------------------------------ +# [ ] "Optional:" prefix on genuinely optional sections (requires knowing +# whether a section is actually optional) +# [ ] Text before a colon must be a complete sentence +# [ ] Oxford comma (missing-comma detection needs real clause parsing) +# [ ] Acronyms used as verbs ("ping the server") — needs POS tagging +# [ ] Spell out an abbreviation on first mention (needs unbounded +# first-mention tracking across the document) +# [ ] Avoid linking the same destination from different link text (the +# guide's own exceptions — different section, long page, multiple +# entry points — need judgment a token rule can't apply) +# [ ] External link icon (a rendered visual/CSS concern, not markdown text) +# [ ] Drop "..." when quoting a UI element name (risks over- and +# under-firing with a regex) +# [ ] Directional language (above/below/right-hand side) as a spatial UI +# reference vs. its equally common non-directional use +# [ ] "A link isn't a button" — requires knowing what a referenced UI +# element actually is +# [ ] Generic he/him/his/she/her — requires knowing whether a pronoun +# names a specific person or is used generically +# [ ] Active voice, metaphor avoidance, and the "what belongs in code +# font" table — all require holistic judgment about content/structure diff --git a/packages/recheck/examples/appendices/inclusive-language.appendix.yaml b/packages/recheck/examples/appendices/inclusive-language.appendix.yaml new file mode 100644 index 0000000000..620f1f238b --- /dev/null +++ b/packages/recheck/examples/appendices/inclusive-language.appendix.yaml @@ -0,0 +1,61 @@ +# ============================================================================== +# Appendix — candidates NOT shipped by recheck/inclusive-language (hand-maintained) +# ============================================================================== +# This file is appended verbatim to the generated example above by +# `node scripts/generate-examples.mjs`. Editing it +# makes examples/inclusive-language.yaml stale until you regenerate — the +# drift test's failure message says so, so this doesn't read as a generator bug. +# +# Full detail, every candidate, and why: +# packages/recheck/presets/inclusive-language/PROVENANCE.md +# +# This preset ships only terms CONFIRMED independently by both +# recheck/google and recheck/microsoft (the intersection). Most exclusions +# here are single-guide coverage, not false-positive risk — listed in the +# "needs a second source" checklist below rather than as commented rule +# blocks, since most were never actually rule-shaped candidates. +# +# ------------------------------------------------------------------------------ +# NOISY — real content on BOTH guides' broader word lists, still excluded +# from an inclusive-language framing because of an unrelated-sense collision. +# ------------------------------------------------------------------------------ + +# NOISY: "hang"/"hung" and "healthy" (of a system) are general word-choice +# content on both sides, not sourced from either guide's bias-free/ +# accessibility pages — and both collide constantly with unrelated correct +# senses ("hung the picture", "hung jury", "a healthy amount of caution"). +# inclusive-language/hang-healthy: +# severity: warn +# message: 'Avoid "%s" as a system-state metaphor; consider a precise term.' +# assertions: +# pattern: +# ignoreCase: true +# tokens: ['\bhang(s|ing)?\b', '\bhealthy\b'] + +# NOISY: "disable"/"disabled" (of a broken system) has a narrow ableist- +# metaphor sense on both guides, but the bare word is core, ubiquitous, +# entirely correct API-documentation vocabulary ("disable this feature", +# "the API is disabled") — a bare-word rule would misfire on nearly every +# document describing a feature flag. +# inclusive-language/disable-disabled: +# severity: warn +# message: 'Avoid "%s" as a system-state metaphor; consider "turn off"/"deactivate".' +# assertions: +# pattern: +# ignoreCase: true +# tokens: ['\bdisabled?\b'] + +# ------------------------------------------------------------------------------ +# NEEDS A SECOND SOURCE — real content on exactly ONE guide's side. Shipping +# these here would make this preset silently guide-specific, defeating the +# point of an intersection. Full term lists are in PROVENANCE.md; a sample: +# ------------------------------------------------------------------------------ +# [ ] Google-only: blackhat/whitehat, black-box/white-box testing, white +# label, grandfathered, ninja, guru, man hours, male/female adapter, +# man-in-the-middle, the disabled, a quadriplegic, wheelchair-bound +# [ ] Microsoft-only: chairman/chairwoman, mankind, manpower, salesman, +# screened subnet, dumb, mute, non-verbal, an epileptic, Asperger's, +# lame (already excluded on Microsoft's own preset for the same reason) +# [ ] Either term needing a live re-check against the OTHER guide before +# it could ever be added here — this is the only path to growing this +# preset beyond its current 11 rules diff --git a/packages/recheck/examples/appendices/microsoft.appendix.yaml b/packages/recheck/examples/appendices/microsoft.appendix.yaml new file mode 100644 index 0000000000..be84e0ad08 --- /dev/null +++ b/packages/recheck/examples/appendices/microsoft.appendix.yaml @@ -0,0 +1,99 @@ +# ============================================================================== +# Appendix — candidates NOT shipped by recheck/microsoft (hand-maintained) +# ============================================================================== +# This file is appended verbatim to the generated example above by +# `node scripts/generate-examples.mjs`. Editing it +# makes examples/microsoft.yaml stale until you regenerate — the drift +# test's failure message says so, so this doesn't read as a generator bug. +# +# Full detail, every candidate, and why: packages/recheck/presets/microsoft/PROVENANCE.md +# +# ------------------------------------------------------------------------------ +# TOO-RISKY / NOISY — real, guide-confirmed content, judged too broad or too +# audience-conditional to enforce mechanically. Shown here as the rule it +# WOULD be if shipped, commented out, so you can see the shape being avoided. +# ------------------------------------------------------------------------------ + +# TOO-RISKY: "deprecated" is confirmed guide content ("avoid in content for a +# technical audience"), but it's itself load-bearing OpenAPI vocabulary (the +# `deprecated: true` field) — shipping this would misfire on Redocly's own +# API-documentation domain. +# microsoft/deprecated-obsolete: +# severity: warn +# message: 'Use "%s" instead of "%s" (Microsoft).' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { deprecated: obsolete } + +# TOO-RISKY: Microsoft's own page carves out exactly Redocly's audience — +# "it's OK to use header as a short form of file header, as in HTML header" +# — the standard, correct term for an HTTP/API header throughout API docs. +# microsoft/header-heading: +# severity: warn +# message: 'Use "%s" instead of "%s" (Microsoft) — except in developer content.' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { header: heading } + +# TOO-RISKY: "context menu" is developer-audience-conditional per Microsoft's +# own page ("use context menu only in content for developers") — Redocly's +# docs ARE developer content, so this would misfire if shipped unconditionally. +# microsoft/context-menu: +# severity: warn +# message: 'Use "%s" instead of "%s" (Microsoft) — except in developer content.' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { context menu: shortcut menu } + +# TOO-RISKY: "that" referring to people is one of the most common words in +# English; only a small fraction of occurrences refer to people at all, so a +# literal swap toward "who" would be extraordinarily noisy. +# microsoft/that-who: +# severity: warn +# message: 'Consider "%s" instead of "%s" when referring to a person (Microsoft).' +# assertions: +# pattern: +# tokens: ['\bthat\b'] + +# TOO-RISKY: "star" is an ordinary English word (star rating, star +# performer) with a narrow, Microsoft-documented exception (OK for a +# phone-keypad key) a blanket swap would miss in both directions. +# microsoft/star-asterisk: +# severity: warn +# message: 'Use "%s" instead of "%s" (Microsoft), except for the phone-keypad key.' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { star: asterisk } + +# ------------------------------------------------------------------------------ +# NEEDS HUMAN REVIEW — real guide content with no safe mechanical detection +# (NOT-ENFORCEABLE). Recheck cannot check these; review them yourself: +# ------------------------------------------------------------------------------ +# [ ] Active voice, subjunctive mood, verb-first sentences, modifier +# stacks, that/who omission — all require POS tagging or grammatical- +# role judgment beyond regex/AST primitives +# [ ] Table cells in sentence case — reference tables are dense with +# identifiers and proper nouns that don't fit cleanly +# [ ] "Name" as a column header — genuinely correct in many API reference +# tables; Microsoft's own objection needs context this preset doesn't have +# [ ] Second-level headings need at least two siblings; no two headings in +# a row; list-item parallelism +# [ ] En dash for a minus sign; "from"/"through" ranges; closing-quote +# placement; mid-sentence colon rules +# [ ] Spell out single-digit numbers; no sentence-initial numerals; no +# K/M/B abbreviations; spell out months +# [ ] Bare-URL/link-formatting rules (available generically via +# `recheck/markdown`'s `no-bare-urls` if you want them, just not +# attributed to Microsoft specifically here) +# [ ] House-rule structural conventions (link fragments, fenced-code +# language, single H1, consistent bullet/fence/emphasis style) — real +# and worth having, but the guide itself never states them, so they +# don't ship under a Microsoft attribution diff --git a/packages/recheck/examples/appendices/plain-language.appendix.yaml b/packages/recheck/examples/appendices/plain-language.appendix.yaml new file mode 100644 index 0000000000..880ccb6933 --- /dev/null +++ b/packages/recheck/examples/appendices/plain-language.appendix.yaml @@ -0,0 +1,88 @@ +# ============================================================================== +# Appendix — candidates NOT shipped by recheck/plain-language (hand-maintained) +# ============================================================================== +# This file is appended verbatim to the generated example above by +# `node scripts/generate-examples.mjs`. Editing it +# makes examples/plain-language.yaml stale until you regenerate — the drift +# test's failure message says so, so this doesn't read as a generator bug. +# +# Full detail, every candidate, and why: +# packages/recheck/presets/plain-language/PROVENANCE.md +# +# ------------------------------------------------------------------------------ +# NOISY — live, verbatim guide content, judged too broad or too domain-risky +# to enforce mechanically. Shown here as the rule it WOULD be if shipped, +# commented out, so you can see the shape being avoided. +# ------------------------------------------------------------------------------ + +# NOISY: live on the guide's own table, but this 1:1 substitution misreads +# ordinary, correct accountability language ("the team is responsible for +# the migration") as wordiness — no textual signal separates that from padding. +# plain-language/be-responsible-for: +# severity: warn +# message: 'Consider "%s" instead of "%s" (plain language).' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { be responsible for: must } + +# NOISY, HIGH-RISK FOR THIS DOMAIN: "implement" is live guide content, but in +# software/API documentation "implement an interface", "implement this +# endpoint", "implement the spec" is ordinary, correct technical vocabulary, +# not wordiness. Flagging it would misfire across nearly every technical +# document Recheck targets. +# plain-language/implement: +# severity: warn +# message: 'Consider "%s" instead of "%s" (plain language).' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { implement: carry out } + +# NOISY: this row is itself a garbled pairing on the live page, and +# separately "command" has a strong, unrelated, extremely common technical +# sense (CLI command, terminal command) — core developer-docs vocabulary. +# plain-language/this-activity-command: +# severity: warn +# message: 'Consider "%s" instead of "%s" (plain language).' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { this activity, command: us, we } + +# NOT SHIPPED, ANY DIRECTION, EVER: "shall" is live guide content ("use +# 'must' not 'shall'"), but SHALL is a defined RFC 2119 normative keyword +# (alongside MUST/SHOULD/MAY/MUST NOT) used throughout specifications and +# API/interface documentation — exactly Recheck's target content. This is +# the single highest-risk exclusion in this preset: a rule flagging it would +# be actively wrong for that whole document class, not an edge case to +# carve out. +# plain-language/shall-must: +# severity: warn +# message: 'Consider "%s" instead of "%s" (plain language) — never for RFC 2119 usage.' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { shall: must } + +# ------------------------------------------------------------------------------ +# NEEDS HUMAN REVIEW — real guide content with no safe mechanical detection +# (NOT-ENFORCEABLE). Recheck cannot check these; review them yourself: +# ------------------------------------------------------------------------------ +# [ ] Nominalizations ("hidden verbs") — the guide's own suffix pattern +# (-ment/-tion/-sion/-ance + a link verb) false-positives constantly on +# ordinary correct nouns ("chance", "information", "assurance") +# [ ] Sentence-length — no word count is stated anywhere on the live site, +# only "write short sentences" +# [ ] Grade-level/readability score — no number is stated anywhere on the +# live site (see PROVENANCE.md "THE NUMBER"); `metric` stays a +# documented opt-in, not something this preset can source from the guide +# [ ] Abbreviation count per document ("no more than three, preferably +# two") — counts DISTINCT abbreviation TYPES, which neither `occurrence` +# nor `length` can express; an engine gap, not a judgment call +# [ ] Paraphrase-testing sample size / testing iteration count — user- +# testing methodology, not document content a linter can ever evaluate diff --git a/packages/recheck/examples/appendices/technical-english.appendix.yaml b/packages/recheck/examples/appendices/technical-english.appendix.yaml new file mode 100644 index 0000000000..3b9464d740 --- /dev/null +++ b/packages/recheck/examples/appendices/technical-english.appendix.yaml @@ -0,0 +1,33 @@ +# ============================================================================== +# Appendix — candidates NOT shipped by recheck/technical-english (hand-maintained) +# ============================================================================== +# This file is appended verbatim to the generated example above by +# `node scripts/generate-examples.mjs`. +# +# Full detail and reasons: packages/recheck/presets/technical-english/PROVENANCE.md +# +# ------------------------------------------------------------------------------ +# NOT SHIPPED — the STE approved-word dictionary +# ------------------------------------------------------------------------------ +# The dictionary is part of the copyrighted standard; encoding it would +# reproduce the standard in part. For general word-choice checking, compose +# this preset with recheck/plain-language. +# +# ------------------------------------------------------------------------------ +# NOT SHIPPED — noun clusters, present tense, one instruction per sentence +# ------------------------------------------------------------------------------ +# Noun clusters need part-of-speech tagging; a tense rule fights legitimate +# `will` in changelogs and roadmaps; one-instruction-per-sentence is not +# reliably detectable (sentence length is the proxy). +# +# ------------------------------------------------------------------------------ +# TIGHTEN for procedure-only content +# ------------------------------------------------------------------------------ +# The shipped default is the descriptive-text bound (25 words). A project +# whose corpus is procedures overrides to the procedural bound: +# +# technical-english/sentence-length: +# assertions: +# length: +# unit: words +# max: 20 diff --git a/packages/recheck/examples/google.yaml b/packages/recheck/examples/google.yaml new file mode 100644 index 0000000000..e13dbaedbc --- /dev/null +++ b/packages/recheck/examples/google.yaml @@ -0,0 +1,1525 @@ +# ============================================================================== +# recheck/google — Google developer documentation style guide, as a config +# ============================================================================== +# Source: Google developer documentation style guide +# https://developers.google.com/style +# License: CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/ +# Synced: 2026-07-29 +# Provenance: packages/recheck/presets/google/PROVENANCE.md (every rule's +# source page, quote, and verdict; everything considered and +# NOT shipped, and why) +# +# Rules are adapted to Recheck's own assertion vocabulary (swap, pattern, +# capitalization, length); wording is paraphrased in each rule's `message`, +# never quoted verbatim from the guide. +# +# Generated file — do not hand-edit. Regenerate with `node scripts/generate-examples.mjs` +# after changing src/config/presets/google.ts or this file's appendix +# (examples/appendices/google.appendix.yaml). + +# ------------------------------------------------------------------------------ +# What to paste +# ------------------------------------------------------------------------------ +# This is the entire adoption cost: two lines. `recheck/markdown` is the +# 53-rule markdownlint-parity structural set; `recheck/google` layers Google's +# own style opinions on top — it ships its own heading/list/table/link +# structural rules too, so the two are complementary, not duplicates. + +extends: + - recheck/markdown + - recheck/google + +# ------------------------------------------------------------------------------ +# How to tune it +# ------------------------------------------------------------------------------ +# Your own rule keys always win over the preset's (`extends` resolves +# first, then your top-level keys are merged on top, per rule key). +# +# Turn a rule off entirely: +# +# google/no-via: +# severity: off +# +# Downgrade an error to a warning: +# +# google/heading-sentence-case: +# severity: warn +# +# Silence one occurrence instead of the whole rule, with an inline HTML +# comment directive — works on any rule, from any preset: +# +# +# Log in via the admin console. +# +# +# ...several occurrences here are all silenced... +# +# +# +# +# THE SHARP EDGE — read this before reaching for a per-term override. +# Merging happens per ASSERTION ID, not per option inside it. +# Setting: +# +# google/compound-forms: +# assertions: +# swap: +# ignoreCase: false +# +# does not just flip `ignoreCase` — it REPLACES the whole `swap` assertion +# object, silently dropping the preset's 81-entry `pairs` map along with +# it, and the config then fails validation outright: +# +# Rule "google/compound-forms": swap requires a "pairs" object mapping +# find -> replace strings +# +# (verified against this exact rule). +# So today, to reject one term out of a bundled swap/pattern rule, your +# options are: turn the whole rule off, restate its entire pairs/tokens +# yourself, or inline-disable each occurrence as shown above. A per-term +# opt-out for swap/pattern is a known follow-up, not a promise. +# +# Two assertion types DO already have a per-term escape hatch: +# `capitalization`'s `exceptions` (an array of allowed terms that COMPOSES +# with the built-in technical-proper-noun vocabulary and anything else you +# add — it does not replace either) and `spelling`'s `ignore` (spelling +# itself isn't shipped by any of these four presets — see the README's +# "Opt-in prose assertions"). +# For example, to stop this preset's own sentence-case rule from flagging +# your product's name: +# +# google/heading-sentence-case: +# assertions: +# capitalization: +# match: $sentence +# exceptions: [Your Product Name] +# +# Verified: this reports zero findings on a heading using "Your Product +# Name" while every other sentence-case violation still fires normally. + +# ------------------------------------------------------------------------------ +# Full expansion (reference) +# ------------------------------------------------------------------------------ +# This is what `extends: [recheck/google]` resolves to today: all 99 rules, +# alphabetized by rule id, generated straight from the live preset (`node +# scripts/generate-examples.mjs`). You do not need to copy any of it — the +# extends block above already gives you all of this. Read it to see exactly +# what you are adopting, or as a starting point if you would rather pin or +# fork specific rules into your own config instead of extending the preset. +# +# `fix: false` on every rule below means what it says: detection-only, no +# exceptions — see "How to tune it" above and PROVENANCE.md for why. + +google/abbrev-no-periods: + severity: warn + message: Don't use periods with acronyms or initialisms such as "%s" (Google). + link: https://developers.google.com/style/abbreviations + scope: summary + fix: false + assertions: + pattern: + tokens: + - \b(?:[A-Z]\.){2,} +google/ableist-figurative-terms: + severity: warn + message: Use a precise term such as "%s" instead of "%s" when describing a system or object (Google). + link: https://developers.google.com/style/word-list#crazy + scope: summary + fix: false + assertions: + swap: + pairs: + crazy: unexpected + insane: unexpected + lunatic: unexpected + bonkers: unexpected + loony: unexpected + sane: valid + sanity check: quick check + dumb down: simplify + retarded: slowed + ignoreCase: true + wordBoundary: true +google/acronym-caps-detect-only: + severity: warn + message: 'Use "%s" instead of "%s" (Google: fixed acronym/abbreviation form).' + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + UNICODE: Unicode + IPSEC: IPsec + IO: I/O + wordBoundary: true +google/acronym-forms: + severity: warn + message: 'Use "%s" instead of "%s" (Google: fixed acronym/abbreviation form).' + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + HTTPs: HTTPS + IPSec: IPsec + No-SQL: NoSQL + No SQL: NoSQL + OAuth 2(?!\.0): OAuth 2.0 + OAuth2: OAuth 2.0 + Oauth: OAuth 2.0 + micro-services: microservices + fin-tech: fintech + adtech: ad tech + ad-tech: ad tech + wordBoundary: true + keysAreRegex: true +google/acronym-forms-proper-noun: + severity: warn + message: 'Use "%s" instead of "%s" (Google: fixed acronym/abbreviation form).' + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + I-O: I/O + FinTech: fintech + wordBoundary: true + keysAreRegex: true +google/aka-form: + severity: warn + message: Use "%s" instead of "%s" (Google). + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + aka: also known as + ignoreCase: true + wordBoundary: true +google/black-white-box-testing: + severity: warn + message: Use "%s" instead of "%s" (Google inclusive language). + link: https://developers.google.com/style/word-list#black-box + scope: summary + fix: false + assertions: + swap: + pairs: + black-box testing: opaque-box testing + black box testing: opaque-box testing + white-box testing: clear-box testing + white box testing: clear-box testing + black-box monitoring: synthetic monitoring + white-box monitoring: introspective monitoring + ignoreCase: true + wordBoundary: true +google/black-white-hat: + severity: warn + message: Use a precise term such as "%s" instead of "%s" (Google inclusive language). + link: https://developers.google.com/style/word-list#blackhat + scope: summary + fix: false + assertions: + swap: + pairs: + black hat: unethical + blackhat: unethical + white hat: ethical + whitehat: ethical + ignoreCase: true + wordBoundary: true +google/blacklist-whitelist: + severity: warn + message: Use "%s" instead of "%s" (Google inclusive language). + link: https://developers.google.com/style/word-list#blacklist + scope: summary + fix: false + assertions: + swap: + pairs: + blacklist: denylist + whitelist: allowlist + graylist: denylist + greylist: denylist + ignoreCase: true + wordBoundary: true +google/blind-figurative: + severity: warn + message: Use "ignore", "unaware of", "disregard", or "reject" instead of "%s" (Google). + link: https://developers.google.com/style/word-list#blind + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bblind to\b + - \bblind eye to\b + ignoreCase: true +google/brand-capitalization: + severity: warn + message: 'Use "%s" instead of "%s" (Google: fixed brand capitalization).' + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + Google Play Services: Google Play services + Google account: Google Account + Google accounts: Google Accounts + cURL: curl + wordBoundary: true +google/brand-capitalization-proper-noun: + severity: warn + message: 'Use "%s" instead of "%s" (Google: fixed brand capitalization).' + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + markdown: Markdown + material design: Material Design + search console: Search Console + wordBoundary: true +google/chapter-terminology: + severity: warn + message: Refer to "document", "page", or "section" instead of "%s" (Google, for web docs). + link: https://developers.google.com/style/word-list#chapter + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bchapters?\b + ignoreCase: true +google/colo-form: + severity: warn + message: Use "%s" instead of "%s" (Google compound-word form). + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + colo: colocate + ignoreCase: true + wordBoundary: true +google/comma-before-that: + severity: warn + message: Don't put a comma before restrictive "that" (Google). + link: https://developers.google.com/style/pronouns + scope: sentence + fix: false + assertions: + pattern: + tokens: + - ', that\b' +google/compound-forms: + severity: warn + message: Use "%s" instead of "%s" (Google compound-word form). + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + e-mail: email + E-mail: email + e-commerce: ecommerce + web page: webpage + check box: checkbox + code base: codebase + code lab: codelab + code-lab: codelab + data store: datastore + datacenter: data center + datatype: data type + file name: filename + filesystem: file system + front-end: frontend + front end: frontend + back-end: backend + back end: backend + host name: hostname + end point: endpoint + name space: namespace + nameserver: name server + life cycle: lifecycle + life-cycle: lifecycle + live stream: livestream + health care: healthcare + health-care: healthcare + on prem: on-premises + on premise: on-premises + on-premise: on-premises + read only: read-only + pre-built: prebuilt + run book: runbook + screen shot: screenshot + time stamp: timestamp + time frame: timeframe + time-to-live: time to live + tool kit: toolkit + tool-kit: toolkit + touch screen: touchscreen + user base: userbase + walk-through: walkthrough + webserver: web server + white paper: whitepaper + white space: whitespace + wild card: wildcard + statusbar: status bar + status-bar: status bar + key/value pair: key-value pair + key value pair: key-value pair + singlemost: single most + signin: sign-in + signout: sign-out + auto-healing: autohealing + auto-scaling: autoscaling + auto-populate: autopopulate + auto populate: autopopulate + auto-tagging: autotagging + pre-capture: precapture + pre-emptible: preemptible + preexisting: pre-existing + pre-recorded: prerecorded + preshared key: pre-shared key + pre-submit: presubmit + meta-feed: metafeed + meta-generation: metageneration + inter-cluster: intercluster + sub-tree: subtree + sub-zone: subzone + sub zone: subzone + subcommand: sub-command + co-locate: colocate + blue/green: blue-green + blue green: blue-green + parent—child: parent-child + long running operation: long-running operation + hard-code: hardcode + hard-coded: hardcoded + in-line: inline + Unixlike: Unix-like + Unix like: Unix-like + resource recordset: resource record set + ignoreCase: true + wordBoundary: true +google/compound-forms-proper-noun: + severity: warn + message: Use "%s" instead of "%s" (Google compound-word form). + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + datasource: data source + ignoreCase: true + wordBoundary: true +google/compound-forms-word-choice: + severity: warn + message: Use "%s" instead of "%s" (Google compound-word form). + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + data cleansing: data cleaning + transcompile: transpile + autoupdate: automatically update + pre-emptive: preemptible + noops: fully managed + NoOps: fully managed + ignoreCase: true + wordBoundary: true +google/conjunctive-adverb-comma: + severity: warn + message: Put a comma after "%s" when it opens a sentence (Google). + link: https://developers.google.com/style/commas + scope: sentence + fix: false + assertions: + pattern: + tokens: + - ^(?:Otherwise|However|Therefore) [a-z] +google/cons-and-pros: + severity: warn + message: Use "%s" instead of "%s" (Google). + link: https://developers.google.com/style/word-list#pros + scope: summary + fix: false + assertions: + swap: + pairs: + pros and cons: advantages and disadvantages + ignoreCase: true + wordBoundary: true +google/copy-and-paste: + severity: warn + message: 'Explain what to enter into a field, not how to enter it (Google): "%s"' + link: https://developers.google.com/style/word-list#Copy_and_paste + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bcopy and paste\b + ignoreCase: true +google/create-a-new: + severity: warn + message: Use "%s ..." instead of "%s ..." unless distinguishing from another recently created item (Google). + link: https://developers.google.com/style/word-list#Create_a_new + scope: summary + fix: false + assertions: + swap: + pairs: + Create a new: Create a + ignoreCase: true + wordBoundary: true +google/dash-style: + severity: warn + message: Don't use an en dash or a double hyphen in place of an em dash; don't space the em dash (Google). + link: https://developers.google.com/style/dashes + scope: summary + fix: false + assertions: + pattern: + tokens: + - – + - \s--\s + - \s—\s +google/data-rate-units: + severity: warn + message: 'Use "%s" instead of "%s" (Google: by convention we don''t use the slash form).' + link: https://developers.google.com/style/word-list#GBps + scope: summary + fix: false + assertions: + swap: + pairs: + KB/s: KBps + Kb/s: Kbps + MB/s: MBps + Mb/s: Mbps + GB/s: GBps + Gb/s: Gbps + wordBoundary: true +google/date-format: + severity: warn + message: Avoid all-numeric slash dates such as "%s" (Google); spell out the month. + link: https://developers.google.com/style/dates-times + scope: summary + fix: false + assertions: + pattern: + tokens: + - \b\d{1,2}/\d{1,2}/\d{2,4}\b +google/disability-language: + severity: warn + message: Use "%s" instead of "%s" (Google inclusive documentation). + link: https://developers.google.com/style/inclusive-documentation + scope: summary + fix: false + assertions: + swap: + pairs: + the disabled: people with disabilities + a quadriplegic: a quadriplegic person + wheelchair-bound: uses a wheelchair + suffering from: experiencing + victim of: living with + ignoreCase: true + wordBoundary: true +google/dummy-variable: + severity: warn + message: 'Use "%s" instead of "%s" (Google): for a statistics sense, see the word list for alternatives.' + link: https://developers.google.com/style/word-list#dummy-variable + scope: summary + fix: false + assertions: + swap: + pairs: + dummy variable: placeholder + ignoreCase: true + wordBoundary: true +google/emphasis-style: + severity: warn + message: Use underscores for emphasis/italics, not asterisks (Google). + link: https://developers.google.com/style/text-formatting + scope: all + fix: false + assertions: + emphasis-style: + style: underscore +google/first-line-h1: + severity: error + message: 'The first line in a file should be a top-level heading (Google: heading structure).' + link: https://developers.google.com/style/headings + scope: all + fix: false + assertions: + first-line-h1: {} +google/gcp-name: + severity: warn + message: Use "%s" instead of "%s" (Google product naming). + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + GCP: Google Cloud + wordBoundary: true +google/gendered-terms: + severity: warn + message: Use non-gendered language such as "%s" instead of "%s" (Google inclusive language). + link: https://developers.google.com/style/word-list#man_hours + scope: summary + fix: false + assertions: + swap: + pairs: + you guys: everyone + guys: everyone + male adapter: plug + female adapter: socket + man hours: person hours + manhours: person hours + manmade: artificial + man made: artificial + manned: staffed + manpower: staff + man-power: staff + man-in-the-middle: on-path attacker + he/she: they + webmaster: website owner + ignoreCase: true + wordBoundary: true +google/grandfathered: + severity: warn + message: Use "%s" instead of "%s" (Google inclusive language). + link: https://developers.google.com/style/word-list#grandfathered + scope: summary + fix: false + assertions: + swap: + pairs: + grandfathered: legacy + grandfather clause: exempt + ignoreCase: true + wordBoundary: true +google/grayed-out: + severity: warn + message: Use "%s" instead of "%s" (Google inclusive language). + link: https://developers.google.com/style/word-list#grayed-out + scope: summary + fix: false + assertions: + swap: + pairs: + grayed-out: unavailable + greyed-out: unavailable + ignoreCase: true + wordBoundary: true +google/heading-increment: + severity: error + message: 'Heading levels should only increment by one level at a time (Google: heading structure).' + link: https://developers.google.com/style/headings + scope: all + fix: false + assertions: + heading-increment: {} +google/heading-sentence-case: + severity: error + message: '"%s" should use %s capitalization (Google: sentence case for headings).' + link: https://developers.google.com/style/headings + scope: heading + fix: false + assertions: + capitalization: + match: $sentence +google/in-order-to: + severity: warn + message: Prefer "%s" over "%s" unless needed to clarify meaning (Google). + link: https://developers.google.com/style/word-list#in_order_to + scope: summary + fix: false + assertions: + swap: + pairs: + in order to: to + ignoreCase: true + wordBoundary: true +google/jargon-with-people-references: + severity: warn + message: Use a precise term such as "%s" instead of "%s" (Google jargon/inclusive language). + link: https://developers.google.com/style/word-list#ninja + scope: summary + fix: false + assertions: + swap: + pairs: + gypsy: Romani + ghetto: clumsy + ninja: expert + guru: expert + sherpa: guide + dojo: training + mom test: beginner user test + grandma test: beginner user test + girlfriend test: beginner user test + monkey test: automated, random tests + brown bag: learning session + brown-bag: learning session + build cop: build monitor + build sheriff: build monitor + war room: incident-management team + warroom: incident-management team + final solution: solution + demilitarized zone: perimeter network + DMZ: perimeter network + denigrate: disparage + sexy: elegant + nuke: remove + voodoo: mysterious + first-class citizen: higher-order value + first class citizen: higher-order value + ignoreCase: true + wordBoundary: true +google/keyboard-keys: + severity: warn + message: Spell out modifier keys (Control, Command) instead of "%s" (Google). + link: https://developers.google.com/style/ui-elements + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bctrl\b + - \bcmd\b + - ⌘ + ignoreCase: true +google/leverage: + severity: warn + message: 'Avoid "%s" if you mean "use", "build on", or "take advantage of" (Google): "%s"' + link: https://developers.google.com/style/word-list#leverage + scope: summary + fix: false + assertions: + swap: + pairs: + leverage: use + leveraging: using + leveraged: used + ignoreCase: true + wordBoundary: true +google/link-intro-about: + severity: warn + message: 'Use "%s" instead of "%s" (Google: use "about", not "on").' + link: https://developers.google.com/style/cross-references + scope: summary + fix: false + assertions: + swap: + pairs: + for more information on: for more information about + more details on: more details about + ignoreCase: true + wordBoundary: true +google/link-punctuation: + severity: warn + message: Don't put link text in quotation marks (Google). + link: https://developers.google.com/style/cross-references + scope: link + fix: false + assertions: + pattern: + tokens: + - ^["“].*["”]$ +google/list-item-capital: + severity: error + message: '"%s" should use %s capitalization (Google: start list items with a capital letter).' + link: https://developers.google.com/style/lists + scope: list-item + fix: false + assertions: + capitalization: + match: $sentence +google/list-length: + severity: warn + message: List has %s item(s); a single item usually reads better as a plain sentence (Google). + link: https://developers.google.com/style/lists + scope: all + fix: false + assertions: + list-length: + min: 2 +google/master-slave: + severity: warn + message: Avoid "%s"; use "worker" or "replica" instead (Google). + link: https://developers.google.com/style/word-list#slave + scope: summary + fix: false + assertions: + swap: + pairs: + slave: worker + ignoreCase: true + wordBoundary: true +google/neither-nor: + severity: warn + message: Write "neither A nor B", not "neither A or B" (Google). + link: https://developers.google.com/style/word-list#neither + scope: sentence + fix: false + assertions: + pattern: + tokens: + - \bneither\b(?:(?!\bnor\b)[\s\S])*?\bor\b +google/no-alt-text: + severity: error + message: 'Every image needs alt text (Google: accessibility).' + link: https://developers.google.com/style/accessibility + scope: all + fix: false + assertions: + no-alt-text: {} +google/no-ampersand: + severity: warn + message: Don't use "&" as a conjunction or shorthand for "and" (Google). + link: https://developers.google.com/style/text-formatting + scope: summary + fix: false + assertions: + pattern: + tokens: + - \s&\s +google/no-and-or: + severity: warn + message: Avoid "and/or" except where space is limited, such as in tables (Google). + link: https://developers.google.com/style/slashes + scope: summary + fix: false + assertions: + pattern: + tokens: + - \band/or\b + ignoreCase: true +google/no-casing-style-names: + severity: warn + message: Don't use a casing style name such as "%s"; describe the naming convention instead (Google). + link: https://developers.google.com/style/capitalization + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bcamel[\s-]?case\b + - \bsnake[\s-]?case\b + ignoreCase: true +google/no-click-on: + severity: warn + message: Use "%s" instead of "%s" (Google). + link: https://developers.google.com/style/word-list#click + scope: summary + fix: false + assertions: + swap: + pairs: + click on: click + ignoreCase: true + wordBoundary: true +google/no-code-in-heading: + severity: warn + message: Avoid code items in headings (Google); rephrase in plain words. + link: https://developers.google.com/style/headings + scope: heading + fix: false + assertions: + pattern: + tokens: + - '`[^`]+`' + includeCode: true +google/no-duplicate-heading: + severity: error + message: 'Headings should be unique so readers can jump between sections (Google: heading structure).' + link: https://developers.google.com/style/headings + scope: all + fix: false + assertions: + no-duplicate-heading: {} +google/no-emphasis-as-heading: + severity: error + message: 'Tag headings using heading elements, not bold/italic text (Google: accessibility).' + link: https://developers.google.com/style/accessibility + scope: all + fix: false + assertions: + no-emphasis-as-heading: {} +google/no-empty-headings: + severity: error + message: Don't use empty headings; make sure headings are followed by content (Google). + link: https://developers.google.com/style/headings + scope: all + fix: false + assertions: + no-empty-headings: {} +google/no-hover: + severity: warn + message: Use "hold the pointer over" instead of "%s" (Google). + link: https://developers.google.com/style/word-list#hover + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bhover(?:s|ing|ed)?\b + ignoreCase: true +google/no-inflected-code: + severity: warn + message: 'Don''t inflect the name of a code element (Google): "%s"' + link: https://developers.google.com/style/code-in-text + scope: summary + fix: false + assertions: + pattern: + tokens: + - '`[^`]+`''s\b' + - '`[^`]+`s\b' + includeCode: true +google/no-internet-slang: + severity: warn + message: Avoid the internet-slang abbreviation "%s"; use "%s" instead (Google). + link: https://developers.google.com/style/abbreviations + scope: summary + fix: false + assertions: + swap: + pairs: + tl;dr: To summarize + ymmv: Your results might vary + RTFM: For more information, see... + ignoreCase: true + wordBoundary: true +google/no-latinisms: + severity: warn + message: Use "%s" instead of "%s" (Google). + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + \bi\.e\.: that is + \be\.g\.: for example + ignoreCase: true + wordBoundary: false + keysAreRegex: true +google/no-latinisms-plain: + severity: warn + message: Use "%s" instead of "%s" (Google). + link: https://developers.google.com/style/word-list + scope: summary + fix: false + assertions: + swap: + pairs: + vice versa: the other way around + ignoreCase: true + wordBoundary: true +google/no-lets: + severity: warn + message: '"%s": avoid if at all possible (Google).' + link: https://developers.google.com/style/word-list#lets + scope: summary + fix: false + assertions: + pattern: + tokens: + - \blet's\b + ignoreCase: true +google/no-link-in-heading: + severity: error + message: Don't put links in headings (Google); move the link into the following paragraph. + link: https://developers.google.com/style/headings + scope: heading + fix: false + assertions: + pattern: + tokens: + - \[[^\]]*\]\([^)]*\) +google/no-merged-cells: + severity: error + message: Don't merge table cells with colspan/rowspan (Google). + link: https://developers.google.com/style/accessibility + scope: all + fix: false + assertions: + pattern: + tokens: + - \bcolspan\s*= + - \browspan\s*= + ignoreCase: true +google/no-numbered-headings: + severity: warn + message: Don't use numbers in headings to indicate a sequence (Google). + link: https://developers.google.com/style/headings + scope: heading + fix: false + assertions: + pattern: + tokens: + - ^\d+[.)]\s + - ^Step\s+\d+\b + - ^Part\s+\d+\b + ignoreCase: true +google/no-please: + severity: warn + message: '"%s": only use when asking for permission or forgiveness, not in the normal course of instructions (Google).' + link: https://developers.google.com/style/word-list#please + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bplease\b + ignoreCase: true +google/no-please-note: + severity: warn + message: '%sGoogle''s style guide says not to use the phrase "%s"; remove it.' + link: https://developers.google.com/style/word-list#please + scope: summary + fix: false + assertions: + swap: + pairs: + please note: '' + ignoreCase: true + wordBoundary: true +google/no-run-the-following-command: + severity: warn + message: Focus on what the command does instead of "%s" (Google). + link: https://developers.google.com/style/procedures + scope: summary + fix: false + assertions: + pattern: + tokens: + - \brun the following command\b + ignoreCase: true +google/no-slash-abbrev: + severity: warn + message: Use "%s" instead of the slash abbreviation "%s" (Google). + link: https://developers.google.com/style/slashes + scope: summary + fix: false + assertions: + swap: + pairs: + \bc/o(?![A-Za-z]): care of + \bw/(?![A-Za-z]): with + ignoreCase: true + wordBoundary: false + keysAreRegex: true +google/no-target-blank: + severity: warn + message: Don't force links to open in a new tab or window (Google). + link: https://developers.google.com/style/cross-references + scope: all + fix: false + assertions: + pattern: + tokens: + - target\s*=\s*["']_blank["'] +google/no-timeless-phrases: + severity: warn + message: '"%s" is implied by the existence of the documentation itself; consider removing it (Google).' + link: https://developers.google.com/style/timeless-documentation + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bas of this writing\b + - \bat present\b + - \bpresently\b + - \bdoes not yet\b + - \bcurrently\b + ignoreCase: true +google/no-toggle-verb: + severity: warn + message: 'Describe the action instead of using "toggle" as a verb (Google): "%s"' + link: https://developers.google.com/style/ui-elements + scope: summary + fix: false + assertions: + pattern: + tokens: + - \btoggle(?:d|s)?\s+(?:the|this|that|a|an)\b + - \bto toggle\b + ignoreCase: true +google/no-trailing-punctuation: + severity: error + message: Don't end headings with periods (Google). + link: https://developers.google.com/style/periods + scope: all + fix: false + assertions: + no-trailing-punctuation: {} +google/no-triple-contractions: + severity: warn + message: Don't use three-word contractions such as "%s" (Google). + link: https://developers.google.com/style/contractions + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bmightn't've\b + - \bwouldn't've\b + - \bcouldn't've\b + - \bshouldn't've\b + ignoreCase: true +google/no-uncheck: + severity: warn + message: Use "%s" instead of "%s" for checkboxes (Google). + link: https://developers.google.com/style/word-list#uncheck + scope: summary + fix: false + assertions: + swap: + pairs: + uncheck: clear + ignoreCase: true + wordBoundary: true +google/no-underline: + severity: warn + message: Reserve underlining for link text (Google). + link: https://developers.google.com/style/text-formatting + scope: all + fix: false + assertions: + pattern: + tokens: + - + ignoreCase: true +google/no-url-as-link-text: + severity: warn + message: Don't use a URL as link text (Google); use a descriptive phrase instead. + link: https://developers.google.com/style/cross-references + scope: link + fix: false + assertions: + pattern: + tokens: + - ^https?:// +google/no-via: + severity: warn + message: '"%s": avoid — Google''s word list says not to use it; rephrase (e.g. "using", "through").' + link: https://developers.google.com/style/word-list#via + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bvia\b + ignoreCase: true +google/number-format: + severity: warn + message: 'Number formatting: "%s" doesn''t match Google''s stated convention.' + link: https://developers.google.com/style/numbers + scope: summary + fix: false + assertions: + pattern: + tokens: + - \d\s% + - (?'] + +# ------------------------------------------------------------------------------ +# NEEDS HUMAN REVIEW — real guide content with no safe mechanical detection +# (NOT-ENFORCEABLE). Recheck cannot check these; review them yourself: +# ------------------------------------------------------------------------------ +# [ ] "Optional:" prefix on genuinely optional sections (requires knowing +# whether a section is actually optional) +# [ ] Text before a colon must be a complete sentence +# [ ] Oxford comma (missing-comma detection needs real clause parsing) +# [ ] Acronyms used as verbs ("ping the server") — needs POS tagging +# [ ] Spell out an abbreviation on first mention (needs unbounded +# first-mention tracking across the document) +# [ ] Avoid linking the same destination from different link text (the +# guide's own exceptions — different section, long page, multiple +# entry points — need judgment a token rule can't apply) +# [ ] External link icon (a rendered visual/CSS concern, not markdown text) +# [ ] Drop "..." when quoting a UI element name (risks over- and +# under-firing with a regex) +# [ ] Directional language (above/below/right-hand side) as a spatial UI +# reference vs. its equally common non-directional use +# [ ] "A link isn't a button" — requires knowing what a referenced UI +# element actually is +# [ ] Generic he/him/his/she/her — requires knowing whether a pronoun +# names a specific person or is used generically +# [ ] Active voice, metaphor avoidance, and the "what belongs in code +# font" table — all require holistic judgment about content/structure diff --git a/packages/recheck/examples/inclusive-language.yaml b/packages/recheck/examples/inclusive-language.yaml new file mode 100644 index 0000000000..de7acee48f --- /dev/null +++ b/packages/recheck/examples/inclusive-language.yaml @@ -0,0 +1,304 @@ +# ============================================================================== +# recheck/inclusive-language — composable inclusive/bias-free word list +# ============================================================================== +# What it is: the INTERSECTION of recheck/google's and recheck/microsoft's +# own confirmed inclusive/bias-free/ableist/accessibility content — +# terminology both flagship guides independently say to avoid. +# Guide-agnostic; carries no structural rules of its own. +# Sources: Google developer documentation style guide (CC BY 4.0, +# https://developers.google.com/style) and the Microsoft +# Writing Style Guide (CC BY 4.0 via its backing GitHub +# repository's LICENSE file: +# https://github.com/MicrosoftDocs/microsoft-style-guide/blob/main/LICENSE) +# Synced: 2026-07-30 (no new fetch — built from five existing +# verification reports; see PROVENANCE.md) +# Provenance: packages/recheck/presets/inclusive-language/PROVENANCE.md +# +# Generated file — do not hand-edit. Regenerate with `node scripts/generate-examples.mjs` +# after changing src/config/presets/inclusive-language.ts or this file's +# appendix (examples/appendices/inclusive-language.appendix.yaml). + +# ------------------------------------------------------------------------------ +# What to paste +# ------------------------------------------------------------------------------ +# Do NOT stack this onto recheck/google or recheck/microsoft: because every +# rule here is built as the intersection of both flagships' own confirmed +# content, stacking it onto either one double-reports most of its findings +# (measured: 7 of 11 duplicate a google/* finding when stacked onto +# recheck/google alone; 6 of 11 duplicate a microsoft/* finding onto +# recheck/microsoft alone — see PROVENANCE.md's "Duplicate-finding audit"). +# This preset earns its keep standalone, alongside recheck/prose, or on a +# project running neither flagship. + +extends: + - recheck/prose + - recheck/inclusive-language + +# ------------------------------------------------------------------------------ +# How to tune it +# ------------------------------------------------------------------------------ +# Your own rule keys always win over the preset's (`extends` resolves +# first, then your top-level keys are merged on top, per rule key). +# +# Turn a rule off entirely: +# +# inclusive-language/nuke: +# severity: off +# +# Every rule in this preset ships at `warn` — upgrade one to `error` if +# your team wants it to fail CI: +# +# inclusive-language/slave: +# severity: error +# +# Silence one occurrence instead of the whole rule, with an inline HTML +# comment directive — works on any rule, from any preset: +# +# +# Nuke the staging database before the demo. +# +# +# ...several occurrences here are all silenced... +# +# +# +# +# THE SHARP EDGE — read this before reaching for a per-term override. +# Merging happens per ASSERTION ID, not per option inside it. +# Setting: +# +# inclusive-language/blacklist-whitelist: +# assertions: +# swap: +# ignoreCase: false +# +# does not just flip `ignoreCase` — it REPLACES the whole `swap` assertion +# object, silently dropping the preset's 2-entry `pairs` map along with +# it, and the config then fails validation outright: +# +# Rule "inclusive-language/blacklist-whitelist": swap requires a "pairs" +# object mapping find -> replace strings +# +# (verified against this exact rule). +# So today, to reject one term out of a bundled swap/pattern rule, your +# options are: turn the whole rule off, restate its entire pairs/tokens +# yourself, or inline-disable each occurrence as shown above. A per-term +# opt-out for swap/pattern is a known follow-up, not a promise. +# +# This preset ships no `capitalization`/`spelling` rule of its own, so the +# per-term escape hatch those two assertion types offer doesn't apply +# here directly — but if you pair this preset with `recheck/prose` (the +# recommended combination above), that preset's own `recheck/capitalization` +# rule already has it. See examples/google.yaml or examples/microsoft.yaml +# for a worked, verified example of that escape hatch. + +# ------------------------------------------------------------------------------ +# Full expansion (reference) +# ------------------------------------------------------------------------------ +# This is what `extends: [recheck/inclusive-language]` resolves to today: all 11 rules, +# alphabetized by rule id, generated straight from the live preset (`node +# scripts/generate-examples.mjs`). You do not need to copy any of it — the +# extends block above already gives you all of this. Read it to see exactly +# what you are adopting, or as a starting point if you would rather pin or +# fork specific rules into your own config instead of extending the preset. +# +# `fix: false` on every rule below means what it says: detection-only, no +# exceptions — see "How to tune it" above and PROVENANCE.md for why. + +inclusive-language/blacklist-whitelist: + severity: warn + message: Use "%s" instead of "%s" (Google C§3.5 rows 3-4; Microsoft a-z/blacklist agrees but prefers the two-word "block list"/"allow list" forms — recheck/microsoft ships those). Verb forms need a rewrite, not a word-for-word swap. + link: https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/b/blacklist + scope: summary + fix: false + assertions: + swap: + pairs: + blacklist: denylist + whitelist: allowlist + ignoreCase: true + wordBoundary: true +inclusive-language/crippled: + severity: warn + message: Avoid "%s" (Google word-list#cripple D§3.6 row 27; Microsoft accessibility term collection Row 2); use "slowed down" for a figurative/system sense, or person-first language when referring to a person. + link: https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/term-collections/accessibility-terms + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bcripple\b + - \bcrippled\b + ignoreCase: true +inclusive-language/differently-abled: + severity: warn + message: Avoid the euphemism "%s" (Google inclusive-documentation D§3.6 row 44; Microsoft accessibility term collection Row 8); use specific, person-first language instead. + link: https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/term-collections/accessibility-terms + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bdifferently[- ]abled\b + ignoreCase: true +inclusive-language/dmz: + severity: warn + message: Avoid "%s"; use "perimeter network" instead (Google C§3.5 row 26; Microsoft a-z/demilitarized-zone-dmz — both guides recommend the identical replacement). + link: https://learn.microsoft.com/en-us/style-guide/bias-free-communication + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bDMZ\b(?!\s+(?:dividing|between|separating)\b) + - \bdemilitarized zone\b(?!\s+(?:dividing|between|separating)\b) +inclusive-language/grayed-out: + severity: warn + message: Use "%s" instead of "%s" (Google C§3.5 row 20; Microsoft a-z/gray-grayed-out agrees the term itself should be avoided when describing an unusable UI state, preferring "not available"/"isn't available"). + link: https://developers.google.com/style/word-list#grayed-out + scope: summary + fix: false + assertions: + swap: + pairs: + grayed-out: unavailable + greyed-out: unavailable + ignoreCase: true + wordBoundary: true +inclusive-language/he-she: + severity: warn + message: Use "%s" instead of "%s" (Google pronouns page; Microsoft bias-free-communication V23). + link: https://learn.microsoft.com/en-us/style-guide/bias-free-communication + scope: summary + fix: false + assertions: + swap: + pairs: + he/she: they + s/he: they + ignoreCase: true + wordBoundary: true +inclusive-language/master-slave-pairing: + severity: warn + message: Avoid the "%s" pairing (Google C§3.5 row 1; Microsoft a-z/master-slave); both guides agree to avoid it but recommend different replacements — e.g. "primary/replica" or "primary/subordinate". + link: https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/m/master-slave + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bmaster\s*/\s*slave\b + - \bmaster-slave\b + ignoreCase: true +inclusive-language/nondisabled-person: + severity: warn + message: Use "%s" instead of "%s" (Google inclusive-documentation D§3.6 row 45; Microsoft accessibility term collection Row 5). + link: https://developers.google.com/style/inclusive-documentation + scope: summary + fix: false + assertions: + swap: + pairs: + normal person: person without a disability + healthy person: person without a disability + ignoreCase: true + wordBoundary: true +inclusive-language/nuke: + severity: warn + message: Avoid the violent-metaphor jargon "%s"; use "%s" or "attack" instead (Google word-list#nuke C§3.5; Microsoft militaristic-language "Never use" list agrees). + link: https://learn.microsoft.com/en-us/style-guide/militaristic-language + scope: summary + fix: false + assertions: + swap: + pairs: + nuke: remove + ignoreCase: true + wordBoundary: true +inclusive-language/slave: + severity: warn + message: Avoid "%s"; use "%s" or "replica" instead (Google C§3.5 row 2; Microsoft a-z/master-slave agrees the pairing itself must be avoided). + link: https://developers.google.com/style/word-list#slave + scope: summary + fix: false + assertions: + swap: + pairs: + slave: worker + ignoreCase: true + wordBoundary: true +inclusive-language/suffering-victim: + severity: warn + message: Use "%s" instead of "%s" (Google inclusive-documentation D§3.6 row 43; Microsoft accessibility term collection Row 4 agrees this phrase should be avoided, though its own replacement is sometimes a fuller sentence rewrite). + link: https://developers.google.com/style/inclusive-documentation + scope: summary + fix: false + assertions: + swap: + pairs: + suffering from: experiencing + victim of: living with + ignoreCase: true + wordBoundary: true + +# ============================================================================== +# Appendix — candidates NOT shipped by recheck/inclusive-language (hand-maintained) +# ============================================================================== +# This file is appended verbatim to the generated example above by +# `node scripts/generate-examples.mjs`. Editing it +# makes examples/inclusive-language.yaml stale until you regenerate — the +# drift test's failure message says so, so this doesn't read as a generator bug. +# +# Full detail, every candidate, and why: +# packages/recheck/presets/inclusive-language/PROVENANCE.md +# +# This preset ships only terms CONFIRMED independently by both +# recheck/google and recheck/microsoft (the intersection). Most exclusions +# here are single-guide coverage, not false-positive risk — listed in the +# "needs a second source" checklist below rather than as commented rule +# blocks, since most were never actually rule-shaped candidates. +# +# ------------------------------------------------------------------------------ +# NOISY — real content on BOTH guides' broader word lists, still excluded +# from an inclusive-language framing because of an unrelated-sense collision. +# ------------------------------------------------------------------------------ + +# NOISY: "hang"/"hung" and "healthy" (of a system) are general word-choice +# content on both sides, not sourced from either guide's bias-free/ +# accessibility pages — and both collide constantly with unrelated correct +# senses ("hung the picture", "hung jury", "a healthy amount of caution"). +# inclusive-language/hang-healthy: +# severity: warn +# message: 'Avoid "%s" as a system-state metaphor; consider a precise term.' +# assertions: +# pattern: +# ignoreCase: true +# tokens: ['\bhang(s|ing)?\b', '\bhealthy\b'] + +# NOISY: "disable"/"disabled" (of a broken system) has a narrow ableist- +# metaphor sense on both guides, but the bare word is core, ubiquitous, +# entirely correct API-documentation vocabulary ("disable this feature", +# "the API is disabled") — a bare-word rule would misfire on nearly every +# document describing a feature flag. +# inclusive-language/disable-disabled: +# severity: warn +# message: 'Avoid "%s" as a system-state metaphor; consider "turn off"/"deactivate".' +# assertions: +# pattern: +# ignoreCase: true +# tokens: ['\bdisabled?\b'] + +# ------------------------------------------------------------------------------ +# NEEDS A SECOND SOURCE — real content on exactly ONE guide's side. Shipping +# these here would make this preset silently guide-specific, defeating the +# point of an intersection. Full term lists are in PROVENANCE.md; a sample: +# ------------------------------------------------------------------------------ +# [ ] Google-only: blackhat/whitehat, black-box/white-box testing, white +# label, grandfathered, ninja, guru, man hours, male/female adapter, +# man-in-the-middle, the disabled, a quadriplegic, wheelchair-bound +# [ ] Microsoft-only: chairman/chairwoman, mankind, manpower, salesman, +# screened subnet, dumb, mute, non-verbal, an epileptic, Asperger's, +# lame (already excluded on Microsoft's own preset for the same reason) +# [ ] Either term needing a live re-check against the OTHER guide before +# it could ever be added here — this is the only path to growing this +# preset beyond its current 11 rules diff --git a/packages/recheck/examples/microsoft.yaml b/packages/recheck/examples/microsoft.yaml new file mode 100644 index 0000000000..0b9b641e64 --- /dev/null +++ b/packages/recheck/examples/microsoft.yaml @@ -0,0 +1,1542 @@ +# ============================================================================== +# recheck/microsoft — Microsoft Writing Style Guide, as a config +# ============================================================================== +# Source: Microsoft Writing Style Guide +# https://learn.microsoft.com/en-us/style-guide/welcome/ +# License: CC BY 4.0 — granted by the guide's backing GitHub repository, +# NOT by any learn.microsoft.com page (none states a licence): +# https://github.com/MicrosoftDocs/microsoft-style-guide/blob/main/LICENSE +# Synced: 2026-07-29 +# Provenance: packages/recheck/presets/microsoft/PROVENANCE.md +# +# Rules are adapted to Recheck's own assertion vocabulary (swap, pattern, +# capitalization, length, occurrence); wording is paraphrased in each rule's +# `message`, never quoted verbatim from the guide. +# +# Generated file — do not hand-edit. Regenerate with `node scripts/generate-examples.mjs` +# after changing src/config/presets/microsoft.ts or this file's appendix +# (examples/appendices/microsoft.appendix.yaml). + +# ------------------------------------------------------------------------------ +# What to paste +# ------------------------------------------------------------------------------ +# This is the entire adoption cost: two lines. `recheck/markdown` is the +# 53-rule markdownlint-parity structural set; `recheck/microsoft` layers the +# Writing Style Guide's own opinions on top — it ships its own +# heading/list/table/alt-text structural rules too, so the two are +# complementary, not duplicates. + +extends: + - recheck/markdown + - recheck/microsoft + +# ------------------------------------------------------------------------------ +# How to tune it +# ------------------------------------------------------------------------------ +# Your own rule keys always win over the preset's (`extends` resolves +# first, then your top-level keys are merged on top, per rule key). +# +# Turn a rule off entirely: +# +# microsoft/az-navigation: +# severity: off +# +# Downgrade an error to a warning: +# +# microsoft/heading-sentence-case: +# severity: warn +# +# Silence one occurrence instead of the whole rule, with an inline HTML +# comment directive — works on any rule, from any preset: +# +# +# Click the hot link to continue. +# +# +# ...several occurrences here are all silenced... +# +# +# +# +# THE SHARP EDGE — read this before reaching for a per-term override. +# Merging happens per ASSERTION ID, not per option inside it. +# Setting: +# +# microsoft/spelling-hyphenation: +# assertions: +# swap: +# ignoreCase: false +# +# does not just flip `ignoreCase` — it REPLACES the whole `swap` assertion +# object, silently dropping the preset's 25-entry `pairs` map along with +# it, and the config then fails validation outright: +# +# Rule "microsoft/spelling-hyphenation": swap requires a "pairs" object +# mapping find -> replace strings +# +# (verified against this exact rule). +# So today, to reject one term out of a bundled swap/pattern rule, your +# options are: turn the whole rule off, restate its entire pairs/tokens +# yourself, or inline-disable each occurrence as shown above. A per-term +# opt-out for swap/pattern is a known follow-up, not a promise. +# +# Two assertion types DO already have a per-term escape hatch: +# `capitalization`'s `exceptions` (an array of allowed terms that COMPOSES +# with the built-in technical-proper-noun vocabulary and anything else you +# add — it does not replace either) and `spelling`'s `ignore` (spelling +# itself isn't shipped by any of these four presets — see the README's +# "Opt-in prose assertions"). +# For example, to stop this preset's own sentence-case rule from flagging +# your product's name: +# +# microsoft/heading-sentence-case: +# assertions: +# capitalization: +# match: $sentence +# exceptions: [Your Product Name] +# +# Verified: this reports zero findings on a heading using "Your Product +# Name" while every other sentence-case violation still fires normally. + +# ------------------------------------------------------------------------------ +# Full expansion (reference) +# ------------------------------------------------------------------------------ +# This is what `extends: [recheck/microsoft]` resolves to today: all 93 rules, +# alphabetized by rule id, generated straight from the live preset (`node +# scripts/generate-examples.mjs`). You do not need to copy any of it — the +# extends block above already gives you all of this. Read it to see exactly +# what you are adopting, or as a starting point if you would rather pin or +# fork specific rules into your own config instead of extending the preset. +# +# `fix: false` on every rule below means what it says: detection-only, no +# exceptions — see "How to tune it" above and PROVENANCE.md for why. + +microsoft/accessibility-terms: + severity: error + message: Use people-first language instead of "%s" — see the accessibility term collection (Microsoft). + link: https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/term-collections/accessibility-terms + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bcrippled\b + - \bhandicapped\b + - \bthe handicapped\b + - \bpeople with handicaps\b + - \bslow learner\b + - \bmentally handicapped\b + - \bdifferently abled\b + - \bspecial needs\b + - \baffected by\b + - \bstricken with\b + - \bsuffers from\b + - \ba victim of\b + - \bsight-impaired\b + - \bvision-impaired\b + - \bhearing-impaired\b + - \bnon-verbal\b + - \bmaimed\b + - \bmissing a limb\b + - \bbirth defect\b + - \bSpecial Ed person\b + - \bnormal person\b + - \bhealthy person\b + - \bAsperger['’]s\b + - \bdumb\b + - \b(?:is|was|are|were|being|been)\s+mute\b + - \bdeaf and mute\b + - \bdeaf-mute\b + - \blame\b + - \bstupid\b + - \ban epileptic\b(?!\s+(?:seizure|episode|fit|attack|event)) + ignoreCase: true +microsoft/actionable: + severity: warn + message: Avoid "%s"; rewrite using "that you can act on" (Microsoft). + link: https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/a/actionable + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bactionable\b + ignoreCase: true +microsoft/alt-text-format: + severity: warn + message: Alt text should start with a capital letter and end with a period (Microsoft). + link: https://learn.microsoft.com/en-us/style-guide/accessibility/alternative-text + scope: alt + fix: false + assertions: + capitalization: + match: ^[A-Z].*\.$ +microsoft/alt-text-generic-opener: + severity: warn + message: Don't start alt text with a generic word such as "%s" (Microsoft). + link: https://learn.microsoft.com/en-us/style-guide/accessibility/alternative-text + scope: alt + fix: false + assertions: + pattern: + tokens: + - ^(?:Image|Icon|Graphic|Button|Link)\b + ignoreCase: true +microsoft/alt-text-length: + severity: warn + message: Alt text is %s %s long; Microsoft limits it to 150 characters (max %s). + link: https://learn.microsoft.com/en-us/style-guide/accessibility/alternative-text + scope: alt + fix: false + assertions: + length: + unit: characters + max: 150 +microsoft/alt-text-no-filename: + severity: warn + message: Don't use an image's file name as its alt text (Microsoft). + link: https://learn.microsoft.com/en-us/style-guide/accessibility/alternative-text + scope: alt + fix: false + assertions: + pattern: + tokens: + - \.(?:png|jpe?g|gif|svg|webp)$ + ignoreCase: true +microsoft/article-before-acronym: + severity: warn + message: 'Use "%s" instead of "%s" (Microsoft: article choice follows pronunciation).' + link: https://learn.microsoft.com/en-us/style-guide/acronyms + scope: summary + fix: false + assertions: + swap: + pairs: + an URL: a URL + a ISP: an ISP + an SQL database: a SQL database + an SQL: a SQL + ignoreCase: true + wordBoundary: true +microsoft/avoid-please: + severity: warn + message: Avoid "%s" except when asking the customer to do something inconvenient (Microsoft). + link: https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/p/please + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bplease\b + ignoreCase: true +microsoft/az-abbreviations-names: + severity: error + message: 'Microsoft style: use "%s" instead of "%s".' + link: https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/h/hexadecimal + scope: summary + fix: false + assertions: + swap: + pairs: + defrag: defragment + okay: OK + ignoreCase: true + wordBoundary: true +microsoft/az-abbreviations-names-detect: + severity: error + message: 'Microsoft style: use "number sign" instead of "%s" — except for the literal phone-keypad key (Microsoft).' + link: https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/n/number-sign + scope: summary + fix: false + assertions: + pattern: + tokens: + - \bpound sign\b + ignoreCase: true +microsoft/az-abbreviations-substitutions: + severity: warn + message: 'Microsoft style: use "%s" instead of "%s".' + link: https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/h/hexadecimal + scope: summary + fix: false + assertions: + swap: + pairs: + hex: hexadecimal + (? "to", "utilize" -> "use"), only 3 duplicate findings +# remain against either recheck/google or recheck/microsoft — an accepted +# paragraph-length overlap and a coincidental substring collision with +# use-contractions, not content duplication (see PROVENANCE.md's +# "Duplicate-finding audit"). Combine with recheck/markdown for full +# structural linting too. + +extends: + - recheck/markdown + - recheck/plain-language + +# ------------------------------------------------------------------------------ +# How to tune it +# ------------------------------------------------------------------------------ +# Your own rule keys always win over the preset's (`extends` resolves +# first, then your top-level keys are merged on top, per rule key). +# +# Turn a rule off entirely: +# +# plain-language/excess-intensifiers: +# severity: off +# +# Downgrade an error to a warning: +# +# plain-language/paragraph-max-words: +# severity: warn +# +# Silence one occurrence instead of the whole rule, with an inline HTML +# comment directive — works on any rule, from any preset: +# +# +# This is absolutely, completely the right approach. +# +# +# ...several occurrences here are all silenced... +# +# +# +# +# THE SHARP EDGE — read this before reaching for a per-term override. +# Merging happens per ASSERTION ID, not per option inside it. +# Setting: +# +# plain-language/complex-words: +# assertions: +# swap: +# ignoreCase: false +# +# does not just flip `ignoreCase` — it REPLACES the whole `swap` assertion +# object, silently dropping the preset's 8-entry `pairs` map along with +# it, and the config then fails validation outright: +# +# Rule "plain-language/complex-words": swap requires a "pairs" object +# mapping find -> replace strings +# +# (verified against this exact rule). +# So today, to reject one term out of a bundled swap/pattern rule, your +# options are: turn the whole rule off, restate its entire pairs/tokens +# yourself, or inline-disable each occurrence as shown above. A per-term +# opt-out for swap/pattern is a known follow-up, not a promise. +# +# This preset ships no `capitalization`/`spelling` rule of its own, so the +# per-term escape hatch those two assertion types offer doesn't apply here +# directly — but if you pair this preset with `recheck/prose`, that +# preset's own `recheck/capitalization` rule already has it. See +# examples/google.yaml or examples/microsoft.yaml for a worked, verified +# example of that escape hatch. + +# ------------------------------------------------------------------------------ +# Full expansion (reference) +# ------------------------------------------------------------------------------ +# This is what `extends: [recheck/plain-language]` resolves to today: all 10 rules, +# alphabetized by rule id, generated straight from the live preset (`node +# scripts/generate-examples.mjs`). You do not need to copy any of it — the +# extends block above already gives you all of this. Read it to see exactly +# what you are adopting, or as a starting point if you would rather pin or +# fork specific rules into your own config instead of extending the preset. +# +# `fix: false` on every rule below means what it says: detection-only, no +# exceptions — see "How to tune it" above and PROVENANCE.md for why. + +plain-language/an-amount-of: + severity: warn + message: Avoid the padded phrase "%s"; state the amount directly (federal plain-language guidance). + link: https://digital.gov/guides/plain-language/writing/style + scope: summary + fix: false + assertions: + pattern: + tokens: + - \ban amount of\b + ignoreCase: true +plain-language/complex-words: + severity: warn + message: Use "%s" instead of "%s" (federal plain-language guidance). + link: https://digital.gov/guides/writing-understanding/familiar-terms + scope: summary + fix: false + assertions: + swap: + pairs: + addressee: you + assist: help + assistance: help + commence: begin + in order that: for + in the amount of: for + in the event of: if + promulgate: issue + ignoreCase: true + wordBoundary: true +plain-language/double-negative-patterns: + severity: warn + message: Avoid the double-negative construction in "%s"; state it positively (federal plain-language guidance). + link: https://digital.gov/guides/plain-language/writing/style + scope: sentence + fix: false + assertions: + pattern: + tokens: + - \bmay not\b(?:(?!\buntil\b)[\s\S])*?\buntil\b + - \bis not\b(?:(?!\bunless\b)[\s\S])*?\bunless\b + ignoreCase: true +plain-language/double-negative-phrases: + severity: warn + message: Use "%s" instead of the double negative "%s" (federal plain-language guidance). + link: https://digital.gov/guides/plain-language/writing/style + scope: summary + fix: false + assertions: + swap: + pairs: + has not yet attained: is under + no fewer than: at least + ignoreCase: true + wordBoundary: true +plain-language/excess-intensifiers: + severity: warn + message: Consider cutting the intensifier "%s" (federal plain-language guidance). + link: https://digital.gov/guides/plain-language/principles/short-simple + scope: summary + fix: false + assertions: + pattern: + tokens: + - \babsolutely\b + - \bactually\b + - \bcompletely\b + - \breally\b + - \bquite\b + - \btotally\b + - \bvery\b + ignoreCase: true +plain-language/filler-phrases: + severity: warn + message: Prefer "%s" over the padded phrase "%s" (federal plain-language guidance). + link: https://digital.gov/guides/plain-language/writing/style + scope: summary + fix: false + assertions: + swap: + pairs: + a number of: several, a few, or many + a sufficient number of: enough + at this point in time: now + is able to: can + on a monthly basis: monthly + on the ground that: because + ignoreCase: true + wordBoundary: true +plain-language/jargon-terms: + severity: warn + message: Use "%s" instead of the jargon term "%s" (federal plain-language guidance). + link: https://digital.gov/guides/plain-language/principles/avoid-jargon + scope: summary + fix: false + assertions: + swap: + pairs: + Riverine avifauna: River birds + Involuntarily undomiciled: Unhoused + ignoreCase: true + wordBoundary: true +plain-language/paragraph-max-words: + severity: error + message: Paragraph is %s %s long; federal plain-language guidance says paragraphs should never exceed 250 words (max %s). + link: https://digital.gov/guides/plain-language/writing/clear-short + scope: paragraph + fix: false + assertions: + length: + unit: words + max: 250 +plain-language/paragraph-sentence-count: + severity: warn + message: Paragraph is %s %s long; federal plain-language guidance recommends at most 8 (roughly 150 words) (max %s). + link: https://digital.gov/guides/plain-language/writing/clear-short + scope: paragraph + fix: false + assertions: + length: + unit: sentences + max: 8 +plain-language/redundant-pairs: + severity: warn + message: Use "%s" instead of the redundant pair "%s" (federal plain-language guidance). + link: https://digital.gov/guides/plain-language/principles/short-simple + scope: summary + fix: false + assertions: + swap: + pairs: + due and payable: due + cease and desist: stop + knowledge and information: knowledge or information + ignoreCase: true + wordBoundary: true + +# ============================================================================== +# Appendix — candidates NOT shipped by recheck/plain-language (hand-maintained) +# ============================================================================== +# This file is appended verbatim to the generated example above by +# `node scripts/generate-examples.mjs`. Editing it +# makes examples/plain-language.yaml stale until you regenerate — the drift +# test's failure message says so, so this doesn't read as a generator bug. +# +# Full detail, every candidate, and why: +# packages/recheck/presets/plain-language/PROVENANCE.md +# +# ------------------------------------------------------------------------------ +# NOISY — live, verbatim guide content, judged too broad or too domain-risky +# to enforce mechanically. Shown here as the rule it WOULD be if shipped, +# commented out, so you can see the shape being avoided. +# ------------------------------------------------------------------------------ + +# NOISY: live on the guide's own table, but this 1:1 substitution misreads +# ordinary, correct accountability language ("the team is responsible for +# the migration") as wordiness — no textual signal separates that from padding. +# plain-language/be-responsible-for: +# severity: warn +# message: 'Consider "%s" instead of "%s" (plain language).' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { be responsible for: must } + +# NOISY, HIGH-RISK FOR THIS DOMAIN: "implement" is live guide content, but in +# software/API documentation "implement an interface", "implement this +# endpoint", "implement the spec" is ordinary, correct technical vocabulary, +# not wordiness. Flagging it would misfire across nearly every technical +# document Recheck targets. +# plain-language/implement: +# severity: warn +# message: 'Consider "%s" instead of "%s" (plain language).' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { implement: carry out } + +# NOISY: this row is itself a garbled pairing on the live page, and +# separately "command" has a strong, unrelated, extremely common technical +# sense (CLI command, terminal command) — core developer-docs vocabulary. +# plain-language/this-activity-command: +# severity: warn +# message: 'Consider "%s" instead of "%s" (plain language).' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { this activity, command: us, we } + +# NOT SHIPPED, ANY DIRECTION, EVER: "shall" is live guide content ("use +# 'must' not 'shall'"), but SHALL is a defined RFC 2119 normative keyword +# (alongside MUST/SHOULD/MAY/MUST NOT) used throughout specifications and +# API/interface documentation — exactly Recheck's target content. This is +# the single highest-risk exclusion in this preset: a rule flagging it would +# be actively wrong for that whole document class, not an edge case to +# carve out. +# plain-language/shall-must: +# severity: warn +# message: 'Consider "%s" instead of "%s" (plain language) — never for RFC 2119 usage.' +# assertions: +# swap: +# ignoreCase: true +# wordBoundary: true +# pairs: { shall: must } + +# ------------------------------------------------------------------------------ +# NEEDS HUMAN REVIEW — real guide content with no safe mechanical detection +# (NOT-ENFORCEABLE). Recheck cannot check these; review them yourself: +# ------------------------------------------------------------------------------ +# [ ] Nominalizations ("hidden verbs") — the guide's own suffix pattern +# (-ment/-tion/-sion/-ance + a link verb) false-positives constantly on +# ordinary correct nouns ("chance", "information", "assurance") +# [ ] Sentence-length — no word count is stated anywhere on the live site, +# only "write short sentences" +# [ ] Grade-level/readability score — no number is stated anywhere on the +# live site (see PROVENANCE.md "THE NUMBER"); `metric` stays a +# documented opt-in, not something this preset can source from the guide +# [ ] Abbreviation count per document ("no more than three, preferably +# two") — counts DISTINCT abbreviation TYPES, which neither `occurrence` +# nor `length` can express; an engine gap, not a judgment call +# [ ] Paraphrase-testing sample size / testing iteration count — user- +# testing methodology, not document content a linter can ever evaluate diff --git a/packages/recheck/examples/technical-english.yaml b/packages/recheck/examples/technical-english.yaml new file mode 100644 index 0000000000..8673bd0027 --- /dev/null +++ b/packages/recheck/examples/technical-english.yaml @@ -0,0 +1,134 @@ +# ============================================================================== +# recheck/technical-english — ASD-STE100-inspired writing principles, as a config +# ============================================================================== +# Source: Publicly documented principles of ASD-STE100 Simplified +# Technical English. https://www.asd-ste100.org +# Notice: ASD-STE100 Simplified Technical English is a Copyright and +# a Trade Mark of ASD, Brussels, Belgium. This preset is an +# independent work; ASD and the STEMG do not review, approve, +# certify, or endorse it. It reproduces no part of the +# standard. +# Provenance: packages/recheck/presets/technical-english/PROVENANCE.md +# +# Generated file — do not hand-edit. Regenerate with `node scripts/generate-examples.mjs` +# after changing src/config/presets/technical-english.ts or this file's +# appendix (examples/appendices/technical-english.appendix.yaml). + +# ------------------------------------------------------------------------------ +# What to paste +# ------------------------------------------------------------------------------ +# Three rules: sentence length (max 25 words; the descriptive-text bound), +# paragraph length (max 6 sentences), and a passive-voice heuristic at +# info. For word-choice checking, compose with recheck/plain-language; +# the STE approved-word dictionary is deliberately NOT encoded (see the +# Notice above and PROVENANCE.md). + +extends: + - recheck/technical-english + +# ------------------------------------------------------------------------------ +# How to tune it +# ------------------------------------------------------------------------------ +# Your own rule keys always win over the preset's (`extends` resolves +# first, then your top-level keys are merged on top, per rule key). +# +# Tighten the sentence bound for procedure-only content (the standard +# recommends 20 words in procedures, 25 in descriptive text): +# +# technical-english/sentence-length: +# severity: warn +# scope: sentence +# assertions: +# length: +# unit: words +# max: 20 +# +# The passive-voice heuristic ships at `info`; turn it off if it reads +# as noise for your corpus: +# +# technical-english/passive-voice: +# severity: off +# +# Silence one occurrence instead of the whole rule, with an inline HTML +# comment directive — works on any rule, from any preset: +# +# + +# ------------------------------------------------------------------------------ +# Full expansion (reference) +# ------------------------------------------------------------------------------ +# This is what `extends: [recheck/technical-english]` resolves to today: all 3 rules, +# alphabetized by rule id, generated straight from the live preset (`node +# scripts/generate-examples.mjs`). You do not need to copy any of it — the +# extends block above already gives you all of this. Read it to see exactly +# what you are adopting, or as a starting point if you would rather pin or +# fork specific rules into your own config instead of extending the preset. +# +# `fix: false` on every rule below means what it says: detection-only, no +# exceptions — see "How to tune it" above and PROVENANCE.md for why. + +technical-english/paragraph-length: + severity: warn + message: Paragraph is %s %s long; ASD-STE100 recommends at most 6 sentences per paragraph (max %s). + link: https://www.asd-ste100.org + scope: paragraph + fix: false + assertions: + length: + unit: sentences + max: 6 +technical-english/passive-voice: + severity: info + message: Prefer the active voice; ASD-STE100 recommends it ("%s"). + link: https://www.asd-ste100.org + scope: sentence + fix: false + assertions: + pattern: + ignoreCase: true + tokens: + - \b(?:is|are|was|were|be|been|being)\s+(?:\w+ed|begun|broken|brought|built|chosen|done|drawn|driven|found|given|held|hidden|kept|known|left|lost|made|meant|paid|put|read|said|seen|sent|set|shown|taken|told|thrown|understood|written)\b +technical-english/sentence-length: + severity: warn + message: Sentence is %s %s long; ASD-STE100 recommends at most 20 words in procedures and 25 in descriptive text (max %s). + link: https://www.asd-ste100.org + scope: sentence + fix: false + assertions: + length: + unit: words + max: 25 + +# ============================================================================== +# Appendix — candidates NOT shipped by recheck/technical-english (hand-maintained) +# ============================================================================== +# This file is appended verbatim to the generated example above by +# `node scripts/generate-examples.mjs`. +# +# Full detail and reasons: packages/recheck/presets/technical-english/PROVENANCE.md +# +# ------------------------------------------------------------------------------ +# NOT SHIPPED — the STE approved-word dictionary +# ------------------------------------------------------------------------------ +# The dictionary is part of the copyrighted standard; encoding it would +# reproduce the standard in part. For general word-choice checking, compose +# this preset with recheck/plain-language. +# +# ------------------------------------------------------------------------------ +# NOT SHIPPED — noun clusters, present tense, one instruction per sentence +# ------------------------------------------------------------------------------ +# Noun clusters need part-of-speech tagging; a tense rule fights legitimate +# `will` in changelogs and roadmaps; one-instruction-per-sentence is not +# reliably detectable (sentence length is the proxy). +# +# ------------------------------------------------------------------------------ +# TIGHTEN for procedure-only content +# ------------------------------------------------------------------------------ +# The shipped default is the descriptive-text bound (25 words). A project +# whose corpus is procedures overrides to the procedural bound: +# +# technical-english/sentence-length: +# assertions: +# length: +# unit: words +# max: 20 diff --git a/packages/recheck/package.json b/packages/recheck/package.json new file mode 100644 index 0000000000..23d69e3841 --- /dev/null +++ b/packages/recheck/package.json @@ -0,0 +1,82 @@ +{ + "name": "@redocly/recheck", + "version": "2.51.0", + "description": "Markdown and prose linting engine for Redocly CLI", + "type": "module", + "types": "lib/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js" + }, + "./presets": { + "types": "./lib/presets.d.ts", + "import": "./lib/presets.js" + } + }, + "engines": { + "node": ">=22.12.0 || >=20.19.0 <21.0.0", + "npm": ">=10" + }, + "engineStrict": true, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Redocly/redocly-cli.git" + }, + "homepage": "https://github.com/Redocly/redocly-cli", + "keywords": [ + "Markdown", + "Markdoc", + "lint", + "prose", + "documentation" + ], + "contributors": [ + "Adam Altman (https://redocly.com/)" + ], + "dependencies": { + "@redocly/ajv": "^8.18.3", + "@redocly/openapi-core": "2.57.0", + "ajv-formats": "^3.0.1", + "js-yaml": "^4.3.1", + "micromark": "^4.0.2", + "micromark-extension-directive": "^4.0.0", + "micromark-extension-frontmatter": "^2.0.0", + "micromark-extension-gfm-autolink-literal": "^2.1.0", + "micromark-extension-gfm-footnote": "^2.1.0", + "micromark-extension-gfm-table": "^2.1.1", + "micromark-extension-math": "^3.1.0", + "micromark-util-symbol": "^2.0.0", + "picomatch": "^4.0.4", + "string-width": "^8.2.1" + }, + "peerDependencies": { + "dictionary-en": "^4.0.0", + "nspell": "^2.1.5" + }, + "peerDependenciesMeta": { + "dictionary-en": { + "optional": true + }, + "nspell": { + "optional": true + } + }, + "devDependencies": { + "@markdoc/markdoc": "^0.5.2", + "@types/js-yaml": "^4.0.9", + "@types/picomatch": "^2.3.4", + "dictionary-en": "^4.0.0", + "micromark-util-types": "^2.0.2", + "nspell": "^2.1.5", + "typescript": "6.0.2" + }, + "files": [ + "lib", + "presets", + "examples", + "skills" + ] +} diff --git a/packages/recheck/presets/google/PROVENANCE.md b/packages/recheck/presets/google/PROVENANCE.md new file mode 100644 index 0000000000..7c0fd252bd --- /dev/null +++ b/packages/recheck/presets/google/PROVENANCE.md @@ -0,0 +1,1018 @@ +# Provenance: `recheck/google` + +Source: [Google developer documentation style guide](https://developers.google.com/style) +(canonical URL: `https://developers.google.com/style`). License: +[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Sync date: **2026-07-29**. + +Modification note: rules are adapted to Recheck's assertion vocabulary +(`swap`, `pattern`, `capitalization`, `length`, plus a handful of +markdownlint-parity/Recheck-original token rules); wording is paraphrased +into each rule's `message`, not quoted verbatim from the guide. Every rule +carries a `link:` to its source page. + +## How this table was produced + +Five independent verification passes fetched the live guide directly +(`curl`, not a summarizing fetch) and confirmed or rejected each candidate +rule against the raw page text/HTML: four slice verifiers (`task-9-verify-A.md` +covering style guide §2.1-2.5, `task-9-verify-B.md` §2.6-2.10, +`task-9-verify-C.md` §3.1-3.5, `task-9-verify-D.md` §3.6-3.9) plus one +cross-check pass that re-parsed the word-list page with a stricter HTML5 +parser and diffed every replacement value quoted by the other four +(`task-9-verify-crosscheck.md`). Together they checked ~380 candidate +rules/entries and found **6 fabrications** — rules that appeared in an +earlier research draft but do not exist anywhere on the live guide (see +"Fabrications" below). This preset is built **only** from entries those +five reports marked `CONFIRMED`; nothing here was sourced from the research +draft directly. Fetch date for the verification passes and for +`sources.json`'s hashes is the same day this preset was authored: 2026-07-29. + +Every quote below is the verifier's own quote (or fetch-log citation), +reproduced here at second hand — see the verifier reports themselves for +the full text and additional context. + +## Shipped rules + +Severity policy: `error` is reserved for rules checking pure document +STRUCTURE (heading hierarchy/uniqueness, list-item mechanics, table +mechanics, link placement, alt-text presence, sentence length) where a +violation is unambiguous and mechanical; every word-choice, terminology, +punctuation-convention, and phrasing rule is `warn`. This is a +simplification of spec §2's severity table (which gives a shorter, purely +illustrative example list) applied uniformly here for predictability, at +the cost of a few punctuation-mechanics rules (Oxford comma, en dash, +single-space-between-sentences) that could arguably also be `error` — see +"Author's judgment calls" at the end of this document. + +Four shipped rules are named exceptions to that split, not silent +inconsistencies with it: `no-code-in-heading` is heading-scoped (in the +STRUCTURE family above by location) but ships at `warn` because the guide +states it with hedged wording ("Avoid code items in headings," not +"don't"). `no-numbered-headings` is also heading-scoped and the guide +states it unconditionally ("Don't use numbers in headings..."), but ships +at `warn` because the shipped pattern is a narrowed heuristic (bare +leading ordinals and `Step N`/`Part N` markers only, to keep the +false-positive rate low), not a complete detector of every way a heading +could number a sequence — the rule's confidence is in what it does flag, +not full coverage of the guide's stated principle. `emphasis-style` and +`strong-style` ship at `warn` because the guide states its markup +preference as a recommendation ("we recommend underscores," "it's best to +use double asterisk"), not an unconditional "don't." So the actual policy +is: STRUCTURE, stated by the guide as an unconditional, completely +detectable rule → `error`; everything else — including these four +nominally-structural rules the guide itself hedges, or that need an +intentionally incomplete detection heuristic → `warn`. + +### Structural (heading, list, table, link, alt-text, sentence mechanics) — `error` + +| Rule id | Source URL | Quote | Verdict | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `google/heading-sentence-case` | [headings](https://developers.google.com/style/headings) | "Use sentence case for all headings and titles." | CONFIRMED | +| `google/heading-increment` | [headings](https://developers.google.com/style/headings) | "put an `

` tag only under an `

` tag" | CONFIRMED | +| `google/single-h1` | [headings](https://developers.google.com/style/headings) | "only use a level-1 heading once on a page" | CONFIRMED | +| `google/first-line-h1` | [headings](https://developers.google.com/style/headings) | "only use a level-1 heading once on a page" (same statement as `single-h1`; see "Author's judgment calls") | CONFIRMED | +| `google/no-duplicate-heading` | [headings](https://developers.google.com/style/headings) | "easier to jump between pages and sections...if the headings...are unique" | CONFIRMED | +| `google/no-trailing-punctuation` | [periods](https://developers.google.com/style/periods) | "Don't end headings with periods." | CONFIRMED | +| `google/no-empty-headings` | [headings](https://developers.google.com/style/headings) | "Don't use empty headings. Make sure headings are followed by content." | CONFIRMED | +| `google/no-emphasis-as-heading` | [accessibility](https://developers.google.com/style/accessibility) | "Tag headings using heading elements." | CONFIRMED (nuance: the engine's own token rule excludes a bold/italic lead-in followed by more text in the SAME paragraph, and a bold/italic-only paragraph that ends in required punctuation — but NOT a bold/italic-only paragraph with no ending punctuation whose description follows in a later paragraph, which the guide's own "run-in heading" pattern also permits; see verifier A row 12 and "Known limitations" below) | +| `google/no-link-in-heading` | [headings](https://developers.google.com/style/headings) | "Don't put links in headings." | CONFIRMED | +| `google/list-item-capital` | [lists](https://developers.google.com/style/lists) | "Start each list item with a capital letter" | CONFIRMED | +| `google/no-alt-text` | [accessibility](https://developers.google.com/style/accessibility) | "For every image, provide an alt attribute" | CONFIRMED | +| `google/no-merged-cells` | [accessibility](https://developers.google.com/style/accessibility) (also [tables](https://developers.google.com/style/tables)) | "Don't merge cells. Don't use colspan or rowspan attributes" | CONFIRMED | +| `google/sentence-length` | [accessibility](https://developers.google.com/style/accessibility) | "Try to use fewer than 26 words per sentence." | CONFIRMED — mapped to the new `length` assertion (`unit: words`, `max: 25`) per spec §5.6's stated-numbers table; this is the first non-prose preset to ship a `length`-backed rule (see "Engine/registry changes" below) | + +### Headings (residual) / lists — `warn` + +| Rule id | Source URL | Quote | Verdict | +| ----------------------------- | -------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `google/no-code-in-heading` | [headings](https://developers.google.com/style/headings) | "Avoid code items in headings." | CONFIRMED | +| `google/no-numbered-headings` | [headings](https://developers.google.com/style/headings) | "Don't use numbers in headings to indicate a sequence" | CONFIRMED | +| `google/list-length` | [lists](https://developers.google.com/style/lists) | "a single item isn't really a list" | CONFIRMED, but the verifier marked this specific line NOT-ENFORCEABLE (descriptive aside, not an imperative rule) — downgraded from the generic "list mechanics" `error` class to `warn` for that reason; ships via `list-length`'s own `min: 2` default, no `max` (Google states no upper bound; the 2-7 range is Microsoft's, spec §5.6) | + +### Voice, person, tense, contractions — `warn` + +| Rule id | Source URL | Quote | Verdict | +| ------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `google/second-person` | [person](https://developers.google.com/style/person) | "use `you` or `your` instead of `we`, `our`, or `us`" | CONFIRMED (organizational-reference exception noted in the rule's message; detection-only). Fix wave A: `ignoreCase: true` replaced with an explicit `We/we/Our/our/Us/us` alternation so it stops also matching the all-caps abbreviation "US" — see "Fix wave A corrections" | +| `google/use-contractions` | [contractions](https://developers.google.com/style/contractions) | "we recommend using negation contractions such as isn't, don't, and can't" | CONFIRMED (`fix: false`: guide's own "is _not_" emphasis exception) | +| `google/no-triple-contractions` | [contractions](https://developers.google.com/style/contractions) | "Don't use three-word contractions such as mightn't've." | CONFIRMED | +| `google/no-lets` | [word-list#lets](https://developers.google.com/style/word-list#lets) | "Don't use if at all possible." | CONFIRMED — citation corrected: the draft cited "contractions", but `let's` is never mentioned there; the quote lives on the word-list page (verifier A row 14) | +| `google/no-please-note` | [word-list#please](https://developers.google.com/style/word-list#please) | "Don't use please in the normal course of explaining how to use a product" (the phrase `please note` specifically has no documented exception) | CONFIRMED. Fix wave A: `fix: false` added — the delete-swap left a capitalization/fragment mess behind ("Please note that X." -> "that X."); see "Fix wave A corrections" | +| `google/no-please` | [word-list#please](https://developers.google.com/style/word-list#please) | "Use please only when you're asking for permission or forgiveness...Recommended: If the issue persists, please contact your account representative." | CONFIRMED, **must be DETECT-ONLY, never a swap** — the guide's own recommended example sentence uses "please"; a delete-swap would rewrite text the guide endorses (per task-9-author-corrections.md) | + +### Timeless documentation — `warn` + +| Rule id | Source URL | Quote | Verdict | +| ---------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `google/no-timeless-phrases` | [timeless-documentation](https://developers.google.com/style/timeless-documentation) | "as of this writing, currently, does not yet, eventually, existing, future...latest, new, newer, now, old, older, presently, at present, soon" (full confirmed list) | CONFIRMED, but only 5 of the ~15 confirmed terms are shipped (`as of this writing`, `at present`, `presently`, `does not yet`, `currently`) — the rest are ordinary high-frequency words (`existing`, `future`, `latest`, `new`, `newer`, `now`, `old`, `older`, `soon`, `eventually`, `in the future`) with legitimate everyday uses that would make a blind pattern unusably noisy; see "Author's judgment calls" | + +### Latinisms, abbreviations, slang — `warn` + +| Rule id | Source URL | Quote | Verdict | +| --------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `google/no-latinisms` | [word-list](https://developers.google.com/style/word-list) | "Don't use i.e. or e.g."; "Don't use vs. as an abbreviation for versus" | CONFIRMED (i.e./e.g./vs. — independently reconfirmed in verifier C's 3.1 slice). Fix wave A: carries only the 3 period-terminated keys now (see "Fix wave A corrections" below for why `aka`/`vice versa` moved out) | +| `google/no-latinisms-plain` | [word-list](https://developers.google.com/style/word-list) | "aka: Don't use. Instead, write out also known as"; "vice versa: ...use...the other way around" | CONFIRMED, same verification as `google/no-latinisms` above. Split out in Fix wave A (2026-07-29) so these two non-period keys get full `\b...\b` anchoring instead of the period-terminated group's unanchored match — see "Fix wave A corrections" | +| `google/no-internet-slang` | [abbreviations](https://developers.google.com/style/abbreviations) | "Don't use internet slang abbreviations such as tl;dr, ymmv, RTFM" | CONFIRMED (each also has its own word-list replacement: "To summarize" / "Your results might vary" / "For more information, see...") | +| `google/no-via` | [word-list#via](https://developers.google.com/style/word-list#via) | "Don't use." | CONFIRMED, DETECT-ONLY (no replacement given) — this is the exact term the research file's own provenance note flagged a summarizer once inverted; independently reconfirmed by verifier C and the cross-check | +| `google/abbrev-no-periods` | [abbreviations](https://developers.google.com/style/abbreviations) | "Don't use periods with acronyms or initialisms." | CONFIRMED | +| `google/us-abbreviation` | [word-list#US](https://developers.google.com/style/word-list#US) | "OK to use as an abbreviation for United States. Don't use U.S. or U.S.A." | CONFIRMED | +| `google/no-slash-abbrev` | [slashes](https://developers.google.com/style/slashes) | "Slashes with abbreviations... Recommended: care of, with / Not recommended: c/o, w/" | CONFIRMED. Fix wave A: both keys gained a leading-only `\b` (were matching inside "src/output", "www/static", "show/hide", "new/old"); Fix wave C: both also gained a trailing `(?![A-Za-z])` (were matching inside "w/o", "c/oscillator") — see "Fix wave A corrections" / "Fix wave C corrections" | + +### Numbers, dates, units — `warn` + +| Rule id | Source URL | Quote | Verdict | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `google/spell-out-ordinals` | [numbers](https://developers.google.com/style/numbers) | "Not recommended: 1st, 5th, 12th, 43rd" | CONFIRMED | +| `google/number-format` | [numbers](https://developers.google.com/style/numbers) (also [hyphens](https://developers.google.com/style/hyphens) for the "from N-M" token) | "use numerals and the percent sign (%), without a space"; "place a zero in front of the decimal point"; "Recommended: 192x192 / Not recommended: 192 x 192"; "Not recommended: from 8-20 files" | CONFIRMED | +| `google/date-format` | [dates-times](https://developers.google.com/style/dates-times) | "Not recommended: 12/02/2017" | CONFIRMED | +| `google/time-format` | [word-list#AM,\_PM](https://developers.google.com/style/word-list#AM,_PM) | "use all caps, no periods, and a space before" (AM/PM); "Remove the minutes from round hours" (dates-times page) | CONFIRMED — citation corrected: the AM/PM quote lives on the word-list page, not dates-times (verifier B row 63) | +| `google/rfc-spacing` | [word-list#RFC](https://developers.google.com/style/word-list#RFC) | "use a space between RFC and the number (for example, RFC 2318)" | CONFIRMED | +| `google/data-rate-units` | [word-list#GBps](https://developers.google.com/style/word-list#GBps) | "By convention, we don't use KB/s" (and the other 5 unit pairs) | CONFIRMED — the master research table's "8 pairs" claim was wrong; verified exactly 6 (verifier B row 67, cross-check) | + +### Punctuation — `warn` + +| Rule id | Source URL | Quote | Verdict | +| --------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `google/no-ampersand` | [text-formatting](https://developers.google.com/style/text-formatting) | "Don't use ampersands (&) as conjunctions or shorthand for and." | CONFIRMED (UI-element exception preserved: pattern requires spaces on both sides, so `AT&T`/`&` are untouched) | +| `google/dash-style` | [dashes](https://developers.google.com/style/dashes) | "En dashes...Don't use. Instead, use a hyphen or the word to."; "Don't put a space before or after it [the em dash]." | CONFIRMED | +| `google/single-space-sentences` | [periods](https://developers.google.com/style/periods) | "Leave only one space between sentences." | CONFIRMED | +| `google/conjunctive-adverb-comma` | [commas](https://developers.google.com/style/commas) | "put a comma after the conjunctive adverb" | CONFIRMED — the draft's fourth example ("Nonetheless") is not named on the live page; only the three confirmed examples (otherwise/however/therefore) are shipped | +| `google/comma-before-that` | [pronouns](https://developers.google.com/style/pronouns) | "That introduces a restrictive clause. It isn't preceded by a comma." | CONFIRMED | +| `google/neither-nor` | [word-list#neither](https://developers.google.com/style/word-list#neither) | "Write neither A nor B, not neither A or B." | CONFIRMED | +| `google/no-and-or` | [slashes](https://developers.google.com/style/slashes) | "avoid writing and/or except when space is limited, such as in tables" | CONFIRMED — the table exception is not separately carved out in the rule's scope (see "Known simplifications") | + +### Links — `warn` + +| Rule id | Source URL | Quote | Verdict | +| ----------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `google/vague-link-text` | [cross-references](https://developers.google.com/style/cross-references) | "Don't use phrases such as this document, this article, or click here" | CONFIRMED (page renamed from `link-text`; content unchanged) | +| `google/no-url-as-link-text` | [cross-references](https://developers.google.com/style/cross-references) | "don't use a URL as link text" | CONFIRMED (legal/ToS exception noted, not separately modeled — see "Known limitations" below) | +| `google/link-intro-about` | [cross-references](https://developers.google.com/style/cross-references) | "Don't use on instead of about" | CONFIRMED | +| `google/link-punctuation` | [cross-references](https://developers.google.com/style/cross-references) | "don't put the link text in quotation marks" | CONFIRMED | +| `google/no-target-blank` | [cross-references](https://developers.google.com/style/cross-references) | "Don't force links to open in a new tab or window" | CONFIRMED | +| `google/self-reference-terms` | [word-list#documentation](https://developers.google.com/style/word-list#documentation) | "use this document, and not this article, this topic, this doc" | CONFIRMED (general terminology preference, independent of link text — distinct from `vague-link-text` above, which is scoped to the `link` segment) | + +### Text formatting — `warn` + +| Rule id | Source URL | Quote | Verdict | +| ------------------------------ | ---------------------------------------------------------------------- | ----------------------------------------------------------------- | --------- | +| `google/emphasis-style` | [text-formatting](https://developers.google.com/style/text-formatting) | "we recommend underscores" | CONFIRMED | +| `google/strong-style` | [text-formatting](https://developers.google.com/style/text-formatting) | "best to use the double asterisk for bold" | CONFIRMED | +| `google/no-underline` | [text-formatting](https://developers.google.com/style/text-formatting) | "Reserve underlining for link text" | CONFIRMED | +| `google/no-casing-style-names` | [capitalization](https://developers.google.com/style/capitalization) | "Don't use a casing style name, such as camel case or snake case" | CONFIRMED | + +### Code in text — `warn` + +| Rule id | Source URL | Quote | Verdict | +| -------------------------- | ---------------------------------------------------------------- | ------------------------------------------ | --------- | +| `google/no-inflected-code` | [code-in-text](https://developers.google.com/style/code-in-text) | "Don't inflect the name of a code element" | CONFIRMED | + +### UI elements and verbs — `warn` + +| Rule id | Source URL | Quote | Verdict | +| ---------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `google/ui-element-quotes` | [ui-elements](https://developers.google.com/style/ui-elements) | 'click the "Next" button' (Not recommended example) | CONFIRMED | +| `google/no-click-on` | [word-list#click](https://developers.google.com/style/word-list#click) | "Don't use click on." | CONFIRMED | +| `google/no-hover` | [word-list#hover](https://developers.google.com/style/word-list#hover) | "Don't use. Instead use hold the pointer over." | CONFIRMED — DETECT-ONLY, not a swap (inflected forms "hovering"/"hovered" don't slot into the replacement phrase) | +| `google/no-uncheck` | [word-list#uncheck](https://developers.google.com/style/word-list#uncheck) | "use clear for checkboxes" | CONFIRMED. Bare `check` and `deselect` are deliberately NOT shipped — see "Author's judgment calls" | +| `google/scroll-to` | [word-list#scroll](https://developers.google.com/style/word-list#scroll) | "write go to the section, instead of scroll to the section" | CONFIRMED (preference, not a flat ban — `fix: false`) | +| `google/no-toggle-verb` | [ui-elements](https://developers.google.com/style/ui-elements) | "Don't use the word toggle as a verb. Describe the action." | CONFIRMED — citation correction: not a word-list entry as the draft implied, but is on the `ui-elements` page (verifier D) | +| `google/keyboard-keys` | [ui-elements](https://developers.google.com/style/ui-elements) | "Spell out the names of modifier keys"; "use uppercase instead of lowercase" | CONFIRMED | +| `google/chapter-terminology` | [word-list#chapter](https://developers.google.com/style/word-list#chapter) | "Instead, refer to documents, pages, or sections" | CONFIRMED, DETECT-ONLY | + +### Plain language and wordiness — `warn` + +| Rule id | Source URL | Quote | Verdict | +| ------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `google/plain-language-swaps` | [word-list](https://developers.google.com/style/word-list) | "allows_you_to: Don't use. Instead, use lets you"; "enable: Not recommended: ...enables you to..."; "comprise: Don't use. Instead, use consist of..."; "desire: Don't use. Instead, use a word like want or need"; "learnings: Don't use. Instead, refer to knowledge..."; "agnostic: Don't use. Instead, use...platform-independent" | CONFIRMED | +| `google/in-order-to` | [word-list#in_order_to](https://developers.google.com/style/word-list#in_order_to) | "Avoid in order to; instead, use to. Use in order to when needed to clarify meaning." | CONFIRMED (conditional — `fix: false`) | +| `google/utilize` | [word-list#utilize](https://developers.google.com/style/word-list#utilize) | "Use with caution. Don't use utilize when you mean use. It's OK to use utilize... when referring to the quantity of a resource being used." | CONFIRMED, DETECT-ONLY given the documented exception | +| `google/leverage` | [word-list#leverage](https://developers.google.com/style/word-list#leverage) | "Avoid using if you mean use... use, build on, or take advantage of." | CONFIRMED (`fix: false` — three valid alternatives given) | +| `google/performant` | [word-list#performant](https://developers.google.com/style/word-list#performant) | "Avoid where possible. Instead, use a more precise term." | CONFIRMED, DETECT-ONLY (no fixed replacement) | +| `google/copy-and-paste` | [word-list#Copy_and_paste](https://developers.google.com/style/word-list#Copy_and_paste) | "Avoid using. Instead, explain what to enter into a field and not how." | CONFIRMED | +| `google/create-a-new` | [word-list#Create_a_new](https://developers.google.com/style/word-list#Create_a_new) | "Avoid using unless you need to distinguish... Instead, use Create a ..." | CONFIRMED (exception noted; `fix: false`) | +| `google/no-run-the-following-command` | [procedures](https://developers.google.com/style/procedures) | "Avoid using run the following command to introduce code. Instead, focus on what the command does." | CONFIRMED | +| `google/cons-and-pros` | [word-list#pros](https://developers.google.com/style/word-list#pros) | "cons: Don't use. Instead, use a more precise term, such as disadvantages." / "pros: ...such as advantages." | CONFIRMED — only the compound phrase "pros and cons" ships; bare "pros"/"cons" are excluded, see "Author's judgment calls" | + +### Product and brand names — `warn` + +| Rule id | Source URL | Quote | Verdict | +| ----------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `google/product-names` | [word-list](https://developers.google.com/style/word-list) | "Cloud SDK: Not Google Cloud SDK"; "APIs Explorer: Not API explorer..."; "API key: Not developer key or dev key"; "account name: ...use username"; "curated roles: ...use predefined roles"; "network IP address: ...use internal IP address"; "media type: ...Don't use MIME type"; "curl: Not cURL"; "interconnect type: ...use connection type"; "peering zone: Not peer zone"; "Android-powered device: Not Android device"; "Not...Cloud Platform, or Cloud" (Google Cloud); "Cloud console: Not...Developers Console" | CONFIRMED (each entry individually confirmed in verifier D's §3.4). Fix wave A: `'Cloud console'` gained a `(?` blob whose JSON +keys serialize in non-deterministic order, a CSP `nonce` attribute +regenerated on every request, and an inline feature-flag/experiment +bootstrap array whose element order also varies per request. None of that +reflects the guide's actual content, but it's enough entropy that two +consecutive fetches of the identical page produce different raw-HTML +digests every time — confirmed empirically: of the 30 pages listed in +`sources.json`, fetched twice each a few seconds apart, 16 had a different +raw byte length on the second fetch even though nothing about the guide's +content changed. Hashing raw HTML gives a 100% false-positive drift rate, +which is not drift detection at all — it's noise indistinguishable from +signal. + +**Fix**: hash the extracted `
...
` +region instead of the full page. That region is exactly the guide's +rendered content (headings, paragraphs, lists, tables, the `
` +definition lists the word-list page is built from) and excludes all of +the non-deterministic chrome described above. Verified reproducible: two +consecutive fetches of all 30 pages in `sources.json` (not just a sample) +produced a byte-identical extracted region, and therefore an identical +sha256, for every single page — including the 16 whose raw HTML length +differed between fetches. Reproduction recipe: fetch the page's HTML with +**`curl`** (Fix wave C / Item 4: a plain Node `fetch()` was confirmed to +return a materially different HTML variant of the same URL — not just the +per-request noise above, but a different response shape from the same +client-vs-client comparison — so a future re-check that fetches with a +different HTTP client could see a mismatch and misread it as guide drift; +`sources.json`'s `normalization.fetchMethod` now records this too), take +the first `
...
` match (a +non-greedy, dot-matches-newline regex scan; every page has exactly one), +and sha256 the UTF-8 bytes of that substring. + +Each `sources.json` entry also records `bytes` — the byte length of the +extracted region, not the raw page — so a future re-check that produces a +matching `bytes` but a different `sha256` (or vice versa) is a signal to +check whether the _extraction_ shape changed (e.g. Google renamed the +wrapper class) before treating the result as real guide drift. + +This normalization has a real, narrow blind spot: a change confined +entirely to the `
` wrapper's own attributes, or to guide content +that Google renders outside that element, would not be detected. No such +case was observed across any of the 30 pages while producing this file. + +### Fix wave A corrections (2026-07-29) + +Beyond the drops above, several shipped pairs were corrected in place — +same rule content, safer matching or `fix: false` — rather than dropped, +because the underlying guide entry is real and worth keeping. Full +before/after detail, reproduction commands, and gate output live in +`task-9-report.md`; this is the pointer from provenance to what changed +and why, so a rule split doesn't read as an unexplained addition. + +- **`google/no-latinisms` split in two.** The period-terminated keys + (`i.e.`, `e.g.`, `vs.`) keep `wordBoundary: false` (a trailing `\b` + right after a period-then-space never matches — both are non-word + characters) but now carry a LEADING `\b` baked into the regex source via + `keysAreRegex`, so `vs.` can no longer match inside unrelated words like + "revs.". `aka` and `vice versa` (no trailing period, so they never + needed the exemption) moved to a new rule, `google/no-latinisms-plain`, + with full `wordBoundary: true` anchoring — this is what stops `aka` from + matching inside "Akamai"/"Osaka". +- **`google/no-slash-abbrev`** (`c/o`, `w/`) similarly gained a + leading-only `\b` via `keysAreRegex`, so `w/` no longer matches inside + "www/static", "show/hide", "new/old", and `c/o` no longer matches inside + "src/output". +- **`google/acronym-forms`**: `'OAuth 2'` gained a `(?!\.0)` negative + lookahead so it can no longer match inside the already-correct "OAuth + 2.0" (which was compounding into "OAuth 2.0.0.0.0.0.0" under + `runRulesUntilStable`). `SHA1` moved to its own rule + (`google/sha1-form`, `fix: false`) with a negative lookbehind excluding + a hyphen-preceded match, matching the guide's own documented + hyphenated-compound exception (e.g. "HMAC-SHA1"). `UNICODE` and `IPSEC` + moved to `google/acronym-caps-detect-only` (`fix: false`): both are + ALL-CAPS matches whose correct replacement (`Unicode`/`IPsec`), when + `applyMatchCase` re-upper-cases it to match the all-caps input, round-trips + back to the ORIGINAL wrong spelling byte-for-byte — a permanent, + silent no-op fix that `--fix` nonetheless reported as "fixed". +- **`google/product-names`**: `'Cloud console'` gained a `(? "Google Cloud" was being written as "GOOGLE CLOUD". +- **`google/colo-form`**: split out of `google/compound-forms`, `fix: +false`. `colo` is a noun ("a colocation facility"); `colocate` is a + verb — the unconditional swap produced "The colocate hosts the racks." +- **`google/no-please-note`**: `fix: false` added. Deleting the phrase + leaves a capitalization/fragment mess behind ("Please note that the + endpoint is deprecated." -> "that the endpoint is deprecated."). +- **`google/second-person`**: `ignoreCase: true` replaced with an explicit + `(?:We|we|Our|our|Us|us)` alternation, so it no longer matches the + all-caps abbreviation "US" that `google/us-abbreviation` fixes toward — + previously the two rules fought each other on every occurrence of "US". + +All of the above were found and fixed by the per-PAIR coverage gate added +in the same pass (`src/config/__tests__/preset-google.test.ts`'s "per-pair +coverage" test) plus targeted idempotency/self-match static analysis — see +`task-9-report.md` for the methodology and the complete list. + +### Fix wave C corrections (2026-07-29) + +An independent re-review found this same `applyMatchCase` bug — an +ALL-CAPS match forcing a multi-word replacement to shout — recurring a +THIRD time (`AKA` -> `ALSO KNOWN AS`, `VICE VERSA` -> `THE OTHER WAY +AROUND`, `C/O` -> `CARE OF`, all in rules that shipped with no `fix: false` +workaround at all), after `GCP` and `UNICODE`/`IPSEC` above had each been +patched around it per-rule. This wave fixed it at the source instead +(`src/core/case-preserve.ts`'s `applyMatchCase`): an ALL-CAPS match no +longer forces a MULTI-WORD replacement to upper-case; single-word +replacements are unchanged (`WHITELIST` -> `ALLOWLIST` still shouts — that +remains correct). Two statements above are now stale as a result: + +- **`GCP` is fixable again.** `google/gcp-name`'s `fix: false` (added + above specifically because `applyMatchCase` was shouting "GOOGLE CLOUD") + is REMOVED — the engine fix produces the correctly-cased `"Google +Cloud"` now, and the result is idempotent. `UNICODE`/`IPSEC` + (`google/acronym-caps-detect-only`) were re-checked against the same + engine fix and correctly stay `fix: false`: their replacements + (`Unicode`, `IPsec`) are each a single word, so the multi-word condition + never applies and the round-trip no-op is unchanged — a genuinely + different defect shape (same-word casing round-trip, not a + multi-word-phrase shout), not something this engine fix was meant to + reach. +- **`google/product-names`'s `'Cloud console'` lookbehind widened.** The + `(? + `"google Google Cloud console"`) the original fix was meant to + eliminate but didn't fully. +- **`google/no-slash-abbrev` gained a trailing guard.** The leading-only + `\b` above fixed matches inside a preceding word (`"src/output"`, + `"www/static"`) but left the TRAILING side open: `w/` still matched + inside the common, ordinary abbreviation `w/o` ("without"), and `c/o` + inside a word immediately following it (`"c/oscillator"`). Both keys now + carry a trailing `(?![A-Za-z])` negative lookahead blocking a following + ASCII letter, so `w/o` and `c/oscillator` are left alone entirely (the + guide's own `slashes` page doesn't list "w/o" as a separate entry), + while `"w/ headers"` and `"c/o the compliance department"` still match. + +See `task-9-report.md`'s "Fix wave C" section for the full list of every +rule whose `--fix` output changed as a result of the engine change (9 +rules / 38 pairs across this preset alone — larger than the three rules +named above, since the fix is in the shared helper every `swap` rule +uses), the semantics chosen and why, and the complete acceptance evidence. + +### CONFIRMED vs. safe-to-fix — a distinction future audits of this preset must also apply + +Recorded here in mirror of `presets/microsoft/PROVENANCE.md`'s "Fix wave C / +Step 5" (task 10), per that task's brief: a verifier `CONFIRMED` verdict +establishes only that the live guide page discusses a term. It does **not** +establish that a blind textual (`swap`) substitution of that term is safe +to auto-apply — that is a separate question, and every verification pass +in task 10 conflated the two. `recheck/microsoft`'s task-10 fix wave C +found two pairs (`as well as` -> `and`, `or greater`/`or higher`/`or lower` +-> `or later`/`or earlier`) that were marked plain `CONFIRMED`, with the +correct quote attached, and still shipped as corrupting unconditional +swaps: the quotes themselves showed a caution against treating two terms as +interchangeable ("don't use X as a synonym for Y") or a context scoped +narrower than what shipped ("when identifying multiple versions..."), not +an unconditional "use Y instead of X" instruction. + +This note is a forward-looking record, not a claim that this file's own +pairs were re-audited against that standard — that re-audit is out of +scope for task 10 (which targets `recheck/microsoft`) and has not been +done here. Whoever next adds a candidate pair to this preset, or authors +either of the two future presets task 10 anticipates, should apply the +same per-pair check `recheck/microsoft`'s Step 1 table used before marking +a `CONFIRMED` pair fixable: is the live quote a direct "Use Y, not X" +instruction, or does it instead read as a synonym-conflation caution, a +verb/context-scoped rule, a multi-target rule, or a "don't use X" with no +replacement stated? Only the first shape is safe to ship as an +unconditional `swap`; the rest need a position anchor, a narrower scope, or +detection-only, in that preference order. A `CONFIRMED` verdict is +necessary evidence that a rule belongs in the preset at all — it is not +sufficient evidence that the rule may safely auto-fix. + +**This note's own re-audit did eventually happen** — see "Fix-posture +change" below, which re-audits every fixable pair in THIS preset against a +second, orthogonal axis the note above doesn't cover, and replaces the +"CONFIRMED vs. safe-to-fix" framing with a stricter, mechanical posture. + +### Fix-posture change (2026-07-30) + +> **RETIRED 2026-07-30 — see "Detection-only" below.** This section's +> criterion (same-word normalization) no longer determines which pairs are +> fixable in this preset: none are. Kept as historical record only. + +Base commit `eb4f8b11dac`, branch `aa/recheck-style-guides`. Brief: +`.superpowers/sdd/preset-fix-posture-brief.md`. Report: +`.superpowers/sdd/preset-fix-posture-report.md`. Applied to both flagship +presets in the same pass — see `presets/microsoft/PROVENANCE.md`'s own +"Fix-posture change" section for the full two-axis rationale and the six +named corruption strings that motivated it (three fix waves on +`recheck/microsoft`, each fixing the pairs a probe found, each followed by +another probe finding more — 2, then 2, then 6). This preset shipped nine +of the same defect class across its own three fix waves (`GCP` shouting, +`OAuth 2` self-compounding, `w/o`/`c/oscillator` corruption, ...) — the +CONFIRMED-vs-safe-to-fix note above addresses the GUIDANCE-SHAPE axis +(direct instruction vs. caution/scoped/multi-target); it does not address +a second, orthogonal axis: whether the avoid-term ALSO has a legitimate, +unrelated sense a blind substitution corrupts regardless of how clearly +the guide states its rule. + +#### The posture + +Auto-fix is retained only where a replacement cannot be wrong: the same +word, normalized (spelling, hyphenation, casing, or a non-standard written +form of the identical word). A pair that substitutes a genuinely different +word or phrase — even a synonym that looks safe on the page — moves to +detection-only. Concrete examples found THIS wave, previously shipped +fixable under a plain `CONFIRMED`/reasonable-looking verdict: + +- **`agnostic` → `platform-independent`** (`google/plain-language-swaps`): + "agnostic" very commonly means doubting or noncommittal about religious + or philosophical claims ("he's agnostic about the existence of an + afterlife") — a blind fix corrupts that sentence into "...platform- + independent about the existence of an afterlife." The word-list entry + is real and the replacement is reasonable for the INTENDED sense; it is + simply also a different word with an unrelated common sense, the same + shape as `recheck/microsoft`'s `DMZ`. +- **`GCP` → `Google Cloud`** (`google/gcp-name`): the same shape as `DMZ` → + `perimeter network` — an acronym expanded into a DIFFERENT phrase than + its own literal expansion (`GCP` stands for "Google Cloud Platform", not + "Google Cloud"), not a respelling, and `GCP` has unrelated expansions in + other domains ("Good Clinical Practice", "Grade Control Point"). Note + this reverses Fix wave C's own "GCP is fixable again" call above: that + wave correctly fixed a real ENGINE bug (the case-preservation shout), + but engine-correctness and word-choice-safety are different questions, + and only the first was checked at the time. +- **`IO` → `I/O`** (`google/acronym-forms`, moved to + `google/acronym-caps-detect-only`): bare, case-sensitive "IO" is a real + product/library name in common developer use ("Socket.IO" — the period + before "IO" is a non-word character, so `\bIO\b` matches inside it) — + "Socket.IO connects clients" would corrupt to "Socket.I/O connects + clients." + +By contrast, `google/compound-forms`'s ~80 remaining pairs (`data store` → +`datastore`, `e-mail` → `email`, ...) are genuine spacing/hyphenation +normalizations of the identical two words and stay fixable — this is the +family the fix-posture brief itself predicted would "largely survive." +Four pairs did NOT survive despite living in that same rule: `data +cleansing` → `data cleaning` (different word, "cleansing" vs "cleaning"), +`transcompile` → `transpile` (two competing compiler-jargon terms, not a +spelling variant), `autoupdate` → `automatically update` (expands "auto" +into a different word), and `pre-emptive` → `preemptible` (a different +adjective — "pre-emptive" describes acting in advance; "preemptible" +describes being subject to preemption — not a hyphenation of one word). +Moved to a new sibling rule, `google/compound-forms-word-choice` +(`fix: false`), since `fix` is a whole-rule flag and this bundle mixed +both classes. + +#### Result + +**Fixable `swap`/`consistency` pairs: 164 → 114**, across 41 rules with a +`swap`/`consistency` assertion (up from 38 — `google/vs-versus`, +`google/aka-form`, and `google/compound-forms-word-choice` are new, +splitting bundles that mixed same-word and different-word pairs). +`vs.` → `versus` and `aka` → `also known as` both stay fixable: unlike +`i.e.`/`e.g.` (Latin abbreviations translated into an unrelated English +phrase) and `vice versa` (a distinct Latin phrase with no letter-derived +relationship to its replacement), `vs.` and `aka` are literal truncations +of the identical word/phrase they abbreviate (`aka` is literally the +initials of "Also Known As"). **Correction (wave 2, see below): this call +on `aka` was wrong.** Expanding an abbreviation INTO a phrase is a +substitution, the same shape as `e.g.`/`i.e.` two sentences above, not a +respelling — "the letters spell out the words" does not make it a +same-word normalization. `aka` moved to `fix: false` in wave 2. `cURL` → +`curl` (a pure casing correction) +moved from `google/product-names` (now fully detection-only) to +`google/brand-capitalization`, which already ships the same class of +case-only brand-name fix. + +Severity is unaffected: this preset's existing policy already puts every +word-choice/phrasing rule at `warn` regardless of fixability (see "Shipped +rules" above), so newly-detection-only rules needed no severity change. + +#### Gates + +`pnpm build`, `pnpm test` (105 files, 1467 passed / 5 skipped — shared +suite with `recheck/microsoft`), `pnpm parity --corpus monorepo-docs` +(unchanged: 27425 = 27425, 0 unexplained — this preset has no bearing on +the markdownlint-parity corpus), `npx nx run recheck:lint +--max-warnings=0` (clean). Every one of the 114 pairs that remain fixable +in this preset (240 combined with `recheck/microsoft`'s 126) was verified +programmatically: real change, idempotent (second `--fix` pass is a +no-op), and the original violation regex no longer matches the fixed +text — not sampled. Full command output and the exhaustive fixable-rule +table are in `.superpowers/sdd/preset-fix-posture-report.md`. + +### Fix-posture change, wave 2 — the proper-noun axis (2026-07-30) + +> **RETIRED 2026-07-30 — see "Detection-only" below.** This section's +> third axis (proper-noun collision) no longer determines which pairs are +> fixable in this preset: none are. Kept as historical record only. + +Base commit `25a62c3d1f1`, branch `aa/recheck-style-guides`. Brief: +`.superpowers/sdd/preset-posture-fix2-brief.md`. Report: +`.superpowers/sdd/preset-fix-posture-report.md`'s "Wave 2" section. See +`presets/microsoft/PROVENANCE.md`'s own "Fix-posture change, wave 2" +section for the full rationale shared by both presets; this section +covers this preset's specific findings. + +#### The third axis + +A pair keeps `fix: true` only if it is the same word normalized (axis 1, +guidance-shape), has no unrelated legitimate sense (axis 2, homograph — +wave 1), **and, new this wave, cannot occur as part of a real +organization, product, brand, or place name.** Four demonstrated +corruptions share this cause: `markdown` → `Markdown` (a retail markdown +sentence gets capitalized into the markup-language name), `FinTech` → +`fintech` (breaks "FinTech Group AG", a real company), `U.S.` → `US` +(breaks "U.S. Bank", a real bank), `USA` → `US` (breaks "USA Gymnastics", +a real governing body — this specific pair lives in +`microsoft/usa-abbreviation`, not this preset, since `google/us- +abbreviation` never shipped a bare `USA` key). Acronyms and single +capitalizable words are the highest-risk shapes: a rule that ignores +case or normalizes punctuation matches a proper noun's own official +spelling exactly as readily as ordinary prose. + +#### Sweep and results + +Every fixable `swap` pair in this preset (105 pairs after this wave, +sweeping the full ~114-pair fixable set left by wave 1) was checked +against question 3. + +| Rule | Pair(s) flipped | Real proper noun / other reason | +| ----------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `google/aka-form` | `aka` → `also known as` | Not proper-noun — **misclassified in wave 1** (see the correction note above): expanding an abbreviation into a phrase is a substitution, the same shape as `e.g.`/`i.e.`, not a same-word normalization. Also happens to double as a real proper noun ("AKA" is the common abbreviation for the sorority Alpha Kappa Alpha — "She was initiated into AKA her freshman year" is a genuine, independent proper-noun collision on top of the misclassification). | +| `google/us-abbreviation` | `U.S.A.`/`U.S.` → `US` (both) | "U.S. Bank" (top-10 US bank), "U.S. Steel", "U.S.A. Track and Field" (national governing body) — the named brief case. | +| `google/acronym-forms` | `FinTech` → `fintech` | "FinTech Group AG" — a real, publicly-traded German company whose name keeps the mixed-case "FinTech" spelling. | +| `google/acronym-forms` | `I-O` → `I/O` | "I-O DATA DEVICE, INC." — a real, major Japanese PC-peripherals manufacturer ("Japan's undisputed market leader" in that industry) whose brand is written exactly "I-O" (hyphenated, capital letters). `fin-tech`/`adtech`/`ad-tech` (all-lowercase keys, no case change involved) do NOT carry this risk: this rule is case-sensitive, so an all-lowercase key can never match a capitalized brand's own casing in the first place. | +| `google/brand-capitalization` | `markdown` → `Markdown` | The named brief case — both a retail/finance homograph (axis 2) AND, in the reverse direction, a proper-noun risk: capitalizing every lowercase occurrence assumes it always means the Markdown language. | +| `google/brand-capitalization` | `material design` → `Material Design` | Same reverse-direction risk as `markdown`: "the material design of the building incorporates local stone" is a plain, unrelated architectural phrase this pair would wrongly capitalize into Google's design-language name. | +| `google/brand-capitalization` | `search console` → `Search Console` | Same shape, weaker but still real: a generic lowercase phrase for an admin/tuning panel, not exclusively Google's product name. | +| `google/compound-forms` | `datasource` → `data source` | Collides with `javax.sql.DataSource`/Spring's `DataSource` — a real, load-bearing Java/Spring class and config-property name ("Configure the DataSource bean in the Spring context"), exactly the kind of technical content this preset's own audience writes about. | + +**9 pairs flipped**, each moved to a new detection-only sibling rather +than anchored (`google/acronym-forms-proper-noun`, `google/brand- +capitalization-proper-noun`, `google/compound-forms-proper-noun`; +`google/aka-form` and `google/us-abbreviation` flip whole-rule since every +pair in each was reclassified). Severity is unaffected — this preset's +existing policy already puts every word-choice/phrasing rule at `warn` +regardless of fixability, so nothing needed adjusting, matching wave 1's +own note. + +**Fixable pairs: 114 → 105.** + +#### Why `fix: false`, not another anchor + +Same reasoning as `recheck/microsoft`'s wave 2 section: three separate +waves have each shown an anchor leaking exactly one near-miss beyond +wherever it was tested. A casing/abbreviation fix is low-value enough +that detection alone is a fine outcome, so every pair this wave found +moved straight to `fix: false` rather than growing another exclusion +list. + +#### Gates + +`pnpm build`, `pnpm test` (105 files, 1490 passed / 5 skipped — 23 new +tests this wave, shared suite with `recheck/microsoft`), `pnpm parity +--corpus monorepo-docs --profile default` (unchanged: 27425 = 27425, 0 +unexplained), `npx nx run recheck:lint --max-warnings=0` (clean). Every +sentence in the brief's acceptance set 1 verified unchanged through +`--fix` twice and still detected against the correct new rule name; every +rule (including the 3 new ones here) still fires on +`google-violations.md`. Full output in +`.superpowers/sdd/preset-fix-posture-report.md`'s "Wave 2" section. + +### Detection-only (2026-07-30) + +Base commit `006c026a1f0`, branch `aa/recheck-style-guides`. Brief: +`.superpowers/sdd/preset-detection-only-brief.md`. Report: +`.superpowers/sdd/preset-detection-only-report.md`. + +**This section REPLACES the fixability criterion described in "CONFIRMED +vs. safe-to-fix," "Fix-posture change," and "Fix-posture change, wave 2" +above — it does not sit alongside them as a fourth, stricter axis.** Those +three sections are kept below, unedited, as the historical record of the +criteria that were tried and superseded; do not read any of them as current +guidance. As of this section, **`recheck/google` ships zero fixable rules. +Every rule in this file is `fix: false`, unconditionally** — set +structurally, once, by a loop at the end of `buildGooglePreset()` +(`src/config/presets/google.ts`), not by auditing pairs against a sharper +rule. A dedicated test (`preset-google.test.ts`'s "is detection-only" +describe block) reads the live preset object and fails if any rule is ever +fixable again — the same derive-from-the-preset shape the per-pair coverage +gate already uses, so this cannot regress silently the way three prior +narrowing passes did. + +#### Why a fourth axis wasn't the answer + +Two prior fix-posture changes (above) each replaced "does the guide confirm +this pair" with a sharper structural test — first "is it the same word +normalized" (axis 1: guidance-shape, then axis 2: homograph), then "does it +also collide with a real proper noun" (axis 3). Each pass shipped clean +against its own criterion and each was then probed again. The fifth +adversarial probe against this preset and `recheck/microsoft` together (the +project's fifth in total, after three rounds already narrowed what counted +as "safe") found **18 of 29 probed pairs (62%) still corrupting correct +prose** — a RISING hit rate, not a falling one, and the failures spanned +every category previously believed safe by axes 1-3, including two this +preset's own criteria treated as clean: + +- **Spelling**, believed the safest category of all: Hemingway's real, + correctly spelled published title _A Moveable Feast_ is corrected to "A + Movable Feast" by `microsoft/az-grammar-usage`'s `moveable` → `movable` + pair (a genuine same-word normalization by every axis above — axis 1 + passes, axis 2 finds no unrelated sense, axis 3 finds no proper-noun + collision on the WORD "moveable" itself, and the collision is instead + with a specific, individually unforeseeable literary title). +- **Hyphenation**, this preset's own `read only` → `read-only` pair + (`google/compound-forms`): "Please read only the introduction" — an + adverb ("only") modifying a verb ("read") plus its object — becomes + "Please read-only the introduction," a nonsense adjective use. Same-word + by every axis (it is the identical two words, just joined), yet wrong, + because the axes check the WORDS, not the GRAMMATICAL ROLE those words + are playing in the sentence being fixed. +- **Meaning inverted outright**: `google/acronym-forms`'s `No SQL` → `NoSQL` + turns "No SQL is used here" (a true statement that no NoSQL database is + in use) into "NoSQL is used here" (a false statement that one is) — same + word-pair, same axis-1/2/3 clearance, opposite meaning. + +Full round-5 acceptance evidence (every sentence above, and more, run +through `--fix` twice and confirmed byte-identical) lives in +`src/config/__tests__/presets.test.ts` (the "detection-only" contract), plus the +per-preset suites in `preset-google.test.ts` and `preset-microsoft.test.ts`. + +#### The conclusion this decision rests on + +A rule's _category_ — spelling, hyphenation, casing, word-choice — does not +predict fix-safety at this scale. A style guide states _intent_ ("use X to +mean Y"); a `swap`/`consistency`/`pattern` rule matches _tokens_ (literal +text, regardless of the grammatical role or referent that text has in a +given sentence). That gap is not closable by inventing a fourth, fifth, or +sixth axis: axis 1 (guidance-shape) closed the space of pairs where the +guide's own wording was ambiguous; axis 2 (homograph) closed the space of +words with an unrelated common sense; axis 3 (proper-noun) closed the space +of words that double as real names. Each closure found the NEXT gap, not +zero gap. The project decision is to stop narrowing and remove fixing +capability from both style-guide presets entirely: users get every finding +(detection is completely unaffected — every rule still runs `execute()` and +reports) and apply the judgment a style guide has always required, same as +before either preset existed and same as Vale (the tool these presets +replace), which never shipped an auto-fixer and never had this class of +bug. + +#### What did not change + +Detection. Every rule's `execute()` path, message, severity, and scope are +untouched — only `fix()` is gated off (`core/runner.ts`'s +`rule.fix !== false` check). The per-pair and per-rule coverage gates +(`preset-google.test.ts`) still require every rule to fire on its own clean +fixture, so a rule that neither fixes nor reports is still caught as dead +weight, same as before this change. + +### Known limitations + +Two verifier-confirmed guide-sanctioned exceptions that the shipped rules +do not implement. Both are documented here explicitly, not just in the +Shipped rules table's own one-line notes, because a rule that is stricter +than its source needs to say so where a reader is actually looking for +"why did this fire on text the guide allows" — otherwise a user hitting +either case reasonably concludes the preset misquotes Google. + +1. **`google/no-emphasis-as-heading` over-fires on one shape of the + guide's own "run-in heading" pattern (verifier A row 12).** The guide + permits bold for "run-in headings" — a bolded lead-in term followed by + its description, most commonly inside a description-list item (`Google's +own example:
  • Emu: the best kind of bird
  • `) or a single + paragraph ("**Emu:** the best kind of bird."), and instructs authors to + "end the run-in heading with a period or a colon." The shipped rule + (ported from markdownlint's MD036) already tolerates the common cases: + it only examines top-level paragraphs (so a run-in heading inside an + actual list item is never even considered), a bold lead-in with more + text in the SAME paragraph is excluded (the paragraph has more than one + meaningful child), and a bold-only paragraph ending in the guide's own + required punctuation is excluded by the rule's pre-existing punctuation + check. Empirically verified still-flagged: a bold/italic-only paragraph + with NO ending punctuation, whose description follows in a SEPARATE, + later paragraph (e.g. `**Emu**\n\nThe best kind of bird.`) — reproduced + directly against the shipped rule. Not fixed in this pass: the rule is + a markdownlint port shared with `recheck/markdown` (out of scope for + this wave's provenance/documentation focus, and doing so risks the same + loosening-vs-noise tradeoff every other TOO-RISKY exclusion in this file + weighs). +2. **`google/no-url-as-link-text` does not exempt legal/ToS documents + (verifier B row 71).** The guide's own text: "Exception: In some legal + documents (such as some Terms of Service documents), it's okay to use + URLs as link text." The shipped rule is a plain pattern match on link + text starting with `http(s)://`, scoped to `link`; it has no way to + know whether the document containing the link is a legal/ToS document, + so it will flag a bare URL used as link text there too, against the + guide's own stated exception. Not enforceable to fix with the engine's + current primitives (same class of gap as `firewalls` → `firewall +rules`'s "Compute Engine documentation only" scoping and the + NOT-ENFORCEABLE table's document-subject-matter entries above) — Recheck + has no signal for what kind of document a file is. +3. **`google/gcp-name` is case-sensitive.** The pair (`GCP: 'Google +Cloud'`) carries no `ignoreCase`, so only the literal all-caps `GCP` + token is matched or fixed — `gcp` and `Gcp` are neither flagged nor + fixed by this rule. A reader could reasonably infer broader coverage + than exists: many OTHER rules in this same file (e.g. `google/ +compound-forms`, `google/use-contractions`) do set `ignoreCase: true`, + so the absence here is easy to read as an oversight rather than a + choice. It is a choice, shared with the sibling `google/product-names` + (also no `ignoreCase`) and `google/brand-capitalization` (explicitly + documented as "deliberately case-sensitive keys, matching only the + wrongly-cased literal form" in its own comment) — all three treat + brand/product-name casing as exact-match by design. Left as-is rather + than widened here (carried over from a `recheck/microsoft` fix-wave + audit, flagged as a documentation gap, not a behavior bug): widening to + `ignoreCase: true` is a scope decision for whoever owns `google.ts`, not + a documentation fix. + +### Author's judgment calls + +Decisions this preset's author made that go beyond a verifier's literal +verdict, recorded per the task's instruction to flag (not silently +resolve) anything not settled by the inputs: + +1. **`google/first-line-h1` and `google/single-h1` share one Google + quote.** Verifier A's row 4 confirms both rule ids against the same + sentence ("only use a level-1 heading once on a page"); the research + draft, not a separate guide statement, is what split them into two rule + ids. Both ship (both are real markdownlint-ported mechanisms Google's + principle supports), but see point 2. +2. **`single-h1` and `first-line-h1` cannot both fire from one document.** + Empirically verified (not assumed): the underlying token rules faithfully + port markdownlint's MD025/MD041, which check OPPOSITE preconditions of + "the document's first heading" — `single-h1` only reports a second h1 + when nothing but comments/frontmatter precede the first one; + `first-line-h1` only fires when that first real content is NOT a correct + h1. `google-violations.md` (which starts with a level-2 heading to + trigger `first-line-h1`) structurally cannot also trigger `single-h1`, so + a second, tiny fixture (`google-violations-single-h1.md`) isolates it. + This is a genuine engine/upstream-semantics interaction, not a fixture + bug or a noisy rule. +3. **Downgraded `list-length` from the generic "list mechanics = error" + class to `warn`.** The verifier marked the underlying quote + NOT-ENFORCEABLE (descriptive, not imperative); only the mechanism + (`list-length`'s `min: 2` default) is deterministic, so the softer + severity reflects the guide's own softer confidence. +4. **Shipped only 5 of ~15 confirmed "timeless documentation" words.** All + ~15 are confirmed content, but 10 of them (`existing`, `future`, + `latest`, `new`, `newer`, `now`, `old`, `older`, `soon`, `eventually`, + `in the future`) are ordinary high-frequency English words with + extensive legitimate everyday use unrelated to documentation staleness. + Shipping them would make the preset unusably noisy on typical prose — + the same class of risk the verifiers flagged elsewhere as TOO-RISKY, + extended here on the same reasoning to entries the verifiers didn't + individually re-litigate for riskiness (their job was confirming + content, not judging blind-match safety for every term). +5. **`google/no-numbered-headings` ships despite some residual risk.** + Narrowly scoped to `Step N`/`Part N` markers and a bare leading ordinal, + to keep the false-positive rate low; broader numeric heading patterns + (e.g. version numbers in a heading) are not matched. +6. **`google/cons-and-pros` ships only the compound phrase.** Bare `pros`/ + `cons` are excluded even though individually confirmed, because standing + alone they're closer to ambiguous (conference abbreviations, "con + artist") than the extremely common, unambiguous two-word phrase. +7. **The Oxford-comma/no-and-or "except in tables" exception is not + separately scoped.** `google/no-and-or` runs over the `summary` scope, + which includes table cells, so it would also (correctly, per the general + rule, but against the guide's own table exception) flag "and/or" inside + a table. Judged not worth a bespoke scope array for one rule; a project + that hits this can override the rule's `scope`. + +### Engine/registry changes this preset required + +- **Schema**: `src/config/schema.ts`'s top-level `patternProperties` only + accepted `^recheck/[a-z0-9-_]+$` rule keys. Spec §2 ("Composition + safety") requires per-preset namespacing (`google/`, + `microsoft/`, ...) precisely so two flagship presets can be + composed without collisions — the pattern is widened to + `^[a-z][a-z0-9-]*/[a-z0-9-_]+$` to allow that (existing `recheck/*` keys + are unaffected; they're just the `recheck` namespace now). +- **`length` moved from opt-in to preset-shipped.** `google/sentence-length` + is the first non-prose preset rule to ship a native scope-rule + assertion beyond what `recheck/prose` already ships. Per + cross-task-constraints.md §C / task-9-10-resolutions.md §5, this trips + the registry<->preset completeness guard in + `src/config/__tests__/presets.test.ts`: `length` is removed from the + documented-opt-in list (now `DOCUMENTED_OPT_IN_ASSERTIONS`, moved from + `prose.ts` to `presets/index.ts` since the policy is monorepo-wide, not + prose-specific) and the completeness test's "shipped" side is derived + dynamically from ALL presets rather than a single prose-named constant. + See that test file's own comments for the mechanics. +- **`.npmignore` widened to include `presets/**/_`.** The package has no +`files`field in`package.json`; publishing is governed entirely by +`.npmignore`, which was a blanket `_`deny with only`dist/\*_/_`and`package.json`allowed back in. A new top-level`presets//`directory (this file,`sources.json`) would have shipped nowhere without +this change — verified with `npm pack --dry-run` before and after. diff --git a/packages/recheck/presets/google/sources.json b/packages/recheck/presets/google/sources.json new file mode 100644 index 0000000000..4c169d1c55 --- /dev/null +++ b/packages/recheck/presets/google/sources.json @@ -0,0 +1,192 @@ +{ + "normalization": { + "method": "article-body-sha256", + "description": "Each digest is sha256 over the UTF-8 bytes of the
    ...
    region extracted from the raw HTML response -- NOT the full page. Hashing the full raw HTML is not reproducible: every fetch embeds a per-request