Merge changes Iaac671d4,If4390c22

* changes:
  eslint: synthesize node_modules for Bazel lint_test
  Revert "Suppress no-unnecessary-type-assertion for intentional assertion"
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index afd4856..e1762c4 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -495,7 +495,6 @@
     if (!this.groupMembers) return;
 
     const el = e.target as GrButton;
-    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
     const index = Number(el.getAttribute('data-index')!);
     const keys = this.groupMembers[index];
     const item =
@@ -547,7 +546,6 @@
     if (!this.includedGroups) return;
 
     const el = e.target as GrButton;
-    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
     const index = Number(el.getAttribute('data-index')!);
     const keys = this.includedGroups[index];
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 208b4da..31a107a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -1616,7 +1616,6 @@
   }
 
   override updated() {
-    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
     const tabs = [...queryAll<HTMLElement>(this.tabs!, 'md-secondary-tab')];
     const tabIndex = tabs.findIndex(t => t.dataset['name'] === this.activeTab);
 
diff --git a/polygerrit-ui/app/elements/chat-panel/chat-panel_test.ts b/polygerrit-ui/app/elements/chat-panel/chat-panel_test.ts
index 777ce3c..84a2052 100644
--- a/polygerrit-ui/app/elements/chat-panel/chat-panel_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/chat-panel_test.ts
@@ -128,7 +128,6 @@
     const policy = element.shadowRoot!.querySelector('.ai-policy');
     assert.isOk(policy);
     assert.include(
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       policy.textContent!,
       'Review agent may display inaccurate info'
     );
diff --git a/polygerrit-ui/app/elements/chat-panel/citations-box_test.ts b/polygerrit-ui/app/elements/chat-panel/citations-box_test.ts
index 74e0a4c..09b1192 100644
--- a/polygerrit-ui/app/elements/chat-panel/citations-box_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/citations-box_test.ts
@@ -179,7 +179,6 @@
       '.citations-summary-message'
     );
     assert.isOk(summary);
-    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
     assert.include(summary.textContent!, '2 citations');
 
     const items = element.shadowRoot?.querySelectorAll('.citation-item');
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index df027c9..6e3d7cc 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -132,7 +132,6 @@
     assert.isFalse(element.isExpanded);
 
     const summaryDiv: HTMLElement =
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       element.shadowRoot!.querySelector('.summary')!;
     summaryDiv.click();
     await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
index eb7d77b..5f5e1775 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -235,7 +235,6 @@
 
   private showKey(e: Event) {
     const el = e.target as GrButton;
-    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
     const index = Number(el.getAttribute('data-index')!);
     this.keyToView = this.keys[index];
     this.viewKeyModal.showModal();
@@ -243,7 +242,6 @@
 
   private handleDeleteKey(e: Event) {
     const el = e.target as GrButton;
-    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
     const index = Number(el.getAttribute('data-index')!);
     this.keysToRemove.push(this.keys[index]);
     this.keys.splice(index, 1);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 0d101ab..40c1774 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -471,7 +471,6 @@
       await element.updateComplete;
       const chips = element.accountChips;
       const chipsOneSpy = sinon.spy(chips[1], 'focus');
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       pressKey(input.input!, 'ArrowLeft');
       assert.isTrue(chipsOneSpy.called);
       const chipsZeroSpy = sinon.spy(chips[0], 'focus');
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index b577f0d..3230e48 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -204,7 +204,6 @@
       const stub = sinon.stub(element.cursor, 'next');
       assertIsDefined(element.dropdown);
       assert.isFalse(element.dropdown.open);
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       pressKey(element!.shadowRoot!.querySelector('#trigger')!, 'ArrowDown');
       await element.updateComplete;
       assert.isTrue(element.dropdown.open);
@@ -220,7 +219,6 @@
       assertIsDefined(element.dropdown);
       const stub = sinon.stub(element.cursor, 'previous');
       assert.isFalse(element.dropdown.open);
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       pressKey(element!.shadowRoot!.querySelector('#trigger')!, 'ArrowUp');
       await element.updateComplete;
       assert.isTrue(element.dropdown.open);
@@ -237,7 +235,6 @@
       // Because enter and space are handled by the same fn, we need only to
       // test one.
       assert.isFalse(element.dropdown.open);
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       pressKey(element!.shadowRoot!.querySelector('#trigger')!, ' ');
       await element.updateComplete;
       assert.isTrue(element.dropdown.open);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
index a4ea783..3108166 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -274,7 +274,6 @@
 
       await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
 
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       pressKey(autocomplete.input!, Key.ESC);
 
       await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
@@ -286,11 +285,9 @@
       await element.open();
       await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
       // Press esc to close suggestions.
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       pressKey(autocomplete.input!, Key.ESC);
       await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
 
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       pressKey(autocomplete.input!, Key.ESC);
 
       await element.updateComplete;
@@ -309,7 +306,6 @@
       await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
       await autocomplete.latestSuggestionUpdateComplete;
 
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       pressKey(autocomplete.input!, Key.ENTER);
 
       await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
@@ -331,12 +327,10 @@
       await autocomplete.latestSuggestionUpdateComplete;
 
       // Press enter to close suggestions.
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       pressKey(autocomplete.input!, Key.ENTER);
 
       await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
 
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
       pressKey(autocomplete.input!, Key.ENTER);
 
       await element.updateComplete;
diff --git a/polygerrit-ui/app/eslint-bazel.config.js b/polygerrit-ui/app/eslint-bazel.config.js
index ad7690e..9724124 100644
--- a/polygerrit-ui/app/eslint-bazel.config.js
+++ b/polygerrit-ui/app/eslint-bazel.config.js
@@ -4,13 +4,21 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-// This file has a special settings for bazel.
-// The settings is required because bazel uses different location
-// for node_modules.
+// Bazel-specific ESLint wrapper.
+//
+// This file is intentionally thin:
+// - `eslint.config.js` remains the source of truth for rules
+// - this file only adapts module resolution for Bazel runfiles
+//
+// In Bazel test mode, npm dependencies are located under runfiles
+// directories such as ui_dev_npm/node_modules. Extend the Node resolver
+// so that ESLint can locate those packages.
 
 const {defineConfig, globalIgnores} = require('eslint/config');
 const js = require('@eslint/js');
 const {FlatCompat} = require('@eslint/eslintrc');
+const path = require('path');
+const fs = require('fs');
 
 const compat = new FlatCompat({
   baseDirectory: __dirname,
@@ -18,21 +26,50 @@
   allConfig: js.configs.all,
 });
 
-function getBazelSettings() {
-  const runFilesDir = process.env['RUNFILES_DIR'];
-  if (!runFilesDir) {
-    // eslint is executed with 'bazel run ...' to fix the source code. It runs
-    // against real source code, no special paths for node_modules is set.
-    return {};
+function pathExists(p) {
+  try {
+    return fs.existsSync(p);
+  // eslint-disable-next-line no-unused-vars
+  } catch (unusedError) {
+    return false;
   }
-  // eslint is executed with 'bazel test...'. Set path to required node_modules
+}
+
+function getRunfilesRoot() {
+  return process.env.RUNFILES_DIR || process.env.TEST_SRCDIR || '';
+}
+
+function getNodeModulesPaths() {
+  const runfilesRoot = getRunfilesRoot();
+
+  if (runfilesRoot) {
+    // Bazel test mode: collect node_modules from runfiles
+    return [
+      path.join(runfilesRoot, 'ui_dev_npm/node_modules'),
+      path.join(runfilesRoot, 'ui_npm/node_modules'),
+      path.join(runfilesRoot, '_main/external/ui_dev_npm/node_modules'),
+      path.join(runfilesRoot, '_main/external/ui_npm/node_modules'),
+      path.join(runfilesRoot, '_main/polygerrit-ui/app/node_modules'),
+    ].filter(pathExists);
+  }
+
+  // Workspace mode
+  return [
+    path.join(__dirname, 'node_modules'),
+    path.join(__dirname, '../../node_modules'),
+    path.join(process.cwd(), 'node_modules'),
+    path.join(process.cwd(), '../../node_modules'),
+  ].filter(pathExists);
+}
+
+function getBazelSettings() {
+  const paths = getNodeModulesPaths();
+  if (paths.length === 0) return {};
+
   return {
     'import/resolver': {
       node: {
-        paths: [
-          `${runFilesDir}/ui_npm/node_modules`,
-          `${runFilesDir}/ui_dev_npm/node_modules`,
-        ],
+        paths,
       },
     },
   };
diff --git a/tools/js/eslint-chdir.js b/tools/js/eslint-chdir.js
index 5aea704..d9fd479 100644
--- a/tools/js/eslint-chdir.js
+++ b/tools/js/eslint-chdir.js
@@ -15,16 +15,118 @@
  * limitations under the License.
  */
 
-// Eslint 7 introduced a breaking change - it uses the current workdir instead
-// of the configuration file directory for resolving relative paths:
-// https://eslint.org/docs/user-guide/migrating-to-7.0.0#base-path-change
-// This file is loaded before the eslint and sets the current directory
-// back to the location of configuration file.
+// ESLint resolves relative paths from the current working directory.
+//
+// In workspace mode (`lint_bin`), this works with the regular Node.js module
+// layout under `polygerrit-ui/app/node_modules`.
+//
+// In Bazel test mode (`lint_test`), npm dependencies are exposed through
+// multiple runfiles trees instead. Typed linting with TypeScript does not see
+// the same module and type environment from that layout as from the workspace
+// layout.
+//
+// To align `lint_test` with `lint_bin`, synthesize a local
+// `polygerrit-ui/app/node_modules` by symlinking entries from the runfiles
+// npm trees, then prepend it to NODE_PATH so resolution matches workspace mode.
 
+const fs = require('fs');
+const Module = require('module');
 const path = require('path');
-const configParamIndex =
-    process.argv.findIndex(arg => arg === '-c' || arg === '---config');
-if (configParamIndex >= 0 && configParamIndex + 1 < process.argv.length) {
-  const dirName = path.dirname(process.argv[configParamIndex + 1]);
-  process.chdir(dirName);
+
+function pathExists(filePath) {
+  try {
+    return fs.existsSync(filePath);
+  } catch {
+    return false;
+  }
 }
+
+function readDirEntries(dirPath) {
+  try {
+    return fs.readdirSync(dirPath, {withFileTypes: true});
+  } catch {
+    return [];
+  }
+}
+
+function ensureDir(dirPath) {
+  fs.mkdirSync(dirPath, {recursive: true});
+}
+
+function symlinkIfMissing(targetPath, linkPath) {
+  if (pathExists(linkPath)) return;
+
+  try {
+    fs.symlinkSync(targetPath, linkPath);
+  } catch {
+    // Ignore races and pre-existing entries.
+  }
+}
+
+function mergeNodeModules(destinationDir, sourceDirs) {
+  ensureDir(destinationDir);
+
+  for (const sourceDir of sourceDirs) {
+    for (const entry of readDirEntries(sourceDir)) {
+      const sourceEntry = path.join(sourceDir, entry.name);
+      const destEntry = path.join(destinationDir, entry.name);
+
+      if (entry.name.startsWith('@') && entry.isDirectory()) {
+        ensureDir(destEntry);
+
+        for (const scopedEntry of readDirEntries(sourceEntry)) {
+          symlinkIfMissing(
+            path.join(sourceEntry, scopedEntry.name),
+            path.join(destEntry, scopedEntry.name)
+          );
+        }
+        continue;
+      }
+
+      symlinkIfMissing(sourceEntry, destEntry);
+    }
+  }
+}
+
+function getRunfilesRoot() {
+  return process.env.RUNFILES_DIR || process.env.TEST_SRCDIR || '';
+}
+
+function getRunfilesNodeModules(runfilesRoot) {
+  return [
+    path.join(runfilesRoot, 'ui_dev_npm/node_modules'),
+    path.join(runfilesRoot, 'ui_npm/node_modules'),
+    path.join(runfilesRoot, '_main/node_modules'),
+  ].filter(pathExists);
+}
+
+function prependNodePath(paths) {
+  const existing = process.env.NODE_PATH
+    ? process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
+    : [];
+
+  process.env.NODE_PATH = [...paths, ...existing].join(path.delimiter);
+  Module._initPaths();
+}
+
+function getConfigDirFromArgv(argv) {
+  const configArgIndex = argv.findIndex(arg => arg === '-c' || arg === '--config');
+  if (configArgIndex < 0 || configArgIndex + 1 >= argv.length) return '';
+
+  return path.dirname(argv[configArgIndex + 1]);
+}
+
+const configDir = getConfigDirFromArgv(process.argv);
+if (!configDir) return;
+
+process.chdir(configDir);
+
+const runfilesRoot = getRunfilesRoot();
+if (!runfilesRoot) return;
+
+const runfilesNodeModules = getRunfilesNodeModules(runfilesRoot);
+if (runfilesNodeModules.length === 0) return;
+
+const localNodeModules = path.join(process.cwd(), 'node_modules');
+mergeNodeModules(localNodeModules, runfilesNodeModules);
+prependNodePath([localNodeModules, ...runfilesNodeModules]);