Skip to content

Add textlint plugin - #2039

Merged
webpro merged 3 commits into
webpro-nl:mainfrom
anandghegde:feat/textlint-plugin
Sep 15, 2026
Merged

webpro merged 3 commits into
webpro-nl:mainfrom
anandghegde:feat/textlint-plugin

Conversation

@anandghegde

Copy link
Copy Markdown
Contributor

Adds a plugin for textlint. It turns on when textlint is a dependency.

Right now, projects using textlint get every textlint-rule-*, textlint-rule-preset-*, textlint-filter-rule-* and textlint-plugin-* package reported as an unused devDependency, because those packages are only referenced by short names in the config.

What it does

  • Config files: .textlintrc, .textlintrc.{json,yaml,yml,js,cjs} and the textlint key in package.json. This is the same list textlint's @textlint/config-loader loads via rc-config-loader.
  • rules: each key maps to textlint-rule-*. Preset keys (preset-x, @scope/preset-x, textlint-rule-preset-x) map to textlint-rule-preset-*. Rules set to false are skipped, like textlint does.
  • filters maps to textlint-filter-rule-*.
  • plugins (array or object) maps to textlint-plugin-*.
  • Script arguments: --rule, --preset and --plugin resolve the same way. --config/-c sends that file through resolveConfig.

Name resolution follows textlint's createFullPackageName and TextLintModuleResolver. textlint tries the prefixed name first (for example @scope/x → @scope/textlint-rule-x) and then the name as written. The plugin picks whichever of those is declared in package.json, similar to the Remark plugin. If none is declared, it reports the prefixed name as unlisted. A name that already has the prefix (textlint-rule-foo, @scope/textlint-plugin-foo) is reported unchanged.

Example:

{
  "rules": {
    "no-todo": true,
    "@textlint-rule/no-unmatched-pair": true,
    "preset-ja-technical-writing": true
  },
  "filters": { "comments": true },
  "plugins": { "@textlint/markdown": true }
}

This resolves to textlint-rule-no-todo, @textlint-rule/textlint-rule-no-unmatched-pair, textlint-rule-preset-ja-technical-writing, textlint-filter-rule-comments and @textlint/textlint-plugin-markdown.

Tests

  • test/plugins/textlint.test.ts covers .textlintrc.json plus a script with --config, --rule, --preset and --plugin. It includes unscoped, scoped, already-prefixed, preset and disabled rules, and expects two unlisted rules.
  • test/plugins/textlint-package-json.test.ts covers the package.json textlint key with array-style plugins.

Commands run:

  • bun test test/plugins/textlint.test.ts test/plugins/textlint-package-json.test.ts: pass
  • pnpm test --runtime node --smoke (packages/knip): 1055/1055 pass
  • pnpm build, pnpm knip, pnpm knip:production (packages/knip): clean
  • pnpm run ci (root: oxlint, oxfmt --check, installed-check, line endings): clean

Claude Code helped write this change. I reviewed it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QBWFWqjbUQGS1c9LsM2bxX

Resolve rules, presets, filter rules and plugins referenced in textlint
config files and `textlint --rule/--preset/--plugin/--config` arguments to
their package names, following textlint's module resolution.

@webpro webpro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding textlint support! Two dependency-resolution issues are detailed inline.

Comment on lines +33 to +37
const toPackageDependency = (prefix: string, name: string, candidates: string[], manifest: Manifest) =>
toDependency(
candidates.find(candidate => isDeclared(manifest, candidate)) ??
(hasPrefix(prefix, name) ? name : createFullPackageName(prefix, name))
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Local rule paths become bogus dependencies

A rule configured with require.resolve('./rules/check-document.cjs') works in textlint, but Knip prefixes the resolved path and reports an unlisted textlint-rule-. Preserve local paths as file inputs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1368973. Rules, filters and plugins given as a relative or absolute path are now added as entry files instead of being turned into a textlint-rule- dependency. The fixture now has a .textlintrc.cjs using require.resolve('./rules/check-document.cjs'): the rule file is picked up and nothing extra is reported as unlisted.

string: ['rule', 'preset', 'plugin'],
resolveInputs: (parsed, { manifest }) => {
const inputs: Input[] = [];
for (const rule of [parsed.rule ?? []].flat()) inputs.push(toRuleDependency(String(rule), manifest));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Comma-separated CLI values are not split

--rule no-todo,max-comma loads two rules in textlint. Knip reports both dependencies unused and invents an unlisted textlint-rule-no-todo,max-comma. Handle array values for rules, presets, and plugins.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1368973. --rule, --preset and --plugin values are now split on commas (and repeated flags still work). The fixture script now uses --rule no-dead-link,no-empty-section --preset jtf-style,ja-spacing --plugin review,latex2e. Before this change it reported all six as unused along with three invented unlisted names. Now it reports neither.

…CLI values

Rules, filters and plugins given as a relative or absolute path (e.g.
`require.resolve('./rules/check-document.cjs')`) are now added as entry
files instead of being prefixed into a `textlint-rule-` dependency.

`--rule`, `--preset` and `--plugin` accept comma-separated lists, as
textlint does, so `--rule no-todo,max-comma` yields both rules.
@pkg-pr-new

pkg-pr-new Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/knip@2039
npm i https://pkg.pr.new/@knip/language-server@2039
npm i https://pkg.pr.new/@knip/mcp@2039

commit: c2ba06f

@webpro webpro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing both earlier findings. One local-module resolution issue remains.

const toModuleDependency = (prefix: string, key: string, manifest: Manifest) =>
toPackageDependency(prefix, key, [createFullPackageName(prefix, key), key], manifest);

const toLocalEntry = (key: string, cwd: string) => toEntry(toAbsolute(key, cwd));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Extensionless local modules are reported unused

A .textlintrc.cjs rule keyed by path.join(__dirname, 'rules/check') can load rules/check.js or rules/check/index.js in textlint. Here, toEntry treats the path as a glob, so Knip reports the loaded rule file as unused. Please use toDeferResolveEntry for these local module paths, and cover extensionless files and directory modules.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6680de6. toLocalEntry now uses toDeferResolveEntry, so the path goes through module resolution instead of being treated as a glob:

const toLocalEntry = (key: string, cwd: string) => toDeferResolveEntry(toAbsolute(key, cwd));

That covers rules, filters and plugins, since all three route through this helper.

The fixture's .textlintrc.cjs now keys rules by all three shapes textlint accepts:

rules: {
  [require.resolve('./rules/check-document.cjs')]: true,
  [path.join(__dirname, 'rules/check-heading')]: true,   // rules/check-heading.js
  [path.join(__dirname, 'rules/check-link')]: true,      // rules/check-link/index.js
}

The test asserts Object.keys(issues.files).length === 0 and the counters go to 4 processed. On the previous commit the two new rule files are reported unused (files: 2), so it fails there and passes now.

pnpm run ci is clean.

A rule, filter or plugin keyed by a local path is resolved by textlint's
module resolver, so it can omit the extension or point at a directory
with an index file. toEntry treated the path as a glob, which matched
nothing and left the loaded rule file reported as unused.

The fixture now also keys rules by an extensionless path and by a
directory.

@webpro webpro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! 🧡

@webpro
webpro merged commit 532dab5 into webpro-nl:main Sep 15, 2026
36 of 40 checks passed
@webpro

webpro commented Sep 16, 2026

Copy link
Copy Markdown
Member

🚀 This pull request is included in v6.36.0. See Release 6.36.0 for release notes.

Using Knip in a commercial project? Please consider becoming a sponsor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants