From 5577541b1d22f78871f510577dc64271fe5dde4f Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Wed, 29 Apr 2026 11:17:15 -0400 Subject: [PATCH 1/2] [APPS] Add @datadog/eslint-plugin-apps with valid-connections-file rule --- .../published/eslint-plugin-apps/README.md | 50 +++++ .../published/eslint-plugin-apps/package.json | 71 +++++++ .../eslint-plugin-apps/rollup.config.mjs | 69 +++++++ .../published/eslint-plugin-apps/src/index.ts | 49 +++++ .../src/rules/valid-connections-file.test.ts | 122 +++++++++++ .../src/rules/valid-connections-file.ts | 193 ++++++++++++++++++ .../eslint-plugin-apps/tsconfig.json | 10 + yarn.lock | 22 +- 8 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 packages/published/eslint-plugin-apps/README.md create mode 100644 packages/published/eslint-plugin-apps/package.json create mode 100644 packages/published/eslint-plugin-apps/rollup.config.mjs create mode 100644 packages/published/eslint-plugin-apps/src/index.ts create mode 100644 packages/published/eslint-plugin-apps/src/rules/valid-connections-file.test.ts create mode 100644 packages/published/eslint-plugin-apps/src/rules/valid-connections-file.ts create mode 100644 packages/published/eslint-plugin-apps/tsconfig.json diff --git a/packages/published/eslint-plugin-apps/README.md b/packages/published/eslint-plugin-apps/README.md new file mode 100644 index 000000000..26bf5639d --- /dev/null +++ b/packages/published/eslint-plugin-apps/README.md @@ -0,0 +1,50 @@ +# `@datadog/eslint-plugin-apps` + +ESLint rules for [Datadog High Code Apps](https://docs.datadoghq.com/service_management/app_builder/). + +## Install + +```bash +npm install --save-dev @datadog/eslint-plugin-apps +``` + +Requires `eslint >= 8.57.0`. + +## Usage — flat config (`eslint.config.js`, ESLint v9) + +```js +import apps from '@datadog/eslint-plugin-apps'; +import tsParser from '@typescript-eslint/parser'; + +export default [ + ...apps.configs.recommended, + { + files: ['connections.{ts,tsx,js,jsx}'], + languageOptions: { parser: tsParser }, + }, +]; +``` + +## Usage — legacy config (`.eslintrc`) + +```json +{ + "extends": ["plugin:@datadog/apps/recommended-legacy"], + "parser": "@typescript-eslint/parser" +} +``` + +## Rules + +### `valid-connections-file` + +Validates the project-root `connections.ts` file the Datadog vite plugin reads at build time. Mirrors the build plugin's structural requirements so authors see violations in their editor instead of as a build error. + +The rule checks that the file: + +- Defines exactly one top-level `export const CONNECTIONS = { … }` +- Uses an object literal for the initializer (not a function call) +- Does not use spread elements or computed keys inside the object +- Uses static string values (string literals or interpolation-free template literals — no env-var lookups, identifiers, concatenation, etc.) + +The rule auto-scopes by filename — it only runs on files whose basename matches `connections.{ts,tsx,js,jsx}`. diff --git a/packages/published/eslint-plugin-apps/package.json b/packages/published/eslint-plugin-apps/package.json new file mode 100644 index 000000000..adbc84a0e --- /dev/null +++ b/packages/published/eslint-plugin-apps/package.json @@ -0,0 +1,71 @@ +{ + "name": "@datadog/eslint-plugin-apps", + "packageManager": "yarn@4.0.2", + "version": "3.1.4", + "license": "MIT", + "author": "Datadog", + "description": "ESLint rules for Datadog High Code Apps", + "keywords": [ + "datadog", + "eslint", + "eslint-plugin", + "apps", + "high-code-apps" + ], + "homepage": "https://github.com/DataDog/build-plugins#readme", + "repository": { + "type": "git", + "url": "https://github.com/DataDog/build-plugins", + "directory": "packages/published/eslint-plugin-apps" + }, + "main": "./dist/src/index.js", + "module": "./dist/src/index.mjs", + "types": "./dist/src/index.d.ts", + "exports": { + "./dist/src": "./dist/src/index.js", + "./dist/src/*": "./dist/src/*", + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "types": "./dist/src/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "buildCmd": "rollup --config rollup.config.mjs", + "build": "yarn clean && yarn buildCmd", + "clean": "rm -rf dist", + "prepack": "yarn build", + "typecheck": "tsc --noEmit", + "watch": "yarn build --watch" + }, + "devDependencies": { + "@dd/tools": "workspace:*", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-node-resolve": "15.3.0", + "@types/eslint": "9.6.1", + "@typescript-eslint/parser": "7.5.0", + "esbuild": "0.25.8", + "eslint": "8.57.0", + "rollup": "4.45.1", + "rollup-plugin-dts": "6.1.1", + "rollup-plugin-esbuild": "6.1.1", + "typescript": "5.4.3" + }, + "peerDependencies": { + "eslint": ">=8.57.0" + }, + "buildPlugin": { + "hideFromRootReadme": true + } +} diff --git a/packages/published/eslint-plugin-apps/rollup.config.mjs b/packages/published/eslint-plugin-apps/rollup.config.mjs new file mode 100644 index 000000000..3197878d8 --- /dev/null +++ b/packages/published/eslint-plugin-apps/rollup.config.mjs @@ -0,0 +1,69 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import json from '@rollup/plugin-json'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import dts from 'rollup-plugin-dts'; +import esbuild from 'rollup-plugin-esbuild'; + +import packageJson from './package.json' with { type: 'json' }; + +// `@dd/tools/rollupConfig.mjs` is bundler-plugin specific (it parses the +// package name expecting a `-plugin` suffix and feeds unplugin assumptions +// into the build), so we hand-roll a minimal config here. + +const external = [ + ...Object.keys(packageJson.peerDependencies ?? {}), + ...Object.keys(packageJson.dependencies ?? {}), + // Externalize ESLint and Node built-ins. + 'eslint', + 'node:path', + 'path', +]; + +const input = 'src/index.ts'; + +export default [ + { + input, + external, + output: { + file: 'dist/src/index.mjs', + format: 'es', + sourcemap: true, + }, + plugins: [ + json(), + nodeResolve({ preferBuiltins: true }), + esbuild({ target: 'node18' }), + ], + }, + { + input, + external, + output: { + file: 'dist/src/index.js', + format: 'cjs', + sourcemap: true, + // Rollup's CJS emit for an `export default` source already produces + // `module.exports = plugin` directly (no `__esModule` wrapping), so + // legacy `.eslintrc` `require()` returns the plugin directly. The + // `footer` collapse trick used with esbuild isn't needed here. + }, + plugins: [ + json(), + nodeResolve({ preferBuiltins: true }), + esbuild({ target: 'node18' }), + ], + }, + { + input, + external, + output: { + file: 'dist/src/index.d.ts', + format: 'es', + }, + plugins: [dts()], + }, +]; diff --git a/packages/published/eslint-plugin-apps/src/index.ts b/packages/published/eslint-plugin-apps/src/index.ts new file mode 100644 index 000000000..09270a9ae --- /dev/null +++ b/packages/published/eslint-plugin-apps/src/index.ts @@ -0,0 +1,49 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { ESLint, Linter, Rule } from 'eslint'; + +import validConnectionsFile from './rules/valid-connections-file'; + +const PLUGIN_NAME = 'apps'; +const PLUGIN_PACKAGE = '@datadog/eslint-plugin-apps'; +const VERSION = '3.1.4'; + +const rules: Record = { + 'valid-connections-file': validConnectionsFile, +}; + +interface AppsPlugin extends ESLint.Plugin { + meta: { name: string; version: string }; + rules: Record; + configs: { + recommended: Linter.Config[]; + 'recommended-legacy': Linter.LegacyConfig; + }; +} + +const plugin: AppsPlugin = { + meta: { name: PLUGIN_PACKAGE, version: VERSION }, + rules, + configs: { + recommended: [], + 'recommended-legacy': { + plugins: ['@datadog/apps'], + rules: { + '@datadog/apps/valid-connections-file': 'error', + }, + }, + }, +}; + +plugin.configs.recommended = [ + { + plugins: { [PLUGIN_NAME]: plugin }, + rules: { + [`${PLUGIN_NAME}/valid-connections-file`]: 'error', + }, + }, +]; + +export default plugin; diff --git a/packages/published/eslint-plugin-apps/src/rules/valid-connections-file.test.ts b/packages/published/eslint-plugin-apps/src/rules/valid-connections-file.test.ts new file mode 100644 index 000000000..92e7c3000 --- /dev/null +++ b/packages/published/eslint-plugin-apps/src/rules/valid-connections-file.test.ts @@ -0,0 +1,122 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { RuleTester } from 'eslint'; + +import rule from './valid-connections-file'; + +// ESLint 8 RuleTester uses the legacy `parser` + `parserOptions` shape (string +// path to the parser module, parserOptions at top level), not the v9 flat +// `languageOptions.parser`. The repo's @types/eslint is v9 so the legacy +// shape isn't typed; cast through `unknown` to keep the tests honest about +// runtime. The published plugin also supports v9 flat config at runtime; this +// test only validates the rule logic against the AST shape @typescript-eslint +// /parser produces. +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +} as unknown as ConstructorParameters[0]); + +const filename = 'connections.ts'; + +ruleTester.run('valid-connections-file', rule, { + valid: [ + { + name: 'CONNECTIONS with string-literal values', + filename, + code: `export const CONNECTIONS = { OPEN_AI: '00000000-0000-0000-0000-000000000000' } as const;`, + }, + { + name: 'template literal without interpolation is allowed', + filename, + code: `export const CONNECTIONS = { A: \`abc\` } as const;`, + }, + { + name: 'string literal key is allowed', + filename, + code: `export const CONNECTIONS = { 'open-ai': 'uuid-1' } as const;`, + }, + { + name: 'out-of-scope file is ignored even when malformed', + filename: 'src/something.ts', + code: `export const OTHER = makeConnections();`, + }, + ], + invalid: [ + { + name: 'missing CONNECTIONS export', + filename, + code: `export const OTHER = { foo: 'bar' } as const;`, + errors: [{ messageId: 'missingExport' }], + }, + { + name: 'lowercase `connections` is not accepted', + filename, + code: `export const connections = { foo: 'a' } as const;`, + errors: [{ messageId: 'missingExport' }], + }, + { + name: 'CONNECTIONS not initialized with object literal', + filename, + code: `export const CONNECTIONS = makeConnections();`, + errors: [{ messageId: 'notObjectLiteral' }], + }, + { + name: 'duplicate CONNECTIONS exports', + filename, + code: `export const CONNECTIONS = { A: 'x' } as const; +export const CONNECTIONS = { B: 'y' } as const;`, + errors: [{ messageId: 'duplicateExport' }], + }, + { + name: 'spread element in CONNECTIONS', + filename, + code: `const other = { B: 'y' }; +export const CONNECTIONS = { ...other, A: 'x' } as const;`, + errors: [{ messageId: 'spreadElement' }], + }, + { + name: 'computed key in CONNECTIONS', + filename, + code: `const k = 'A'; +export const CONNECTIONS = { [k]: 'x' } as const;`, + errors: [{ messageId: 'computedKey' }], + }, + { + name: 'value is an identifier (env-like reference)', + filename, + code: `declare const ENV_ID: string; +export const CONNECTIONS = { A: ENV_ID } as const;`, + errors: [ + { + messageId: 'valueNotStaticString', + data: { key: 'A', valueType: 'Identifier' }, + }, + ], + }, + { + name: 'value is a binary expression', + filename, + code: `export const CONNECTIONS = { A: 'a' + 'b' } as const;`, + errors: [ + { + messageId: 'valueNotStaticString', + data: { key: 'A', valueType: 'BinaryExpression' }, + }, + ], + }, + { + name: 'value is a template literal with interpolation', + filename, + code: `declare const id: string; +export const CONNECTIONS = { A: \`prefix-\${id}\` } as const;`, + errors: [ + { + messageId: 'templateInterpolation', + data: { key: 'A' }, + }, + ], + }, + ], +}); diff --git a/packages/published/eslint-plugin-apps/src/rules/valid-connections-file.ts b/packages/published/eslint-plugin-apps/src/rules/valid-connections-file.ts new file mode 100644 index 000000000..c177b87b3 --- /dev/null +++ b/packages/published/eslint-plugin-apps/src/rules/valid-connections-file.ts @@ -0,0 +1,193 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { Rule } from 'eslint'; +import path from 'node:path'; + +// Local structural aliases. We intentionally do NOT import from 'estree' here: +// the monorepo has multiple copies of @types/estree resolved transitively, and +// pulling the named types causes TypeScript identity mismatches against +// `Rule.Node` (which references its own bundled estree). Structural typing is +// enough — we only branch on `.type` strings. +type EstreeNode = { type: string; [key: string]: unknown }; + +const CONNECTIONS_BASENAME_RE = /^connections\.(ts|tsx|js|jsx)$/; +// Only `CONNECTIONS` (uppercase) is accepted, matching the build plugin's +// extractor at packages/plugins/apps/src/backend/extract-connections.ts. +const CONNECTIONS_EXPORT_NAME = 'CONNECTIONS'; + +type Messages = + | 'missingExport' + | 'notObjectLiteral' + | 'duplicateExport' + | 'spreadElement' + | 'computedKey' + | 'valueNotStaticString' + | 'templateInterpolation'; + +const rule: Rule.RuleModule = { + meta: { + type: 'problem', + docs: { + description: 'Validate the structure of a Datadog Apps `connections.ts` file', + url: 'https://github.com/DataDog/build-plugins/tree/main/packages/published/eslint-plugin-apps#valid-connections-file', + }, + schema: [], + messages: { + missingExport: + 'connections file must define a top-level `export const CONNECTIONS = { … }`.', + notObjectLiteral: + '`export const CONNECTIONS` must be initialized with an object literal.', + duplicateExport: + 'Multiple top-level `export const CONNECTIONS` declarations are not allowed.', + spreadElement: 'Spread elements are not supported inside the connections object.', + computedKey: 'Computed keys are not supported inside the connections object.', + valueNotStaticString: + 'Value for "{{ key }}" must be a string literal; got {{ valueType }}.', + templateInterpolation: + 'Value for "{{ key }}" must be a static string — template literals with interpolations are not allowed.', + } satisfies Record, + }, + + create(context) { + const basename = path.basename(context.filename); + if (!CONNECTIONS_BASENAME_RE.test(basename)) { + return {}; + } + + return { + 'Program:exit'(programNode) { + const matches: EstreeNode[] = []; + + for (const node of programNode.body as EstreeNode[]) { + if (node.type !== 'ExportNamedDeclaration' || !node.declaration) { + continue; + } + const decl = node.declaration as EstreeNode; + if (decl.type !== 'VariableDeclaration') { + continue; + } + for (const declarator of decl.declarations as EstreeNode[]) { + const id = declarator.id as EstreeNode; + if (id.type === 'Identifier' && id.name === CONNECTIONS_EXPORT_NAME) { + matches.push(declarator); + } + } + } + + if (matches.length === 0) { + context.report({ + node: programNode, + messageId: 'missingExport', + }); + return; + } + + if (matches.length > 1) { + for (let i = 1; i < matches.length; i += 1) { + context.report({ + node: matches[i] as unknown as Rule.Node, + messageId: 'duplicateExport', + }); + } + } + + const declarator = matches[0]; + const declaratorInit = declarator.init as EstreeNode | null | undefined; + const init = unwrapTsAssertion(declaratorInit); + if (!init || init.type !== 'ObjectExpression') { + context.report({ + node: (declaratorInit ?? declarator) as unknown as Rule.Node, + messageId: 'notObjectLiteral', + }); + return; + } + + checkObject(init, context); + }, + }; + }, +}; + +function checkObject(obj: EstreeNode, context: Rule.RuleContext): void { + for (const property of obj.properties as EstreeNode[]) { + if (property.type === 'SpreadElement') { + context.report({ + node: property as unknown as Rule.Node, + messageId: 'spreadElement', + }); + continue; + } + + if (property.computed) { + context.report({ + node: property as unknown as Rule.Node, + messageId: 'computedKey', + }); + continue; + } + + const key = readKeyName(property); + const propertyValue = property.value as EstreeNode; + const value = unwrapTsAssertion(propertyValue); + + if (value && value.type === 'Literal' && typeof value.value === 'string') { + continue; + } + + if (value && value.type === 'TemplateLiteral') { + const expressions = value.expressions as unknown[]; + if (expressions.length === 0) { + continue; + } + context.report({ + node: value as unknown as Rule.Node, + messageId: 'templateInterpolation', + data: { key }, + }); + continue; + } + + const reported = value ?? propertyValue; + context.report({ + node: reported as unknown as Rule.Node, + messageId: 'valueNotStaticString', + data: { key, valueType: reported.type }, + }); + } +} + +function readKeyName(property: EstreeNode): string { + const key = property.key as EstreeNode; + if (key.type === 'Identifier') { + return String(key.name); + } + if (key.type === 'Literal') { + return String(key.value); + } + return ''; +} + +/** + * Unwrap TypeScript-specific assertion wrappers (`as const`, `as Foo`, + * `x`, `x!`) so we can validate the underlying expression. ESLint with + * @typescript-eslint/parser preserves these as TSAsExpression / TSTypeAssertion + * /TSSatisfiesExpression /TSNonNullExpression nodes; pure estree ASTs never + * see them. + */ +function unwrapTsAssertion(node: EstreeNode | null | undefined): EstreeNode | null | undefined { + let current = node; + while ( + current && + (current.type === 'TSAsExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSNonNullExpression') + ) { + current = current.expression as EstreeNode | null | undefined; + } + return current; +} + +export default rule; diff --git a/packages/published/eslint-plugin-apps/tsconfig.json b/packages/published/eslint-plugin-apps/tsconfig.json new file mode 100644 index 000000000..32b367efa --- /dev/null +++ b/packages/published/eslint-plugin-apps/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./", + "outDir": "./dist" + }, + "include": ["**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/yarn.lock b/yarn.lock index 329b10d2e..2d5e4b358 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1729,6 +1729,26 @@ __metadata: languageName: unknown linkType: soft +"@datadog/eslint-plugin-apps@workspace:packages/published/eslint-plugin-apps": + version: 0.0.0-use.local + resolution: "@datadog/eslint-plugin-apps@workspace:packages/published/eslint-plugin-apps" + dependencies: + "@dd/tools": "workspace:*" + "@rollup/plugin-json": "npm:6.1.0" + "@rollup/plugin-node-resolve": "npm:15.3.0" + "@types/eslint": "npm:9.6.1" + "@typescript-eslint/parser": "npm:7.5.0" + esbuild: "npm:0.25.8" + eslint: "npm:8.57.0" + rollup: "npm:4.45.1" + rollup-plugin-dts: "npm:6.1.1" + rollup-plugin-esbuild: "npm:6.1.1" + typescript: "npm:5.4.3" + peerDependencies: + eslint: ">=8.57.0" + languageName: unknown + linkType: soft + "@datadog/js-instrumentation-wasm@npm:1.0.8": version: 1.0.8 resolution: "@datadog/js-instrumentation-wasm@npm:1.0.8" @@ -4018,7 +4038,7 @@ __metadata: languageName: node linkType: hard -"@types/eslint@npm:*": +"@types/eslint@npm:*, @types/eslint@npm:9.6.1": version: 9.6.1 resolution: "@types/eslint@npm:9.6.1" dependencies: From 0e68960f27bf883b13401e579765de0be8e64613 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Wed, 29 Apr 2026 12:02:47 -0400 Subject: [PATCH 2/2] Use named estree types and declare @types/estree as a direct dep --- .../published/eslint-plugin-apps/package.json | 1 + .../src/rules/valid-connections-file.ts | 95 +++++++++++-------- yarn.lock | 1 + 3 files changed, 57 insertions(+), 40 deletions(-) diff --git a/packages/published/eslint-plugin-apps/package.json b/packages/published/eslint-plugin-apps/package.json index adbc84a0e..44c40ab27 100644 --- a/packages/published/eslint-plugin-apps/package.json +++ b/packages/published/eslint-plugin-apps/package.json @@ -54,6 +54,7 @@ "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "15.3.0", "@types/eslint": "9.6.1", + "@types/estree": "1.0.8", "@typescript-eslint/parser": "7.5.0", "esbuild": "0.25.8", "eslint": "8.57.0", diff --git a/packages/published/eslint-plugin-apps/src/rules/valid-connections-file.ts b/packages/published/eslint-plugin-apps/src/rules/valid-connections-file.ts index c177b87b3..ac4326c76 100644 --- a/packages/published/eslint-plugin-apps/src/rules/valid-connections-file.ts +++ b/packages/published/eslint-plugin-apps/src/rules/valid-connections-file.ts @@ -3,15 +3,17 @@ // Copyright 2019-Present Datadog, Inc. import type { Rule } from 'eslint'; +import type { + Expression, + ObjectExpression, + Pattern, + Program, + Property, + SpreadElement, + VariableDeclarator, +} from 'estree'; import path from 'node:path'; -// Local structural aliases. We intentionally do NOT import from 'estree' here: -// the monorepo has multiple copies of @types/estree resolved transitively, and -// pulling the named types causes TypeScript identity mismatches against -// `Rule.Node` (which references its own bundled estree). Structural typing is -// enough — we only branch on `.type` strings. -type EstreeNode = { type: string; [key: string]: unknown }; - const CONNECTIONS_BASENAME_RE = /^connections\.(ts|tsx|js|jsx)$/; // Only `CONNECTIONS` (uppercase) is accepted, matching the build plugin's // extractor at packages/plugins/apps/src/backend/extract-connections.ts. @@ -26,6 +28,14 @@ type Messages = | 'valueNotStaticString' | 'templateInterpolation'; +// `@types/eslint` ships a nested copy of `@types/estree` whose ArrayExpression +// /Pattern/etc. types are structurally identical but a different TS module +// identity from the top-level `@types/estree` we depend on. We use named +// estree types internally for traversal (real type safety) and cast through +// `unknown` to `Rule.Node` only at `context.report` boundaries, where the two +// type universes meet. +const asRuleNode = (node: { type: string }): Rule.Node => node as unknown as Rule.Node; + const rule: Rule.RuleModule = { meta: { type: 'problem', @@ -58,19 +68,21 @@ const rule: Rule.RuleModule = { return { 'Program:exit'(programNode) { - const matches: EstreeNode[] = []; + const program = programNode as unknown as Program; + const matches: VariableDeclarator[] = []; - for (const node of programNode.body as EstreeNode[]) { + for (const node of program.body) { if (node.type !== 'ExportNamedDeclaration' || !node.declaration) { continue; } - const decl = node.declaration as EstreeNode; - if (decl.type !== 'VariableDeclaration') { + if (node.declaration.type !== 'VariableDeclaration') { continue; } - for (const declarator of decl.declarations as EstreeNode[]) { - const id = declarator.id as EstreeNode; - if (id.type === 'Identifier' && id.name === CONNECTIONS_EXPORT_NAME) { + for (const declarator of node.declaration.declarations) { + if ( + declarator.id.type === 'Identifier' && + declarator.id.name === CONNECTIONS_EXPORT_NAME + ) { matches.push(declarator); } } @@ -87,18 +99,17 @@ const rule: Rule.RuleModule = { if (matches.length > 1) { for (let i = 1; i < matches.length; i += 1) { context.report({ - node: matches[i] as unknown as Rule.Node, + node: asRuleNode(matches[i]), messageId: 'duplicateExport', }); } } const declarator = matches[0]; - const declaratorInit = declarator.init as EstreeNode | null | undefined; - const init = unwrapTsAssertion(declaratorInit); + const init = unwrapTsAssertion(declarator.init); if (!init || init.type !== 'ObjectExpression') { context.report({ - node: (declaratorInit ?? declarator) as unknown as Rule.Node, + node: asRuleNode(declarator.init ?? declarator), messageId: 'notObjectLiteral', }); return; @@ -110,11 +121,11 @@ const rule: Rule.RuleModule = { }, }; -function checkObject(obj: EstreeNode, context: Rule.RuleContext): void { - for (const property of obj.properties as EstreeNode[]) { +function checkObject(obj: ObjectExpression, context: Rule.RuleContext): void { + for (const property of obj.properties) { if (property.type === 'SpreadElement') { context.report({ - node: property as unknown as Rule.Node, + node: asRuleNode(property), messageId: 'spreadElement', }); continue; @@ -122,49 +133,48 @@ function checkObject(obj: EstreeNode, context: Rule.RuleContext): void { if (property.computed) { context.report({ - node: property as unknown as Rule.Node, + node: asRuleNode(property), messageId: 'computedKey', }); continue; } const key = readKeyName(property); - const propertyValue = property.value as EstreeNode; - const value = unwrapTsAssertion(propertyValue); + // Property values may be wrapped in TypeScript-specific assertion nodes + // when parsed by @typescript-eslint/parser; unwrap before validating. + const value = unwrapTsAssertion(property.value as Expression); if (value && value.type === 'Literal' && typeof value.value === 'string') { continue; } if (value && value.type === 'TemplateLiteral') { - const expressions = value.expressions as unknown[]; - if (expressions.length === 0) { + if (value.expressions.length === 0) { continue; } context.report({ - node: value as unknown as Rule.Node, + node: asRuleNode(value), messageId: 'templateInterpolation', data: { key }, }); continue; } - const reported = value ?? propertyValue; + const reported = (value ?? property.value) as Expression; context.report({ - node: reported as unknown as Rule.Node, + node: asRuleNode(reported), messageId: 'valueNotStaticString', data: { key, valueType: reported.type }, }); } } -function readKeyName(property: EstreeNode): string { - const key = property.key as EstreeNode; - if (key.type === 'Identifier') { - return String(key.name); +function readKeyName(property: Property): string { + if (property.key.type === 'Identifier') { + return property.key.name; } - if (key.type === 'Literal') { - return String(key.value); + if (property.key.type === 'Literal') { + return String(property.key.value); } return ''; } @@ -174,10 +184,15 @@ function readKeyName(property: EstreeNode): string { * `x`, `x!`) so we can validate the underlying expression. ESLint with * @typescript-eslint/parser preserves these as TSAsExpression / TSTypeAssertion * /TSSatisfiesExpression /TSNonNullExpression nodes; pure estree ASTs never - * see them. + * see them and `@types/estree` doesn't model them, so we work with a structural + * shape here and cast back at the call sites. */ -function unwrapTsAssertion(node: EstreeNode | null | undefined): EstreeNode | null | undefined { - let current = node; +type EstreeNodeLike = { type: string; expression?: unknown }; + +function unwrapTsAssertion( + node: T, +): T { + let current = node as EstreeNodeLike | null | undefined; while ( current && (current.type === 'TSAsExpression' || @@ -185,9 +200,9 @@ function unwrapTsAssertion(node: EstreeNode | null | undefined): EstreeNode | nu current.type === 'TSSatisfiesExpression' || current.type === 'TSNonNullExpression') ) { - current = current.expression as EstreeNode | null | undefined; + current = current.expression as EstreeNodeLike | null | undefined; } - return current; + return current as T; } export default rule; diff --git a/yarn.lock b/yarn.lock index 2d5e4b358..0cf69622e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1737,6 +1737,7 @@ __metadata: "@rollup/plugin-json": "npm:6.1.0" "@rollup/plugin-node-resolve": "npm:15.3.0" "@types/eslint": "npm:9.6.1" + "@types/estree": "npm:1.0.8" "@typescript-eslint/parser": "npm:7.5.0" esbuild: "npm:0.25.8" eslint: "npm:8.57.0"