jsDocProcessor check examples buggy with typescript-eslint
The projectService option in typescript eslint is weird enough but..
@example error: Fatal: Parsing error: /home/charlike/code/mid-april-2025/workspaces-filter/src/foo.ts/1_/home/charlike/code/mid-april-2025/workspaces-filter/src/foo.md/*.js was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProject.eslint
Whatever i try to include these dummy md/js/example files it fails.
Any hints on integrating eslint-plugin-jsdoc with typescript?
I have a root tsconfig in which i have include that includes both ts and js files. I tried with projectService: true, i tried with { projectService: { allowDefaultProject: ["*.js"] } }, i tried adding *md/*.js ..
It's most likely because of how you build the dummy filename which in turn cannot be detected properly by typescript-eslint (or eslint, or settings or whatever) for some reason. Maaybe just make a random name or hash of the source of the example.
~~This has not been tested with typescript-eslint.~~ I had forgotten that we have one test for it.
~~Fenced blocks of TypeScript also are not currently supported.~~ See below
In any case, the dummy files are simply ESLint's way of providing a way for the config files to target languages-within-a-language (like we do with JavaScript within JavaScript (@example tag) comment blocks) and should not be included. Hopefully, any solution we may provide in the future can avoid triggering errors as you have seen.
Not ideal, because this disables type-checked rules, but I worked around this by adding the following to my config:
{
name: "jsdoc/examples/rules",
files: ["**/*.md/*.js"],
languageOptions: {
parserOptions: {
projectService: false,
},
},
rules: {
// "always" newline rule at end unlikely in sample code
"eol-last": 0,
// Wouldn't generally expect example paths to resolve relative to JS file
"import/no-unresolved": 0,
// Snippets likely too short to always include import/export info
"import/unambiguous": 0,
"jsdoc/require-file-overview": 0,
// The end of a multiline comment would end the comment the example is in.
"jsdoc/require-jsdoc": 0,
// Unlikely to have inadvertent debugging within examples
"no-console": 0,
// Often wish to start `@example` code after newline; also may use
// empty lines for spacing
"no-multiple-empty-lines": 0,
// Many variables in examples will be `undefined`
"no-undef": 0,
// Common to define variables for clarity without always using them
"no-unused-vars": 0,
// See import/no-unresolved
"node/no-missing-import": 0,
"node/no-missing-require": 0,
// Can generally look nicer to pad a little even if code imposes more stringency
"padded-blocks": 0,
...ts.configs.disableTypeChecked.rules,
},
},
Fenced blocks of TypeScript also are not currently supported.
This is a blocker for me though, I'm going to have to turn off the processor for now. All of my examples are in fenced code blocks, which gets parsed as template literals 😂
Fenced blocks of TypeScript also are not currently supported.
This is a blocker for me though, I'm going to have to turn off the processor for now. All of my examples are in fenced code blocks, which gets parsed as template literals 😂
The exampleCodeRegex and rejectExampleCodeRegex options should help identify code between fenced code blocks, but won't currently detect that TypeScript is desired and that the @typescript-eslint parser should be included, for example.
Now that I've had a chance to take a closer look...
I apologize that I had been mistaken in stating we had not tested with @typescript-eslint. Per one of our tests, it does work to process TypeScript within @example tags (in a JavaScript file).
And I see it works with the following eslint.config.js (thus finding TS examples within a JS file; to find within a TS file, supply getJsdocProcessorPlugin({parser: typescriptEslintParser})):
/**
* @example
* const list: number[] = [
* 1,
* 2,
* 3,
* ];
* quux(list);;;
*/
export default [
{
files: [
'**/*.md/*.js',
],
languageOptions: {
parser: typescriptEslintParser,
},
rules: {
'no-extra-semi': [
'error',
],
},
},
{
files: [
'eslint.config.js',
],
plugins: {
examples: getJsdocProcessorPlugin(),
},
processor: 'examples/examples',
},
];
However, indeed, the type-checked rules are indeed problematic, as you discovered. It seems TS expects the files to be on the filesystem, which ESLint does not do here since it is creating them as strings. It seems this is at least confirmed by Google AI (in a related question).
So to allow type-aware rules, I believe we'll either need TS, typescript-eslint, or eslint to change.
It appears I was thankfully also mistaken about fenced code blocks because we can just define it ourselves as follows:
{
files: [
'**/*.js',
],
plugins: {
examples: getJsdocProcessorPlugin({
exampleCodeRegex: '^```(?:ts|js|typescript|javascript)([\\s\\S]*)```\s*$'
}),
},
processor: 'examples/examples',
},
This only evaluates code between the fenced block start and end markers.
So, in short, you can get TS-checking, but just not with type-aware rules.
Btw, I've submitted #1392 to add a new option to make it more convenient to have ignored fenced blocks. Previously one would need to specify which blocks were allowed or ignored.
Now, by default, languages which are not "js", "javascript", "ts", or "typescript" will be ignored by the processor. If you do want such blocks to be checked (e.g., to avoid language name errors), submit allowedLanguagesToProcess: false as an option (as with the old behavior) or add the specific languages within allowedLanguagesToProcess: [language1, language2, etc.].
Btw, I've submitted #1392 to add a new option to make it more convenient to have ignored fenced blocks. Previously one would need to specify which blocks were allowed or ignored.
So to enable fenced code blocks, you need exampleCodeRegex: "^```([\\s\\S]*)```\\s*$"?
Btw, I've submitted #1392 to add a new option to make it more convenient to have ignored fenced blocks. Previously one would need to specify which blocks were allowed or ignored.
So to enable fenced code blocks, you need
exampleCodeRegex: "^```([\\s\\S]*)```\\s*$"?
Right (or equivalent). Otherwise, the whole @example tag contents are treated as JavaScript.
Ok, I've gotten it working with type-aware rules, but I haven't managed to get it working for both JS as well as TS.
import index, {
getJsdocProcessorPlugin
} from 'eslint-plugin-jsdoc';
import ts, {
parser as typescriptEslintParser,
} from 'typescript-eslint';
export default [
...ts.configs.strictTypeChecked,
{
files: ['**/*.js'],
...ts.configs.disableTypeChecked,
// This commented-out block is not working as expected in place of the previous line
// languageOptions: {
// parser: typescriptEslintParser,
// parserOptions: {
// projectService: {
// allowDefaultProject: [
// // '*.js', // Also not working if added
// '*.js/*.md/*.ts'
// ]
// }
// }
// },
// name: 'jsdoc/examples/processor',
// plugins: {
// examples: getJsdocProcessorPlugin({
// parser: typescriptEslintParser,
// matchingFileName: 'placeholder.md/*.ts'
// }),
// },
// processor: 'examples/examples',
},
{
files: [
'**/*.ts',
],
languageOptions: {
parser: typescriptEslintParser,
parserOptions: {
projectService: {
allowDefaultProject: ['*.ts/*.md/*.ts']
}
}
},
name: 'jsdoc/examples/processor',
plugins: {
examples: getJsdocProcessorPlugin({
parser: typescriptEslintParser,
// In order to avoid the default of processing our examples
// as *.js files, we indicate the inner blocks are TS.
// This allows us to target TS files, as we do below.
matchingFileName: 'placeholder.md/*.ts'
}),
},
processor: 'examples/examples',
},
{
files: [
// `**/*.ts` could also work if you want to share this config
// with other non-@example TypeScript
'**/*.md/*.ts',
],
name: 'jsdoc/examples/rules',
languageOptions: {
parser: typescriptEslintParser
},
rules: {
'no-extra-semi': 'error',
...index.configs.examples[1].rules,
},
}
];
Ok, I've gotten it working with type-aware rules, but I haven't managed to get it working for both JS as well as TS.
Really, I'm starting to wonder if it even makes sense to use typed linting on examples? Like if we disable no-undef & co because examples needn't be complete, does it even make sense to try to run typed linting? Maybe this would better served with "it's possible to set allowDefaultProject to *.ts/*.md/*.ts, but not recommended; generally you should apply ts.configs.disableTypeChecked to *.ts/*.md/*.ts snippets instead."
Ok, I've gotten it working with type-aware rules, but I haven't managed to get it working for both JS as well as TS.
Really, I'm starting to wonder if it even makes sense to use typed linting on examples? Like if we disable
no-undef& co because examples needn't be complete, does it even make sense to try to run typed linting? Maybe this would better served with "it's possible to setallowDefaultProjectto*.ts/*.md/*.ts, but not recommended; generally you should applyts.configs.disableTypeCheckedto*.ts/*.md/*.tssnippets instead."
That makes sense, though it would be nice to figure out how we can get JS and TS files working together with type-aware rules for any who wanted it.
I wonder if somehow state in the parser, TS project service, or possibly or own getJsdocProcessor.js is shared between instances, and thus causing conflicts when running both together.
I asked Google AI about typescript-eslint and got this apparently relevant portion:
The question of state sharing is complex because typescript-eslint's behavior changes depending on whether or not you're using type-aware linting.
Standard linting
In a standard, non-type-aware linting setup, typescript-eslint works much like regular eslint.
- Each file is linted individually without knowledge of other files in the project.
- There is effectively no shared state between runs, and caching can be applied on a per-file basis.
- This is a highly performant and isolated process.
Type-aware linting
When you enable type-aware linting by adding parserOptions.project to your configuration, typescript-eslint's behavior changes significantly. This is required for powerful rules that need access to TypeScript's type information.
Even in this mode, state is not shared between separate eslint instances. However, within a single type-aware linting run (e.g., a single npx eslint command), state is managed and shared to avoid re-computations.
How type-aware linting works internally
- During a type-aware linting run,
typescript-eslintasks TypeScript to build an in-memory representation of your entire project, including all its type information. - This "program" contains an abstract syntax tree (AST) and full type details for every file.
- As each file is linted, the
typescript-eslintrules can query this central program instance for type information. For example, a rule can ask for the type of a variable in a different file. - To optimize performance in monorepos,
typescript-eslintcan build a map of TypeScript programs. When a file is processed, the linter checks if an existing program instance already contains that file before creating a new one.
That may explain why I got better results when running both TS and JS files together:
// In tsconfig.json
"include": [
"*.js",
"*.ts"
],
...ts.configs.strictTypeChecked,
{
files: [
'**/*.ts',
'**/*.js'
],
languageOptions: {
parser: typescriptEslintParser,
parserOptions: {
projectService: {
allowDefaultProject: [
'*.ts/*.md/*.ts',
'*.js/*.md/*.ts',
// Oddly had to add this to get the test file properly linted, but
// didn't need to add `eslint.config.js` here, though it was linted (actually I wasn't allowed to add it with the project service saying it was already included)
'test.js'
]
}
}
},
name: 'jsdoc/examples/processor',
plugins: {
examples: getJsdocProcessorPlugin({
parser: typescriptEslintParser,
// In order to avoid the default of processing our examples
// as *.js files, we indicate the inner blocks are TS.
// This allows us to target TS files, as we do below.
matchingFileName: 'placeholder.md/*.ts'
}),
},
processor: 'examples/examples',
},
{
files: [
// `**/*.ts` could also work if you want to share this config
// with other non-@example TypeScript
'**/*.md/*.ts'
],
name: 'jsdoc/examples/rules',
languageOptions: {
parser: typescriptEslintParser
},
rules: {
'no-extra-semi': 'error',
...index.configs.examples[1].rules,
},
}
@JoshuaKGoldberg : Could I trouble you for any insights into why we may be seeing problems with multiple separate invocations of typescript-eslint here? The particularly problematic example is https://github.com/gajus/eslint-plugin-jsdoc/issues/1377#issuecomment-3289360441 .
We're trying to allow separate config for linting @example tag TypeScript content within TypeScript and JavaScript files.
I'm starting to wonder if it even makes sense to use typed linting on examples?
Same here. In theory, it absolutely would make sense. Examples can also contain the kinds of issues typed lint rules catch (for-in over arrays, floating/misused Promises, etc.).
Unfortunately, given the way ESLint is set up right now, there's no way for the typescript-eslint types to handle examples well. Types are generated by parsers and parsers aren't told upfront what the list of files is. Parsers only know the current file they're parsing, not whether additional files will be added. But TypeScript doesn't have a performant way to add new files that aren't in the current TSConfig. If files can't be explicitly listed in the TSConfig then adding them causes a large amount of TypeScript project recreation work. Hence the allowDefaultProject option being limited to 8 files by default.
https://github.com/typescript-eslint/typescript-eslint/discussions/11568#discussioncomment-14318666 has references to most of the discussions around this area.
So, no, I don't think it's practical to run typed linting on Markdown snippets right now. 😕