Skip to content

Bug: tsconfig.json not respected in new files when multiple tsconfigs include the same directory with projectService enabled #8835

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
4 tasks done
fire332 opened this issue Apr 4, 2024 · 19 comments
Labels
bug Something isn't working evaluating community engagement we're looking for community engagement on this issue to show that this problem is widely important

Comments

@fire332
Copy link

fire332 commented Apr 4, 2024

Before You File a Bug Report Please Confirm You Have Done The Following...

  • I have tried restarting my IDE and the issue persists.
  • I have updated to the latest version of the packages.
  • I have searched for related issues and found none that matched my issue.
  • I have read the FAQ and my problem is not listed.

Issue Description

When EXPERIMENTAL_useProjectService: true and at least two tsconfig.jsons include a non-empty common directory, typescript-eslint will not respect tsconfig.json for new/renamed files until ESLint is reloaded.

Possibly related: #8206 #7435

Reproduction Repository Link

https://github.com/fire332/typescript-eslint-bug-repro/

Repro Steps

  1. clone the repo
  2. pnpm install
  3. open project in VSCode with the ESLint extension installed
  4. IMPORTANT: open .prettierrc.js or eslint.config.js and wait for ESLint to initialize
  5. create a new .ts file in ./src or ./src-alt
  6. problems reported will show:
This rule requires the `strictNullChecks` compiler option to be turned on to function correctly. eslint(@typescript-eslint/no-unnecessary-condition)
  1. run Developer: Reload Window from the command palette
  2. check the file you created and note there's no problems reported
  3. rename the file and note that the same problem will be reported again

Versions

package version
typescript-eslint 7.5.0
TypeScript 5.4.3
ESLint 8.57.0
node >=20.11.1
@fire332 fire332 added bug Something isn't working triage Waiting for team members to take a look labels Apr 4, 2024
@karlhorky
Copy link

karlhorky commented Apr 6, 2024

I can confirm this behavior of erroring with the strictNullChecks error message, although I'm not sure about the requirement that reproduction requires multiple tsconfig.json files to cause this behavior - in my reproduction, it also happens with a single tsconfig.json file 🤔

My reproduction (see config files below):

  1. Open a .tsx file (confirm that linting is working)
  2. Copy the file
  3. 💥 Observe the error
Kapture.2024-04-06.at.18.32.20.mp4

Error message:

This rule requires the `strictNullChecks` compiler option to be turned on to function correctly. eslint(@typescript-eslint/no-unnecessary-condition)

Reproduction files:

eslint.config.js

import eslintTypescript from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';

/** @type {import('@typescript-eslint/utils/ts-eslint').FlatConfig.ConfigArray} */
const config = [
  {
    files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'],
    languageOptions: {
      parser: typescriptParser,
      parserOptions: {
        project: './tsconfig.json',
        EXPERIMENTAL_useProjectService: true,
        warnOnUnsupportedTypeScriptVersion: true,
      },
    },
    plugins: {
      '@typescript-eslint': {
        rules: eslintTypescript.rules,
      },
    },
    rules: {
      '@typescript-eslint/no-unnecessary-condition': 'warn',
    },
  },
];

export default config;

tsconfig.json

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "target": "ES2015",
    "moduleResolution": "Bundler",
    "moduleDetection": "force",
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "downlevelIteration": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "strict": true,
    "incremental": true,
    "noUncheckedIndexedAccess": true,
    "checkJs": true,
    "jsx": "preserve",
    "strictNullChecks": true,
    "module": "esnext"
  },
  "include": [
    "**/*.ts",
    "**/*.tsx",
    "**/*.js",
    "**/*.jsx",
    "**/*.cjs",
    "**/*.mjs"
  ],
  "exclude": ["node_modules", "build", "public", "scripts"]
}

@JoshuaKGoldberg JoshuaKGoldberg added this to the 8.0.0 milestone Apr 7, 2024
@JoshuaKGoldberg JoshuaKGoldberg added the team assigned A member of the typescript-eslint team should work on this. label Jun 2, 2024
@JoshuaKGoldberg
Copy link
Member

Hey good news, I tried this out again today and it looks like the problem is fixed! I tried the two mentioned reproductions and a new clean repo going off our Getting Started docs.

I haven't dug deep but I my hunch is some combination of #8815 + #9097 would at least be good places to look.

@fire332 @karlhorky are you able to confirm whether this issue is still happening for you? 🙏

@JoshuaKGoldberg JoshuaKGoldberg removed their assignment Jun 2, 2024
@JoshuaKGoldberg JoshuaKGoldberg added awaiting response Issues waiting for a reply from the OP or another party and removed triage Waiting for team members to take a look team assigned A member of the typescript-eslint team should work on this. labels Jun 2, 2024
@fire332
Copy link
Author

fire332 commented Jun 3, 2024

Without changing any of the configs and just updating dependencies, the behavior described in the opening comment remains identical.

@JoshuaKGoldberg Have you ensured the ESLint extension is installed and ESLint completed initializing in step 4 before moving on to step 5?

Versions

package version
typescript-eslint NEW 7.11.0
TypeScript NEW 5.4.5
ESLint 8.57.0
node NEW 20.14.0

@karlhorky
Copy link

karlhorky commented Jun 3, 2024

I'm also still experiencing the problem described in my comment, using ESLint v9, the latest v8 alpha and the new languageOptions.parserOptions.projectService config option naming.

  • typescript-eslint@8.0.0-alpha.24
  • eslint@9.3.0
  • typescript@5.4.5

@JoshuaKGoldberg JoshuaKGoldberg self-assigned this Jun 3, 2024
@JoshuaKGoldberg JoshuaKGoldberg added team assigned A member of the typescript-eslint team should work on this. and removed awaiting response Issues waiting for a reply from the OP or another party labels Jun 3, 2024
@JoshuaKGoldberg
Copy link
Member

Hmm. Interesting. I'll take another look, thanks.

In case it's useful, I'm only testing with v8 - as of yesterday, typescript-eslint@8.0.0-alpha.25. We have some improvements to the project service on the v8 line that we don't plan on backporting to v7.

@fire332
Copy link
Author

fire332 commented Jun 3, 2024

I've just tested on the v8 version with ESLint v9 with the patch from #9205 as well and the same issue occurs. The repro has been updated on the typescript-eslint@8 branch.

@higherorderfunctor
Copy link
Contributor

higherorderfunctor commented Jun 3, 2024

@fire332 my patch would fix your base compiler options not getting pulled in where you extends in the tsconfig.json.

Stuff like this should start working.

"paths": {
  "@common/*": ["./common/*"],
},

Two thoughts:

First, are we sure this isn't a tsserver issue? To find the bug for my patch I had to enable logging here:

With something like this:

        logger: {
            close: doNothing,
            endGroup: doNothing,
            getLogFileName: () => undefined,
            hasLevel: () => true,
            info: (s) => console.info(`INFO: ${s}`),
            loggingEnabled: () => true,
            msg: (s, type) => console.log(`MSG (${type}): ${s}`),
            perftrc: (s) => console.log(`PERF: ${s}`),
            startGroup: doNothing,
        },

I was using the CLI, so if this is an editor only bug you may need to write to a file unless your editor can capture stdout.

You can also try the environment variable, but it didn't work for me on the CLI running eslint. You may also get double logs if your editor runs tsserver + eslint language servers that could overwrite themselves so probably best to leave the filename alone to append the PID if this happens to work through vscode.

I also ran with the environment variable DEBUG=* to log all eslint messages.

With logging in eslint and tsserver I was able to trace the issue I found. I raise that it might be tsserver as I see file watchers in my logs like this:

INFO: FileWatcher:: Added:: WatchInfo: /<PROJECT>/tsconfig.json 2000 undefined Project: /<PROJECT>/tsconfig.json WatchType: Config file

@JoshuaKGoldberg would you accept a PR to allow the tsserver logging to be enabled? It was really helpful in running down project service related issues.

The second thought is why are we overlapping includes in tsconfigs?

The setup I use is below. All tsconfig.jsons are simply "linchpins" with an explicit "include: []" and some project references. This is how my tooling finds all it's files from the root tsconfig.json. tsconfig.build.json has overlapping includes but the root tsconfig.build.json has its own graph never seen by my editor.

A large scale project that follows this method is https://github.com/Effect-TS/effect if you wish to look at an extended example.

.
├── packages
│   ├── package-a
│   │   ├── tsconfig.build.json   # includes lib (or src) files, has refs to workspace dependencies' tsconfig.build.json
│   │   ├── tsconfig.json         # has refs to lib and test
│   │   └── tsconfig.lib.json     # includes lib (or src) files, has refs to workspace dependencies' tsconfig.json
│   │   └── tsconfig.test.json    # includes tests files, has refs to lib (or src) and workspace dependencies' tsconfig.json
│   ├── package-b
│   │   ├── tsconfig.build.json   # includes lib (or src) files,has refs to workspace dependencies' tsconfig.build.json
│   │   ├── tsconfig.json         # has refs to lib and test
│   │   └── tsconfig.lib.json     # includes lib (or src) files, has refs to workspace dependencies' tsconfig.json
│   │   └── tsconfig.test.json    # includes tests files, has refs to lib (or src) and workspace dependencies' tsconfig.json
├── tsconfig.base.json            # has shared compiler options, EVERY other tsconfig extends this one
├── tsconfig.build.json           # has refs to each package's tsconfig.build.json
├── tsconfig.json                 # has refs to each package's tsconfig.json and tsconfig.utils.json
├── tsconfig.utils.json           # includes root level stuff like my eslint.config.js, esbuild.config.js, etc

For a large project you will need to increase maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING from https://typescript-eslint.io/packages/typescript-estree/.

Keep in mind that this is pretty slow as the option indicates. My linting for my work project has gone from a couple minutes to 20 minutes and I suspect there is some race condition as it sometimes goes for an hour before the CI server kills it. Most of that overhead appears to just be tsserver resolving files. My project is a mixed frontend/backend monorepo using vue which doesn't help since it looks, at a glance at least so take with a grain of salt, to send every code block in a .vue file to be resolved (e.g., I think <h3>{{ section.text }}</h3> is maybe sent as its own file). Excluding vue files it is roughly 3x slower (2min to 6min on my project).

Here is an example of a vue file that seems to keep getting opened triggering resolution in tsserver on a single file lint from the CLI with https://github.com/vuejs/vue-eslint-parser.

  typescript-eslint:typescript-estree:useProgramFromProjectService Opening project service file for: <REDACTED>/project/packages/project-a/src/views/Home.vue at absolute path <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opened project service file: { configFileName: '<REDACTED>/project/packages/project-a/tsconfig.src.json', configFileErrors: [] } +5s
  typescript-eslint:typescript-estree:useProgramFromProjectService Retrieving script info and then program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +1ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Found project service program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opening project service file for: <REDACTED>/project/packages/project-a/src/views/Home.vue at absolute path <REDACTED>/project/packages/project-a/src/views/Home.vue +8ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opened project service file: { configFileName: '<REDACTED>/project/packages/project-a/tsconfig.src.json', configFileErrors: [] } +1s
  typescript-eslint:typescript-estree:useProgramFromProjectService Retrieving script info and then program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Found project service program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opening project service file for: <REDACTED>/project/packages/project-a/src/views/Home.vue at absolute path <REDACTED>/project/packages/project-a/src/views/Home.vue +2ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opened project service file: { configFileName: '<REDACTED>/project/packages/project-a/tsconfig.src.json', configFileErrors: [] } +1s
  typescript-eslint:typescript-estree:useProgramFromProjectService Retrieving script info and then program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +1ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Found project service program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opening project service file for: <REDACTED>/project/packages/project-a/src/views/Home.vue at absolute path <REDACTED>/project/packages/project-a/src/views/Home.vue +1ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opened project service file: { configFileName: '<REDACTED>/project/packages/project-a/tsconfig.src.json', configFileErrors: [] } +1s
  typescript-eslint:typescript-estree:useProgramFromProjectService Retrieving script info and then program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Found project service program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opening project service file for: <REDACTED>/project/packages/project-a/src/views/Home.vue at absolute path <REDACTED>/project/packages/project-a/src/views/Home.vue +1ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opened project service file: { configFileName: '<REDACTED>/project/packages/project-a/tsconfig.src.json', configFileErrors: [] } +1s
  typescript-eslint:typescript-estree:useProgramFromProjectService Retrieving script info and then program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Found project service program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opening project service file for: <REDACTED>/project/packages/project-a/src/views/Home.vue at absolute path <REDACTED>/project/packages/project-a/src/views/Home.vue +1ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opened project service file: { configFileName: '<REDACTED>/project/packages/project-a/tsconfig.src.json', configFileErrors: [] } +1s
  typescript-eslint:typescript-estree:useProgramFromProjectService Retrieving script info and then program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Found project service program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opening project service file for: <REDACTED>/project/packages/project-a/src/views/Home.vue at absolute path <REDACTED>/project/packages/project-a/src/views/Home.vue +1ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opened project service file: { configFileName: '<REDACTED>/project/packages/project-a/tsconfig.src.json', configFileErrors: [] } +1s
  typescript-eslint:typescript-estree:useProgramFromProjectService Retrieving script info and then program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Found project service program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opening project service file for: <REDACTED>/project/packages/project-a/src/views/Home.vue at absolute path <REDACTED>/project/packages/project-a/src/views/Home.vue +1ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Opened project service file: { configFileName: '<REDACTED>/project/packages/project-a/tsconfig.src.json', configFileErrors: [] } +1s
  typescript-eslint:typescript-estree:useProgramFromProjectService Retrieving script info and then program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms
  typescript-eslint:typescript-estree:useProgramFromProjectService Found project service program for: <REDACTED>/project/packages/project-a/src/views/Home.vue +0ms

@JoshuaKGoldberg
Copy link
Member

@JoshuaKGoldberg would you accept a PR to allow the tsserver logging to be enabled? It was really helpful in running down project service related issues.

Absolutely, that'd be great. Yes please! I'd consider it a different issue from this: feel free to file a new issue and/or send a PR. If you do only one, I can do the other. 🙂

@higherorderfunctor
Copy link
Contributor

higherorderfunctor commented Jun 6, 2024

Did some log analysis to see if I could find the inflection point between the original file and a copied file as I am able to reproduce on a large repo. It did not work on a small example repo. The last line is where the difference occurs.

Original File

typescript-eslint:typescript-estree:useProgramFromProjectService Opening project service file for: <REDACTED>/packages/project-a/src/stores/auth.ts at absolute path <REDACTED>/packages/project-a/src/stores/auth.ts
typescript-eslint:tsserver INFO: 'Search path: <REDACTED>/packages/project-a/src/stores'
typescript-eslint:tsserver INFO: 'For info: <REDACTED>/packages/project-a/src/stores/auth.ts :: Config file name: <REDACTED>/packages/project-a/tsconfig.json'
typescript-eslint:tsserver INFO: 'Creating configuration project <REDACTED>/packages/project-a/tsconfig.json'

This log message happens here: https://github.com/microsoft/TypeScript/blob/27bcd4cb5a98bce46c9cdd749752703ead021a4b/src/server/editorServices.ts#L2462

Copied File

typescript-eslint:typescript-estree:useProgramFromProjectService Opening project service file for: <REDACTED>/packages/project-a/src/stores/auth2.ts at absolute path <REDACTED>/packages/project-a/src/stores/auth2.ts
typescript-eslint:tsserver INFO: 'Search path: <REDACTED>/packages/project-a/src/stores'
typescript-eslint:tsserver INFO: 'For info: <REDACTED>/packages/project-a/src/stores/auth2.ts :: Config file name: <REDACTED>/packages/project-a/tsconfig.json'
typescript-eslint:tsserver INFO: 'FileWatcher:: Added:: WatchInfo: <REDACTED>/packages/project-a/src/stores/tsconfig.json 2000 undefined WatchType: Config file for the inferred project root'

That log message is set here when setting up the watch: https://github.com/microsoft/TypeScript/blob/27bcd4cb5a98bce46c9cdd749752703ead021a4b/src/server/editorServices.ts#L2192

I haven't had the time to do a full code level trace or fire up the debugger yet, but at a glance it looks like tsserver treats the new file as an orphan. Since I cannot reproduce on a small repo, it may suggest a race condition.

Wrapping up for the night I took a shot in the dark at changing this line in the plugin to use getScriptInfoEnsuringProjectsUptoDate instead of getScriptInfo:

const scriptInfo = service.getScriptInfo(filePathAbsolute);

It didn't fix the issue, but this week is also my first venture into the tsserver code, so I may not be using it correctly.

To reproduce the logging this is roughly the code I ended up using at

const util = require('util');
const debug = require('debug');
const log = debug('typescript-eslint:tsserver');
function createProjectService(optionsRaw, jsDocParsingMode) {
    const service = new tsserver.server.ProjectService({
        ...
        logger: {
          close: doNothing,
          endGroup: doNothing,
          getLogFileName: () => undefined,
          hasLevel: () => true,
          info: (s) => log(`INFO: %s`, util.inspect(s, false, null, false)),
          loggingEnabled: () => true,
          msg: (s, type) => log(`MSG (%s): %s`, util.inspect(type, false, null, false), util.inspect(s, false, null, false)),
          perftrc: (s) => log(`PERF: %s`, util.inspect(s, false, null, false)),
          startGroup: doNothing,
        },
        ...
    });
    ...
}

I am using neovim but with the vscode eslint LSP. I just started my editor with the DEBUG=* environment variable set to generate the logs which end up in ~/.local/state/nvim/lsp.log. This generates ALOT of data so I wouldn't recommend leaving it on.

@fluidsonic
Copy link

fluidsonic commented Jun 9, 2024

I ran into the same warning but with a different setup.

This rule requires the strictNullChecks compiler option to be turned on to function correctly

I don't use EXPERIMENTAL_useProjectService: true but there may be a common root cause.
If I use --fix I get the warning, if I don't use --fix I don't get that warning.

I did some debugging. It looks like typescript-estree creates two different "programs" for the same file internally, because --fix uses multiple cycles:

/**
* If we are in singleRun mode but the parseAndGenerateServices() function has been called more than once for the current file,
* it must mean that we are in the middle of an ESLint automated fix cycle (in which parsing can be performed up to an additional
* 10 times in order to apply all possible fixes for the file).
*
* In this scenario we cannot rely upon the singleRun AOT compiled programs because the SourceFiles will not contain the source
* with the latest fixes applied. Therefore we fallback to creating the quickest possible isolated program from the updated source.
*/
if (parseSettings.singleRun && options.filePath) {
parseAndGenerateServicesCalls[options.filePath] =
(parseAndGenerateServicesCalls[options.filePath] || 0) + 1;
}
const { ast, program } =
parseSettings.singleRun &&
options.filePath &&
parseAndGenerateServicesCalls[options.filePath] > 1
? createIsolatedProgram(parseSettings)
: getProgramAndAST(parseSettings, hasFullTypeInformation);

The first program is created through getProgramAndAST, the second one using createIsolatedProgram.

The first cycle uses getProgramAndAST which in turn uses createProgramFromConfigFile to load the correct tsconfig.json and pass the options to createProgram:

function createProgramFromConfigFile(
configFile: string,
projectDirectory?: string,
): ts.Program {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (ts.sys === undefined) {
throw new Error(
'`createProgramFromConfigFile` is only supported in a Node-like environment.',
);
}
const parsed = ts.getParsedCommandLineOfConfigFile(
configFile,
CORE_COMPILER_OPTIONS,
{
onUnRecoverableConfigFileDiagnostic: diag => {
throw new Error(formatDiagnostics([diag])); // ensures that `parsed` is defined.
},
fileExists: fs.existsSync,
getCurrentDirectory: () =>
(projectDirectory && path.resolve(projectDirectory)) || process.cwd(),
readDirectory: ts.sys.readDirectory,
readFile: file => fs.readFileSync(file, 'utf-8'),
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
},
);
// parsed is not undefined, since we throw on failure.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = parsed!;
if (result.errors.length) {
throw new Error(formatDiagnostics(result.errors));
}
const host = ts.createCompilerHost(result.options, true);
return ts.createProgram(result.fileNames, result.options, host);
}

The second cycle uses createIsolatedProgram which completely ignores tsconfig.json and uses pre-defined options:

const program = ts.createProgram(
[parseSettings.filePath],
{
jsDocParsingMode: parseSettings.jsDocParsingMode,
noResolve: true,
target: ts.ScriptTarget.Latest,
jsx: parseSettings.jsx ? ts.JsxEmit.Preserve : undefined,
...createDefaultCompilerOptionsFromExtra(parseSettings),
},
compilerHost,
);

That in turn explains the warning because strictNullChecks is indeed false in that case.

Since my problem is caused by the same file being compiled multiple times due to --fix, it may as well be related to this issue where the same file is compiled multiple times.

@higherorderfunctor
Copy link
Contributor

higherorderfunctor commented Jun 10, 2024

To expand upon @fluidsonic's findings, using the file watchers (without actually watching the filesystem) looks like it may be a viable solution.

I created a small proof-of-concept to try and isolate certain behaviors. It will unfortunately require some state management.

I track watches requested by tsserver in a Trie data structure to find the most specific watch. Triggering it after creating a new file fixed the inferred project issue. This means it will use the correct tsconfig.json and not the default compiler options which is why the strictNullChecks issue pops up.

I'm sure more is required to integrate into the plugin efficiently since the plugin is just getting files from eslint without necessarily having access to if the file is new or changed since the last project update.

It doesn't really come with much performance benefits. It was slower on my test single file repo, hanging while waiting for the projectsUpdatedInBackground event, even though the results were available almost a full second earlier in the logs. It was slightly faster (almost by a second) on a large repo.

For --fix I'm still wondering if using something like ScriptInfo.editContent would be faster. That likely involves even more state management though.

Expand src/index.ts...
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-empty-function */
import ts from 'typescript';
import path from 'node:path';
import fs from 'node:fs';

const eventHandler: ts.server.ProjectServiceEventHandler = (event) => {
  console.log(event);
};

const logger: ts.server.Logger = {
  close: () => {},
  endGroup: () => {},
  getLogFileName: (): undefined => undefined,
  hasLevel: () => true,
  info: (s) => {
    console.log('INFO:', s);
  },
  loggingEnabled: () => true,
  msg: (s) => {
    console.log('MSG:', s);
  },
  perftrc: (s) => {
    console.log('PERF:', s);
  },
  startGroup: () => {},
};

class TrieNode<T> {
  children: Map<string, TrieNode<T>>;
  value: T | null;
  constructor(public readonly path: string) {
    this.children = new Map();
    this.value = null;
  }
}

class Trie<T> {
  root: TrieNode<T>;

  constructor() {
    this.root = new TrieNode('');
  }

  insert(path: string) {
    if (!path.startsWith('/')) throw new Error('absolute paths only');
    const parts = path.split('/').slice(1); // drop the first empty string
    const { currentNode } = parts.reduce(
      ({ currentNode, rootPath }, part) => {
        path = `${rootPath}/${part}`;
        if (!currentNode.children.has(part)) {
          currentNode.children.set(part, new TrieNode(path));
        }
        return {
          currentNode: currentNode.children.get(part)!,
          rootPath: path,
        };
      },
      {
        currentNode: this.root,
        rootPath: this.root.path,
      },
    );
    return currentNode;
  }

  get(path: string): TrieNode<T> | null {
    const parts = path.split('/');
    const { lastNodeWithValue } = parts.reduce(
      ({ currentNode, lastNodeWithValue }, part) => {
        if (!currentNode.children.has(part)) {
          return { currentNode: currentNode, lastNodeWithValue };
        }
        const childNode = currentNode.children.get(part)!;
        return {
          currentNode: childNode,
          lastNodeWithValue: childNode.value !== null ? childNode : lastNodeWithValue,
        };
      },
      {
        currentNode: this.root,
        lastNodeWithValue: null as TrieNode<T> | null,
      },
    );
    return lastNodeWithValue;
  }
}

class Watcher implements ts.FileWatcher {
  constructor(
    private readonly node: TrieNode<Watcher>,
    public readonly callback: () => void,
  ) {}

  close() {
    this.node.value = null;
  }
}

const watchers = new Trie<Watcher>();

const stubFileWatcher = (
  path: string,
  callback: ts.FileWatcherCallback,
  pollingInterval?: number,
  options?: ts.WatchOptions,
): ts.FileWatcher => {
  console.log('WATCHFILE:', path, pollingInterval, options);
  const node = watchers.insert(path);
  if (node.value !== null) {
    return node.value;
  }
  const watcher = new Watcher(node, () => {
    callback(path, ts.FileWatcherEventKind.Changed, new Date());
  });
  node.value = watcher;
  return watcher;
};

const stubDirectoryWatcher = (
  path: string,
  callback: ts.DirectoryWatcherCallback,
  recursive?: boolean,
  options?: ts.WatchOptions,
): ts.FileWatcher => {
  console.log('WATCHDIR:', path, recursive, options);
  const node = watchers.insert(path);
  if (node.value !== null) {
    return node.value;
  }
  const watcher = new Watcher(node, () => {
    callback(path);
  });
  node.value = watcher;
  return watcher;
};

const host: ts.server.ServerHost = {
  ...ts.sys,
  clearImmediate,
  clearTimeout,
  setImmediate,
  setTimeout,
  watchFile: stubFileWatcher,
  watchDirectory: stubDirectoryWatcher,
};

const options: ts.server.ProjectServiceOptions = {
  host, // host capabilities
  logger, // logger
  cancellationToken: ts.server.nullCancellationToken, // cancel in flight requests
  useSingleInferredProject: false, // treat files without a tsconfig as separate projects
  useInferredProjectPerProjectRoot: false, // don't lump files without a tsconfig that are in the same directory together
  // typingsInstaller: undefined, // use default
  eventHandler, // get those diags
  canUseWatchEvents: true, // use setup watches
  suppressDiagnosticEvents: false, // we want the diagnostics
  throttleWaitMilliseconds: 0, // 200, // debounce requests
  globalPlugins: [], // no extra global plugins
  pluginProbeLocations: [], // just look for plugins in node_modules
  allowLocalPluginLoads: true, // allow plugins
  // typesMapLocation: undefined, // use default
  serverMode: ts.LanguageServiceMode.Semantic, // full type checking
  session: undefined, // lsp interface, this PoC has eslint as interface so don't use
  jsDocParsingMode: ts.JSDocParsingMode.ParseAll, // parse all jsdoc
};

const projectService = new ts.server.ProjectService(options);

const file = ts.server.toNormalizedPath(`${process.cwd()}/src/index.ts`);

const formatDiagnostic = (diagnostic: ts.Diagnostic) => {
  if (diagnostic.file === undefined) return; // TODO: could be whole project
  if (diagnostic.start === undefined) return; // TODO: could be whole file
  const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
  const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
  // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
  console.log(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`);
};

const printDiagnostic = (file: ts.server.NormalizedPath) => {
  console.log('GETTING DIAGNOSTICS FOR:', file);

  projectService.openClientFile(file, undefined, ts.ScriptKind.TS, process.cwd());

  const scriptInfo = projectService.getScriptInfo(file);

  if (!scriptInfo) {
    console.log('NO SCRIPT INFO');
    process.exit(1);
  }

  const project = projectService.getDefaultProjectForFile(
    scriptInfo.fileName,
    true, // finds project or creates inferred project
  );

  if (project) {
    const languageService = project.getLanguageService();

    const program = languageService.getProgram();

    if (program) {
      const source = program.getSourceFile(file);
      const diagnostics = program.getSemanticDiagnostics(source);
      diagnostics.forEach(formatDiagnostic);
    }
  }
};

printDiagnostic(file);

const destination = ts.server.toNormalizedPath(path.join(path.dirname(file), 'index2.ts'));

fs.copyFileSync(file, destination);

const watcher = watchers.get(destination);
if (watcher !== null && watcher.value !== null) watcher.value.callback();

printDiagnostic(destination);

fs.rmSync(destination);

With this block executed after creating a new file, tsserver finds and associates to the correct project.

const watcher = watchers.get(destination);
if (watcher !== null && watcher.value !== null) watcher.value.callback();
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: <REDACTED>/ts-editor-services-test/tsconfig.json
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index2.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: <REDACTED>/ts-editor-services-test/tsconfig.json
More log output...
INFO: -----------------------------------------------
INFO: Search path: <REDACTED>/ts-editor-services-test
INFO: For info: <REDACTED>/ts-editor-services-test/tsconfig.json :: No config files found.
INFO: Project '<REDACTED>/ts-editor-services-test/tsconfig.json' (Configured)
INFO:   Files (173)

INFO: -----------------------------------------------
INFO: Open files:
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: <REDACTED>/ts-editor-services-test/tsconfig.json
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index2.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: <REDACTED>/ts-editor-services-test/tsconfig.json
INFO: Running: <REDACTED>/ts-editor-services-test/tsconfig.json
INFO: Running: *ensureProjectForOpenFiles*
INFO: Before ensureProjectForOpenFiles:
INFO: Project '<REDACTED>/ts-editor-services-test/tsconfig.json' (Configured)
INFO:   Files (173)

INFO: -----------------------------------------------
INFO: Open files:
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: <REDACTED>/ts-editor-services-test/tsconfig.json
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index2.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: <REDACTED>/ts-editor-services-test/tsconfig.json
INFO: After ensureProjectForOpenFiles:
INFO: Project '<REDACTED>/ts-editor-services-test/tsconfig.json' (Configured)
INFO:   Files (173)

INFO: -----------------------------------------------
INFO: Open files:
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: <REDACTED>/ts-editor-services-test/tsconfig.json
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index2.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: <REDACTED>/ts-editor-services-test/tsconfig.json
{
  eventName: "projectsUpdatedInBackground",
  data: {
    openFiles: [ "<REDACTED>/ts-editor-services-test/src/index.ts",
      "<REDACTED>/ts-editor-services-test/src/index2.ts"
    ],
  },
}

If I comment that section out, the new file is assigned to an inferred project.

INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: <REDACTED>/ts-editor-services-test/tsconfig.json
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index2.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: /dev/null/inferredProject1*
More log output...
INFO: -----------------------------------------------
INFO: Project '<REDACTED>/ts-editor-services-test/tsconfig.json' (Configured)
INFO:   Files (172)

INFO: -----------------------------------------------
INFO: Project '/dev/null/inferredProject1*' (Inferred)
INFO:   Files (178)

INFO: -----------------------------------------------
INFO: Open files:
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: <REDACTED>/ts-editor-services-test/tsconfig.json
INFO:   FileName: <REDACTED>/ts-editor-services-test/src/index2.ts ProjectRootPath: <REDACTED>/ts-editor-services-test
INFO:           Projects: /dev/null/inferredProject1*

@higherorderfunctor
Copy link
Contributor

higherorderfunctor commented Jun 11, 2024

So I have a hobbled together set of what I hope are the start of fixes . One note if anyone clones, the package.jsons were modified to use links for in-repo dependencies so I could add the build outputs to my larger work repository also using links but with pnpm.

First, I track watches set by the ProjectService with a new getWatchesForProjectService.ts. Will need to look through getWatchProgramsForProjects.ts to see if there is room to consolidate.

This is used by createProjectService.ts to call the watch constructors via the host and useProgramFromProjectService.ts to trigger the watches when a file has not been seen by the ProjectService. I trigger the most specific watch which fixed the strictNullChecks for me.

I use getScriptInfo and ScriptInfo.editContent to deal with lint requests for files that are already open (e.g., --fix). Some @stylistic rules crash on the AST for vue files without it. I have some commented out caches for open files and programs that produced similar issues. I was hoping to get away with not needing to re-open the file after a fix. There may be some room for improvements, but it would take some more research.

Also noteworthy is that calling service.setHostConfiguration triggers a full project reload for files with irregular extensions like vue. It should probably be set once with ProjectService during construction instead of on every file. Linting a vue package in my work monorepo went from 3m30s to 20s. The whole monorepo I am testing on went from 16m in CI on server hardware to 3m40s on the mini pc I am currently using which is a much slower machine. I'm excited by those numbers and they produced the same results for both static analysis and running with --fix.

I may have a memory leak as I had to increase the heap. Could also just be linting a large monorepo using only a default project withProjectServices without ever closing a file with tsserver. Again, some more research is needed to cut that memory usage down.

edit: Been looking through the heap. I'm thinking it might be "okay". We use a lot of complex types which is likely inflating the memory usage. The SourceFileObject count is accurate. Most of the memory is taken up by NodeObjects which looks like some low level AST data structure. Should be good enough for an LSP and I'll just have to bump the heap if I want the speed on a full CLI lint. I did put an LRU in to limit the number of opened files. Not really seeing a benefit on the CLI where it just churns through everything quickly.

@higherorderfunctor
Copy link
Contributor

higherorderfunctor commented Jun 13, 2024

If anyone wants to test, I've moved to this branch: https://github.com/higherorderfunctor/typescript-eslint/tree/patches2upstream. There are a lot of modifications, so I'll work to get these up-streamed over several PRs. I've confirmed the strictNullChecks issue is still fixed for the scenario I was able to reproduce. I'm still on eslint@8.57.0 for reference as there is one plugin I use that doesn't work with FlatCompat in eslint@9 (eslint-config-airbnb-base/whitespace).

I've included the types and typescript-estree builds in the repo if your package manager supports installing from git. My package.json looks like below with pnpm@9.1.x. The patch is for #9223 until those fixes make it into eslint-plugin-import-x.

package.json
{
  ...
  "pnpm": {
    "patchedDependencies": {
      "eslint-plugin-import-x@0.5.1": "patches/eslint-plugin-import-x@0.5.1.patch",
    },
    // forces dependencies to use the same version
    "overrides": {
      "typescript": "$typescript",
      "typescript-eslint": "$typescript-eslint",
      "@typescript-eslint/eslint-plugin": "$typescript-eslint",
      "@typescript-eslint/parser": "$typescript-eslint",
      "@typescript-eslint/rule-schema-to-typescript-types": "$typescript-eslint",
      "@typescript-eslint/scope-manager": "$typescript-eslint",
      "@typescript-eslint/type-utils": "$typescript-eslint",
      "@typescript-eslint/utils": "$typescript-eslint",
      "@typescript-eslint/visitor-keys": "$typescript-eslint",
      // my custom fork
      "@typescript-eslint/types": "higherorderfunctor/typescript-eslint#patches2upstream&path:packages/types",
      "@typescript-eslint/typescript-estree": "higherorderfunctor/typescript-eslint#patches2upstream&path:packages/typescript-estree"
    }

  },
  "devDependencies": {
    "eslint": "^8.57.0",
    "typescript": "rc",
    "typescript-eslint": "rc-v8",
    // just for reference on what I have tested against
    "@eslint/eslintrc": "^3.1.0",
    "@eslint/js": "^8.57.0",
    "@stylistic/eslint-plugin": "^2.1.0",
    "@types/eslint": "^8.56.10",
    "@typescript-eslint/utils": "rc-v8", // uses some types in my eslint.config.js for type checking
    "@vue/eslint-config-prettier": "^9.0.0",
    "@vue/eslint-config-typescript": "^13.0.0",
    "eslint-config-airbnb-base": "^15.0.0",
    "eslint-config-airbnb-typescript": "^18.0.0",
    "eslint-config-prettier": "^9.1.0",
    "eslint-import-resolver-alias": "^1.1.2",
    "eslint-import-resolver-typescript": "^3.6.1",
    "eslint-plugin-codegen": "0.28.0",
    "eslint-plugin-deprecation": "^3.0.0",
    "eslint-plugin-import-x": "^0.5.1",
    "eslint-plugin-jsonc": "^2.16.0",
    "eslint-plugin-markdown": "^5.0.0",
    "eslint-plugin-prefer-arrow-functions": "^3.3.2",
    "eslint-plugin-prettier": "^5.1.3",
    "eslint-plugin-promise": "^6.2.0",
    "eslint-plugin-security": "^3.0.0",
    "eslint-plugin-simple-import-sort": "^12.1.0",
    "eslint-plugin-sonar": "^0.14.1",
    "eslint-plugin-sonarjs": "^1.0.3",
    "eslint-plugin-sort-destructure-keys": "^2.0.0",
    "eslint-plugin-tsdoc": "^0.3.0",
    "eslint-plugin-vue": "^9.26.0",
    "eslint-plugin-vuejs-accessibility": "^2.3.0",
    "eslint-plugin-vuetify": "^2.4.0",
    "eslint_d": "^13.1.2",
    "jsonc-eslint-parser": "^2.4.0",
    "vue-eslint-parser": "^9.4.3",
    ...
  },
  ...
}

My RAM utilization is way down today... not sure if it is something I did, something upstream in the rc-v8 branch, or something in the latest typescript@rc, but linting my whole project now is down to ~2min and RAM is slightly over 3G. I had my heap up to 9G a couple days ago or it would crash.

        Elapsed (wall clock) time (h:mm:ss or m:ss): 2:04.05
        Maximum resident set size (kbytes): 3235328

If you do get OOM, the heap can be increased.

NODE_OPTIONS="--max-old-space-size=8184" eslint ...

Known Issues

With the flat config and extraFileExtensions, it is best to set it once if possible. I had different projectService settings between my front-end and back-end. Changing the extraFileExtensions mid-lint causes tsserver to do a full project reload. I have guards in place to do it, but it will cause a performance hit. I originally only set it once and was wondering why I was having issue with .vue files intermittently. Was caused by which projectService happened to be loaded first.

Additional Options

I've added two new options with their defaults below. incremental only sends deltas to tsserver which I am hoping makes the LSP experience better. maximumOpenFiles will automatically close files with tsserver, which is nice on the CLI which has no mechanism to know when it is done with a file. The new options are why the types package is needed if you enable typing on your eslint.config.js.

{
  languageOptions: {
    parserOptions: {
      projectService: {
        incremental: false, // when true, only sends changes to tsserver instead of whole files
        maximumOpenFiles: 16, // auto closes open files with tsserver based on access order
      }
    }
  }
}

@JoshuaKGoldberg JoshuaKGoldberg removed their assignment Jun 13, 2024
@JoshuaKGoldberg JoshuaKGoldberg removed the team assigned A member of the typescript-eslint team should work on this. label Jun 13, 2024
@JoshuaKGoldberg JoshuaKGoldberg added the accepting prs Go ahead, send a pull request that resolves this issue label Jun 13, 2024
@JoshuaKGoldberg
Copy link
Member

Marking as accepting PRs since I'm conferencing much of this month. Caveats:

  • That this is very TypeScript-APIs-heavy stuff. Prepare for deep diving into TS source.
  • This is time sensitive for us, so we might have to take over a PR that's stalled.

@higherorderfunctor
Copy link
Contributor

higherorderfunctor commented Jun 17, 2024

I mentioned this in another thread, but I have a busy week this week in case someone else needs to pick up the PR. Dropping my notes in case anyone else does pick it up.

The working implementation is https://github.com/higherorderfunctor/typescript-eslint/tree/patches2upstream, the draft PR is not currently in a working state.

While I have a working implementation, it duplicates some of the existing logic in getWatchProgramsForProjectsts.ts.

saveWatchCallback looks likely to be a drop in candidate for my implementation.

Exporting or proving an access function for fileWatchCallbackTrackingMap and folderWatchCallbackTrackingMap could allow for the watches to be updated easily. I haven't analyzed the paths tsserver requests the host to watch. I opted to use a trie so I could find the most specific path. getWatchProgramsForProjectsts.ts just uses a Map, so maybe supporting partial matches is not even needed.

hasTSConfigChanged is probably good to consider since changes to tsconfig.json I don't believe will reported to the plugin.

maybeInvalidateProgram which calls the above has quite a bit of logic, but I am not sure all of it is needed for project services. I didn't see a need to do all the caching that is done. Linting results were almost immediate by just walking the service, even on a larger project, and triggering the watcher for the most specific matching path.

const filePathMatchedByConfiguredProject = (
  service: ts.server.ProjectService,
  filePath: string,
): boolean => {
  for (const project of service.configuredProjects.values()) {
    if (project.containsFile(filePath as ts.server.NormalizedPath)) {
      return true;
    }
  }
  return false;
};

@JoshuaKGoldberg JoshuaKGoldberg changed the title Bug: tsconfig.json not respected in new files when multiple tsconfigs include the same directory with useProjectService enabled Bug: tsconfig.json not respected in new files when multiple tsconfigs include the same directory with projectService enabled Jul 21, 2024
@JoshuaKGoldberg
Copy link
Member

👋 @higherorderfunctor now that #9306 is merged (thanks again!), do you think you'll have time to un-draft #9353 in the next few days? For context, we plan on releasing v8 in three days: midday Wednesday EST.

I'm also wondering if this is just not something we can reasonably be tackling internally in typescript-eslint. ESLint's architecture right now doesn't give us any indication as to whether it's single-run or when files are closed in an editor (eslint/rfcs#102). We have existing issues with file changes in editors (see #3536 & its links)...

@higherorderfunctor
Copy link
Contributor

higherorderfunctor commented Jul 28, 2024

@JoshuaKGoldberg I can probably get a minimum viable implementation done by Monday. Basically, is the file in the project service? If no, trigger the best matching watcher. Recheck and if still no add to default project or treat as an isolated file.

This will deal with new source files requiring the LSP to be restarted.

Some of the more complex decision making I don't think I'd be able to have ready by release day. Examples:

  • Handling changes to tsconfig.
  • Handling changes to package.json and/or node_modules to reload dependencies.
  • Handling changes to deleted or moved files. This shouldn't impact runtime behavior, but the indexing of watchers would hold refs to non existent files and directories leaking some memory.
  • Looking for potential optimizations. Basic one I could implement is not indexing or tracking file watchers and only indexing directory watchers. File changes are handled by opening the files and sending "editor" updates and not through watchers. That one is easy, but there may be others I haven't considered.
  • Is this even the best approach long term? Users will most likely be using the tsserver lsp which is doing real watching including tsconfigs and dependencies. Sharing the same project service instance could solve these project structure updates. A lot to unpack with that idea though.

@JoshuaKGoldberg
Copy link
Member

Yeah, these are a lot of open questions. I really really appreciate your offer to hustle but long-term I don't think it's a given that we'd want to try to handle these cases internally (#8835 (comment)). I'm going to go ahead and remove this from the v8 milestone.

Sharing the same project service instance could solve these project structure updates

That's one of the things I'm very hopeful for with the project service!

@JoshuaKGoldberg JoshuaKGoldberg removed this from the 8.0.0 milestone Jul 28, 2024
@JoshuaKGoldberg JoshuaKGoldberg added evaluating community engagement we're looking for community engagement on this issue to show that this problem is widely important and removed accepting prs Go ahead, send a pull request that resolves this issue labels Jul 28, 2024
@leaftail1880
Copy link

leaftail1880 commented Aug 8, 2024

Also experiencing this issue with typescript-eslint@8.0.1 and strictTypeChecked/stylisticTypeChecked configs in monorepo. Each time i create a new file, it errors 'this rule requires strictNullChecks in tsconfig.json' and simply reloading ESLint server using VSCode command helps, but its just annoying.

strict is actually enabled on each config in monorepo

My eslint.config.js:

import eslint from '@eslint/js'
import prettier from 'eslint-config-prettier'
import ts from 'typescript-eslint'

export default ts.config(
  eslint.configs.recommended,
  prettier,
  ...ts.configs.stylisticTypeChecked,
  ...ts.configs.strictTypeChecked,
  {
    languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }
  }
)
Package Version
node v22.1.4
typescript v5.5.4
typescript-eslint v8.0.1
eslint v9.6.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working evaluating community engagement we're looking for community engagement on this issue to show that this problem is widely important
Projects
None yet
6 participants