diff --git a/.changeset/portable-oxlint-safety.md b/.changeset/portable-oxlint-safety.md new file mode 100644 index 0000000..df1d933 --- /dev/null +++ b/.changeset/portable-oxlint-safety.md @@ -0,0 +1,5 @@ +--- +"@stll/oxlint-plugin": minor +--- + +Publish portable Oxlint rules for unsafe casts, incomplete union-keyed records, and unsanitized DOM HTML sinks. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d81bbdd..34da154 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,20 +24,24 @@ jobs: - name: Enforce release intent # The repository owner publishes this versioned shared contract. - uses: stella/.github/.github/actions/changeset-policy@c47bb87cc82047b5fdc2540983148d4e3efa9a37 # v1.3.0 + uses: stella/.github/.github/actions/changeset-policy@5dafee16c28009f17d3bc088f8aecdf9ce47a6d5 with: release-paths: | packages/typescript-config/** packages/oxlint-config/** + packages/oxlint-plugin/** generated-paths: | bun.lock packages/typescript-config/CHANGELOG.md packages/typescript-config/package.json packages/oxlint-config/CHANGELOG.md packages/oxlint-config/package.json + packages/oxlint-plugin/CHANGELOG.md + packages/oxlint-plugin/package.json package-files: | packages/typescript-config/package.json packages/oxlint-config/package.json + packages/oxlint-plugin/package.json version-file: bun.lock checks: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d073733..79f5d20 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,7 @@ on: # accidentally start a release transaction. - packages/typescript-config/CHANGELOG.md - packages/oxlint-config/CHANGELOG.md + - packages/oxlint-plugin/CHANGELOG.md workflow_dispatch: inputs: publish_to_npm: @@ -33,7 +34,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - package: [typescript-config, oxlint-config] + package: [typescript-config, oxlint-config, oxlint-plugin] permissions: contents: read steps: @@ -60,8 +61,8 @@ jobs: shell: bash run: | set -euo pipefail - if [[ "$PACKAGE" == "oxlint-config" ]]; then - bun --filter @stll/oxlint-config build + if [[ "$PACKAGE" == "oxlint-config" || "$PACKAGE" == "oxlint-plugin" ]]; then + bun --filter "@stll/$PACKAGE" build fi mkdir -p release-artifacts ( @@ -87,9 +88,10 @@ jobs: contents: write id-token: write # Keep this caller filename stable: npm trusted publishing authorizes it. - uses: stella/.github/.github/workflows/npm-independent-release.yml@c47bb87cc82047b5fdc2540983148d4e3efa9a37 # v1.3.0 + uses: stella/.github/.github/workflows/npm-independent-release.yml@b8c0884053fb8d4fb6ba157692189e0f1aae1785 # v1.6.0 with: artifact-pattern: npm-tarball-* package-files: | packages/typescript-config/package.json packages/oxlint-config/package.json + packages/oxlint-plugin/package.json diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index c5f1e72..a2c015c 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -5,8 +5,10 @@ on: branches: [main] concurrency: + # Let the active mutation finish; the shared workflow skips obsolete queued + # source revisions before it receives write credentials. group: release-pr-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: {} @@ -16,7 +18,7 @@ jobs: permissions: contents: read # The repository owner publishes this versioned shared contract. - uses: stella/.github/.github/workflows/changeset-release-pr.yml@c47bb87cc82047b5fdc2540983148d4e3efa9a37 # v1.3.0 + uses: stella/.github/.github/workflows/changeset-release-pr.yml@5dafee16c28009f17d3bc088f8aecdf9ce47a6d5 with: bun-version-file: package.json secrets: diff --git a/README.md b/README.md index 772b1bc..0c14cea 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,21 @@ This repo intentionally contains only portable tooling policy: - `@stll/typescript-config`: strict TypeScript config presets. - `@stll/oxlint-config`: general upstream oxlint rules and the shared `stella-lowercase` and `no-raw-colors` JS plugins. +- `@stll/oxlint-plugin`: portable static-safety rules for unsafe type + assertions, incomplete union-keyed records, and raw DOM HTML sinks. - `rust/`: source-of-truth Rust formatting, lint, and Cargo profile templates. - `rust-lints/`: Dylint libraries for stella-specific Rust rules. -Repo-specific stella rules stay in the consuming repo: custom oxlint plugins, -security rules, i18n rules, generated native artifacts, benchmark exceptions, -and package-specific ignores. +Repo-specific stella rules stay in the consuming repo: domain authorization, +i18n, generated native artifacts, benchmark exceptions, and package-specific +ignores. ## Usage Install the shared TypeScript and oxlint packages: ```bash -bun add -d @stll/typescript-config @stll/oxlint-config oxlint oxlint-tsgolint typescript +bun add -d @stll/typescript-config @stll/oxlint-config @stll/oxlint-plugin @oxlint/plugins oxlint oxlint-tsgolint typescript ``` The shared defaults require TypeScript 7.0.2 or newer, oxlint 1.75.0 or @@ -66,6 +68,25 @@ export default library({ }); ``` +Add the portable safety rules to an existing Oxlint config: + +```ts +import { defineConfig } from "oxlint"; +import { + portableSafetyPluginSpecifiers, + portableSafetyRules, +} from "@stll/oxlint-plugin"; + +export default defineConfig({ + jsPlugins: [...portableSafetyPluginSpecifiers], + rules: { ...portableSafetyRules }, +}); +``` + +When using `no-unsafe-inner-html`, disable the blanket `react/no-danger` rule; +the portable rule permits static and provably sanitized HTML while rejecting +untrusted values. + `react/react-compiler` is part of the default rule set and requires **oxlint >= 1.70**. It is a nursery rule upstream, so its diagnostics may change between oxlint minor versions; re-audit findings after bumping the diff --git a/bun.lock b/bun.lock index 8e9a8c6..35b5e1c 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@stll/oxlint-config": "workspace:*", + "@stll/oxlint-plugin": "workspace:*", "bun-types": "1.3.14", "oxfmt": "0.48.0", "oxlint": "1.75.0", @@ -31,6 +32,20 @@ "oxlint-tsgolint": ">=7.0.2001 <8", }, }, + "packages/oxlint-plugin": { + "name": "@stll/oxlint-plugin", + "version": "0.0.0", + "devDependencies": { + "@oxlint/plugins": "1.75.0", + "@stll/typescript-config": "workspace:*", + "oxfmt": "0.48.0", + "typescript": "7.0.2", + }, + "peerDependencies": { + "@oxlint/plugins": ">=1.75.0 <2", + "oxlint": ">=1.75.0 <2", + }, + }, "packages/typescript-config": { "name": "@stll/typescript-config", "version": "0.2.0", @@ -39,6 +54,9 @@ }, }, }, + "overrides": { + "js-yaml": "3.15.1", + }, "packages": { "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], @@ -190,6 +208,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.75.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA=="], + "@oxlint/plugins": ["@oxlint/plugins@1.75.0", "", {}, "sha512-dNQBRuvkeecm9nxi1cXRxXA1oAyqJqHof6cdnjY/WDBU1nZ9V07rp9X9gG7+JgShrjJW4VR2dTo7t2EcX4XD8g=="], + "@publint/pack": ["@publint/pack@0.1.4", "", {}, "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -228,6 +248,8 @@ "@stll/oxlint-config": ["@stll/oxlint-config@workspace:packages/oxlint-config"], + "@stll/oxlint-plugin": ["@stll/oxlint-plugin@workspace:packages/oxlint-plugin"], + "@stll/typescript-config": ["@stll/typescript-config@workspace:packages/typescript-config"], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], @@ -326,7 +348,7 @@ "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], @@ -404,7 +426,7 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], @@ -550,10 +572,6 @@ "publint/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - "read-yaml-file/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], - "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - - "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], } } diff --git a/oxlint.config.ts b/oxlint.config.ts index 513905c..a01c344 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,5 +1,31 @@ import { library } from "./packages/oxlint-config/src/index"; +import { + portableSafetyPluginSpecifiers, + portableSafetyRules, +} from "./packages/oxlint-plugin/src/index"; export default library({ ignorePatterns: ["packages/oxlint-config/dist/"], + jsPlugins: [...portableSafetyPluginSpecifiers], + overrides: [ + { + // Oxlint presents plugin AST nodes as intentionally untyped values. + // Type-aware any-flow and condition rules cannot add safety inside the + // visitor implementation; the passive fixtures prove rule behavior. + files: ["packages/oxlint-plugin/src/**/*.ts"], + rules: { + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-return": "off", + "typescript/no-unsafe-argument": "off", + "typescript/strict-boolean-expressions": "off", + "typescript/no-unnecessary-condition": "off", + }, + }, + { + files: ["packages/oxlint-plugin/fixtures/**/*.{ts,tsx}"], + rules: portableSafetyRules, + }, + ], }); diff --git a/package.json b/package.json index 424f6eb..f951c73 100644 --- a/package.json +++ b/package.json @@ -7,18 +7,18 @@ "packages/*" ], "scripts": { - "build": "bun --filter @stll/oxlint-config build", + "build": "bun --filter @stll/oxlint-config build && bun --filter @stll/oxlint-plugin build", "changeset": "changeset", "changeset:version": "changeset version && bun scripts/check-lockfile-workspace-versions.ts --write && bun install --frozen-lockfile", "check:lockfile-versions": "bun scripts/check-lockfile-workspace-versions.ts", - "typecheck": "bun --filter @stll/oxlint-config typecheck", + "typecheck": "bun --filter @stll/oxlint-config typecheck && bun --filter @stll/oxlint-plugin typecheck", "test": "bun test", "test:rust-lints": "cd rust-lints/stella_lints && cargo test", "lint": "bun run build && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --deny-warnings --type-aware .", "lint:fix": "bun --bun oxlint -c oxlint.config.ts --type-aware --fix .", "format": "oxfmt . \"!.ai/**\" \"!.agents/**\" \"!.claude/**\" \"!AGENTS.md\" \"!CLAUDE.md\" \"!GEMINI.md\"", - "publint": "publint --pack npm packages/typescript-config && publint --pack npm packages/oxlint-config", - "pack:dry-run": "bun run build && (cd packages/typescript-config && npm pack --dry-run) && (cd packages/oxlint-config && npm pack --dry-run)", + "publint": "publint --pack npm packages/typescript-config && publint --pack npm packages/oxlint-config && publint --pack npm packages/oxlint-plugin", + "pack:dry-run": "bun run build && (cd packages/typescript-config && npm pack --dry-run) && (cd packages/oxlint-config && npm pack --dry-run) && (cd packages/oxlint-plugin && npm pack --dry-run)", "prepack": "bun run build && bun run typecheck", "sync-ai": "bash scripts/sync-ai-skills.sh", "sync-ai:check": "bash scripts/sync-ai-skills.sh --check" @@ -27,6 +27,7 @@ "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@stll/oxlint-config": "workspace:*", + "@stll/oxlint-plugin": "workspace:*", "bun-types": "1.3.14", "oxfmt": "0.48.0", "oxlint": "1.75.0", @@ -35,5 +36,8 @@ "tsdown": "0.22.9", "typescript": "7.0.2" }, + "overrides": { + "js-yaml": "3.15.1" + }, "packageManager": "bun@1.3.14" } diff --git a/packages/oxlint-plugin/fixtures/no-any-casts.fixture.ts b/packages/oxlint-plugin/fixtures/no-any-casts.fixture.ts new file mode 100644 index 0000000..12545ff --- /dev/null +++ b/packages/oxlint-plugin/fixtures/no-any-casts.fixture.ts @@ -0,0 +1,17 @@ +// Passive regression fixture for `no-any-casts/no-any-casts`. + +declare const value: unknown; + +// MUST flag: `as any` bypasses every structural guarantee. +// oxlint-disable-next-line no-any-casts/no-any-casts, typescript/no-explicit-any -- fixture: TypeScript as-any casts must be rejected +export const asAny = value as any; + +// MUST flag: angle-bracket any assertions are the same escape hatch. +// oxlint-disable-next-line no-any-casts/no-any-casts, typescript/no-explicit-any -- fixture: legacy any assertions must be rejected +export const angleAny = value; + +// Allowed: const assertions preserve literal information without using any. +export const literalTuple = ["workspace", "document"] as const; + +// Allowed: satisfies checks a value without laundering its inferred type. +export const checkedTuple = ["workspace"] satisfies readonly string[]; diff --git a/packages/oxlint-plugin/fixtures/no-dangerous-type-assertions.fixture.ts b/packages/oxlint-plugin/fixtures/no-dangerous-type-assertions.fixture.ts new file mode 100644 index 0000000..94ef346 --- /dev/null +++ b/packages/oxlint-plugin/fixtures/no-dangerous-type-assertions.fixture.ts @@ -0,0 +1,20 @@ +// Passive regression fixture for +// `no-dangerous-type-assertions/no-dangerous-type-assertions`. + +type Payload = { + required: string; +}; + +// MUST flag: an object-literal cast can conceal a missing required field. +// oxlint-disable-next-line no-dangerous-type-assertions/no-dangerous-type-assertions -- fixture: object literals must be checked instead of cast +export const hiddenMissingField = {} as Payload; + +// MUST flag: angle-bracket assertions conceal the same invalid state. +// oxlint-disable-next-line no-dangerous-type-assertions/no-dangerous-type-assertions -- fixture: legacy object-literal casts must be rejected +export const legacyHiddenMissingField = {}; + +// Allowed: satisfies checks every required property without widening. +export const checkedPayload = { required: "present" } satisfies Payload; + +// Allowed: `as const` controls literal widening and is explicitly exempt. +export const immutablePayload = { required: "present" } as const; diff --git a/packages/oxlint-plugin/fixtures/no-partial-record-satisfies.fixture.ts b/packages/oxlint-plugin/fixtures/no-partial-record-satisfies.fixture.ts new file mode 100644 index 0000000..7302a1b --- /dev/null +++ b/packages/oxlint-plugin/fixtures/no-partial-record-satisfies.fixture.ts @@ -0,0 +1,70 @@ +// Passive regression fixture for +// `no-partial-record-satisfies/no-partial-record-satisfies`. +// +// Each `oxlint-disable-next-line` below intentionally suppresses a case the +// rule MUST flag. If the rule regresses (e.g. someone drops the `Partial` +// unwrap or the `Readonly` peeling), the matching disable becomes unused and +// `--report-unused-disable-directives-severity=error` fails CI. + +type Union = "a" | "b" | "c"; + +// `as const satisfies Partial>` — an easy source of +// classification-table drift. +// oxlint-disable-next-line no-partial-record-satisfies/no-partial-record-satisfies +const asConstPartial = { + a: 1, +} as const satisfies Partial>; + +// Non-`as const` `satisfies Partial>` on an object literal. +// oxlint-disable-next-line no-partial-record-satisfies/no-partial-record-satisfies +const bareSatisfiesPartial = { a: 1 } satisfies Partial>; + +// `Readonly>>` — outer `Readonly` wrapper. +// oxlint-disable-next-line no-partial-record-satisfies/no-partial-record-satisfies +const readonlyOuter = { + a: 1, +} as const satisfies Readonly>>; + +// `Partial>>` — inner `Readonly` wrapper. +// oxlint-disable-next-line no-partial-record-satisfies/no-partial-record-satisfies +const readonlyInner = { + a: 1, +} as const satisfies Partial>>; + +// --- Cases the rule MUST NOT flag --- + +// Total `Record`, no `Partial` — the required fix. +const totalRecord = { + a: 1, + b: 2, + c: 3, +} as const satisfies Record; + +// Type-annotation position, not `satisfies` — a function parameter. +const acceptsOverrides = (overrides: Partial>) => + overrides; + +// Type-annotation position — a variable/accumulator annotation. +const accumulator: Partial> = { a: 1 }; + +// Type-annotation position — a type alias for genuinely sparse data. +type SparseByUnion = Partial>; + +// `satisfies Partial>` on a non-object-literal expression. +const fromHelper = (): Partial> => ({ a: 1 }); +const notAnObjectLiteral = fromHelper() satisfies Partial< + Record +>; + +export const __noPartialRecordSatisfiesFixture = { + asConstPartial, + bareSatisfiesPartial, + readonlyOuter, + readonlyInner, + totalRecord, + acceptsOverrides, + accumulator, + notAnObjectLiteral, +}; + +export type { SparseByUnion }; diff --git a/packages/oxlint-plugin/fixtures/no-unsafe-inner-html.fixture.tsx b/packages/oxlint-plugin/fixtures/no-unsafe-inner-html.fixture.tsx new file mode 100644 index 0000000..9acccc0 --- /dev/null +++ b/packages/oxlint-plugin/fixtures/no-unsafe-inner-html.fixture.tsx @@ -0,0 +1,102 @@ +// Passive regression fixture for +// `no-unsafe-inner-html/no-unsafe-inner-html`. +// +// Each `oxlint-disable-next-line` below intentionally suppresses a case the +// rule MUST flag. If the rule regresses, the matching disable becomes unused +// and `--report-unused-disable-directives-severity=error` fails CI. + +const rawHtml = ""; + +const escapeHtml = (value: string): string => + value.replaceAll("&", "&").replaceAll("<", "<"); + +const sanitizeHtml = (value: string): string => escapeHtml(value); + +const escapeRegex = (value: string): string => + value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const sanitizeFilename = (value: string): string => value.replaceAll("/", "_"); + +export const UnsafeDangerouslySetInnerHtml = () => ( + // oxlint-disable-next-line no-unsafe-inner-html/no-unsafe-inner-html +
+); + +export const UnsafeInnerHtmlAssignment = () => { + const element = document.createElement("div"); + // oxlint-disable-next-line no-unsafe-inner-html/no-unsafe-inner-html + element.innerHTML = rawHtml; + return element; +}; + +export const UnsafeTemplateInterpolation = () => { + const element = document.createElement("div"); + // oxlint-disable-next-line no-unsafe-inner-html/no-unsafe-inner-html + element.innerHTML = `${rawHtml}`; + return element; +}; + +export const UnsafeRegexEscaper = () => { + const element = document.createElement("div"); + // oxlint-disable-next-line no-unsafe-inner-html/no-unsafe-inner-html + element.innerHTML = escapeRegex(rawHtml); + return element; +}; + +export const UnsafeFilenameSanitizer = () => ( +
+); + +const hoistedPayload = { __html: rawHtml }; + +export const UnsafeHoistedPayload = () => ( + // oxlint-disable-next-line no-unsafe-inner-html/no-unsafe-inner-html +
+); + +const spreadPayload = { __html: rawHtml }; + +export const UnsafeSpreadPayload = () => ( +
+); + +// --- Cases the rule MUST NOT flag --- + +export const SafeSanitizedCall = () => ( +
+); + +export const SafeAnnotatedSource = () => ( +
+); + +export const SafeTemplateInterpolation = () => { + const element = document.createElement("div"); + element.innerHTML = `${escapeHtml(rawHtml)}`; + return element; +}; + +export const safeStaticMarkup = () => { + const element = document.createElement("div"); + element.innerHTML = "Static"; + return element; +}; + +// A plain object with a `__html` property is data, not a DOM sink until it is +// passed to `dangerouslySetInnerHTML`. +export const htmlPayload = { __html: rawHtml }; diff --git a/packages/oxlint-plugin/package.json b/packages/oxlint-plugin/package.json new file mode 100644 index 0000000..595da13 --- /dev/null +++ b/packages/oxlint-plugin/package.json @@ -0,0 +1,95 @@ +{ + "name": "@stll/oxlint-plugin", + "version": "0.0.0", + "description": "Portable static-safety rules for Oxlint consumers.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/stella/tooling.git", + "directory": "packages/oxlint-plugin" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "dist/index.mjs", + "types": "dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "default": "./dist/index.mjs" + }, + "./no-any-casts": { + "import": { + "types": "./dist/no-any-casts.d.mts", + "default": "./dist/no-any-casts.mjs" + }, + "require": { + "types": "./dist/no-any-casts.d.cts", + "default": "./dist/no-any-casts.cjs" + }, + "default": "./dist/no-any-casts.mjs" + }, + "./no-dangerous-type-assertions": { + "import": { + "types": "./dist/no-dangerous-type-assertions.d.mts", + "default": "./dist/no-dangerous-type-assertions.mjs" + }, + "require": { + "types": "./dist/no-dangerous-type-assertions.d.cts", + "default": "./dist/no-dangerous-type-assertions.cjs" + }, + "default": "./dist/no-dangerous-type-assertions.mjs" + }, + "./no-partial-record-satisfies": { + "import": { + "types": "./dist/no-partial-record-satisfies.d.mts", + "default": "./dist/no-partial-record-satisfies.mjs" + }, + "require": { + "types": "./dist/no-partial-record-satisfies.d.cts", + "default": "./dist/no-partial-record-satisfies.cjs" + }, + "default": "./dist/no-partial-record-satisfies.mjs" + }, + "./no-unsafe-inner-html": { + "import": { + "types": "./dist/no-unsafe-inner-html.d.mts", + "default": "./dist/no-unsafe-inner-html.mjs" + }, + "require": { + "types": "./dist/no-unsafe-inner-html.d.cts", + "default": "./dist/no-unsafe-inner-html.cjs" + }, + "default": "./dist/no-unsafe-inner-html.mjs" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit", + "format": "oxfmt ." + }, + "devDependencies": { + "@oxlint/plugins": "1.75.0", + "@stll/typescript-config": "workspace:*", + "oxfmt": "0.48.0", + "typescript": "7.0.2" + }, + "peerDependencies": { + "@oxlint/plugins": ">=1.75.0 <2", + "oxlint": ">=1.75.0 <2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } +} diff --git a/packages/oxlint-plugin/src/index.ts b/packages/oxlint-plugin/src/index.ts new file mode 100644 index 0000000..75fcdb6 --- /dev/null +++ b/packages/oxlint-plugin/src/index.ts @@ -0,0 +1,26 @@ +import type { OxlintConfig } from "oxlint"; + +type JsPlugins = NonNullable; +type Rules = NonNullable; + +export const noAnyCastsPluginSpecifier = "@stll/oxlint-plugin/no-any-casts"; +export const noDangerousTypeAssertionsPluginSpecifier = + "@stll/oxlint-plugin/no-dangerous-type-assertions"; +export const noPartialRecordSatisfiesPluginSpecifier = + "@stll/oxlint-plugin/no-partial-record-satisfies"; +export const noUnsafeInnerHtmlPluginSpecifier = + "@stll/oxlint-plugin/no-unsafe-inner-html"; + +export const portableSafetyPluginSpecifiers = [ + noAnyCastsPluginSpecifier, + noDangerousTypeAssertionsPluginSpecifier, + noPartialRecordSatisfiesPluginSpecifier, + noUnsafeInnerHtmlPluginSpecifier, +] satisfies JsPlugins; + +export const portableSafetyRules = { + "no-any-casts/no-any-casts": "error", + "no-dangerous-type-assertions/no-dangerous-type-assertions": "error", + "no-partial-record-satisfies/no-partial-record-satisfies": "error", + "no-unsafe-inner-html/no-unsafe-inner-html": "error", +} satisfies Rules; diff --git a/packages/oxlint-plugin/src/no-any-casts.ts b/packages/oxlint-plugin/src/no-any-casts.ts new file mode 100644 index 0000000..c34d325 --- /dev/null +++ b/packages/oxlint-plugin/src/no-any-casts.ts @@ -0,0 +1,38 @@ +// Disallow `as any` and `` casts. +// +// Distinct from `no-explicit-any` (which targets `: any` annotations +// in declarations). This rule catches the cast form — typically +// `value as any` or the laundering `value as any as Target` — which +// bypasses brand checks, discriminated-union narrowing, and every +// other safety the type system provides. + +import { eslintCompatPlugin } from "@oxlint/plugins"; + +export default eslintCompatPlugin({ + meta: { name: "no-any-casts" }, + rules: { + "no-any-casts": { + meta: { + type: "problem", + messages: { + noAnyCast: + "Avoid `as any` casts. They bypass branded IDs and " + + "discriminated-union narrowing. Narrow with a type guard, " + + "use `unknown` + a guard, or refactor the source so the " + + "cast isn't needed.", + }, + }, + createOnce(context) { + function check(node) { + if (node.typeAnnotation?.type === "TSAnyKeyword") { + context.report({ node, messageId: "noAnyCast" }); + } + } + return { + TSAsExpression: check, + TSTypeAssertion: check, + }; + }, + }, + }, +}); diff --git a/packages/oxlint-plugin/src/no-dangerous-type-assertions.ts b/packages/oxlint-plugin/src/no-dangerous-type-assertions.ts new file mode 100644 index 0000000..6a72b16 --- /dev/null +++ b/packages/oxlint-plugin/src/no-dangerous-type-assertions.ts @@ -0,0 +1,73 @@ +// Disallow object-literal casts: `{...} as T`, `{...}`. +// +// Casting an object literal to a type silently hides missing required +// fields — the type system would otherwise catch the omission. Use a +// typed binding (`const x: T = { ... }`) or `satisfies T` so missing +// fields surface as type errors. +// +// Allows `as const` (legitimate widening control). +// Allows `as any` / `as unknown` (those have dedicated rules / are +// often used as the first step of an explicit launder). + +import { eslintCompatPlugin } from "@oxlint/plugins"; + +export default eslintCompatPlugin({ + meta: { name: "no-dangerous-type-assertions" }, + rules: { + "no-dangerous-type-assertions": { + meta: { + type: "problem", + messages: { + noObjectLiteralCast: + "Don't cast an object literal with `as`. Type the binding " + + "(`const x: T = { ... }`) or use `satisfies T` so missing " + + "required fields fail typecheck.", + }, + }, + createOnce(context) { + function unwrapExpression(expression) { + let current = expression; + while ( + current?.type === "ParenthesizedExpression" || + current?.type === "TSAsExpression" || + current?.type === "TSTypeAssertion" || + current?.type === "TSNonNullExpression" || + current?.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + return current; + } + + function check(node) { + const expression = unwrapExpression(node.expression); + if (expression?.type !== "ObjectExpression") { + return; + } + const ann = node.typeAnnotation; + if (!ann) { + return; + } + // Allow `as const` + if ( + ann.type === "TSTypeReference" && + ann.typeName?.type === "Identifier" && + ann.typeName.name === "const" + ) { + return; + } + // `as any` and `as unknown` have their own rules / are + // typically the laundering step, not the final shape claim. + if (ann.type === "TSAnyKeyword" || ann.type === "TSUnknownKeyword") { + return; + } + context.report({ node, messageId: "noObjectLiteralCast" }); + } + return { + TSAsExpression: check, + TSTypeAssertion: check, + }; + }, + }, + }, +}); diff --git a/packages/oxlint-plugin/src/no-partial-record-satisfies.ts b/packages/oxlint-plugin/src/no-partial-record-satisfies.ts new file mode 100644 index 0000000..7df188f --- /dev/null +++ b/packages/oxlint-plugin/src/no-partial-record-satisfies.ts @@ -0,0 +1,124 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; +// Ban ` satisfies Partial>` (with or +// without a leading `as const`). +// +// A const object literal pinned with `satisfies` against a `Record` keyed by +// a union declares itself the classification of every union member — +// `satisfies` fails typecheck the moment a new member is added and left +// unhandled. Wrapping that `Record` in `Partial` cancels the guarantee: a +// missing member typechecks silently, so the literal can drift out of sync +// with the union with no compiler signal. That silent-drift surface can cause +// a classification-table bug when a newly added member falls through a +// `Partial>` lookup instead of failing typecheck. +// +// `Readonly<...>` wrapping either side (`Readonly>>` or +// `Partial>>`) is unwrapped before the check, since it +// changes mutability, not totality. +// +// Only the `satisfies` expression on an object literal is flagged. Plain +// type-annotation positions (`const x: Partial> = {...}`), +// function parameters, and variable/return type annotations are exempt: +// those are legitimate uses (override inputs, accumulators, genuinely sparse +// data) and carry no compiler guarantee of totality to lose. +// +// Flagged: +// const x = { +// a: 1, +// } as const satisfies Partial>; +// const y = { a: 1 } satisfies Partial>; +// const z = { +// a: 1, +// } as const satisfies Readonly>>; +// +// Allowed: +// const x = { +// a: 1, +// } as const satisfies Record; +// const fn = (overrides: Partial>) => overrides; +// let acc: Partial> = {}; +// type Sparse = Partial>; +// +// Fix by making the record total over the union — an explicit "none" / +// `false` / `null` value for members where nothing applies forces a new +// union member to be classified before it typechecks. If only a subset of +// the union is ever relevant, derive a narrower union for the keys instead +// of falling back to `Partial`. For genuinely sparse data where absence has +// documented meaning (e.g. an unbounded key space), add an eslint-disable +// with a comment naming what absence means. + +import { isIdentifier } from "./utils.ts"; + +const unwrapReadonlyType = (typeNode) => { + if ( + typeNode?.type === "TSTypeReference" && + isIdentifier(typeNode.typeName, "Readonly") + ) { + return typeNode.typeArguments?.params?.at(0) ?? null; + } + return typeNode; +}; + +// Matches `Partial>`, unwrapping a `Readonly<...>` wrapper on +// either the outer (`Readonly>>`) or inner +// (`Partial>>`) type. +const isPartialRecordType = (typeNode) => { + const outer = unwrapReadonlyType(typeNode); + if ( + outer?.type !== "TSTypeReference" || + !isIdentifier(outer.typeName, "Partial") + ) { + return false; + } + const inner = unwrapReadonlyType(outer.typeArguments?.params?.at(0) ?? null); + return ( + inner?.type === "TSTypeReference" && isIdentifier(inner.typeName, "Record") + ); +}; + +// Matches an object literal, or an object literal wrapped in `as const`. +const isObjectLiteralExpression = (node) => { + if (node?.type === "ObjectExpression") { + return true; + } + if ( + node?.type === "TSAsExpression" && + node.typeAnnotation?.type === "TSTypeReference" && + isIdentifier(node.typeAnnotation.typeName, "const") + ) { + return isObjectLiteralExpression(node.expression); + } + return false; +}; + +export default eslintCompatPlugin({ + meta: { name: "no-partial-record-satisfies" }, + rules: { + "no-partial-record-satisfies": { + meta: { + type: "problem", + messages: { + noPartialRecordSatisfies: + "Don't pin an object literal with `satisfies Partial>` " + + "— Partial cancels the totality check satisfies would otherwise " + + "give you, so a union member can silently fall through. Make the " + + "record total (explicit 'none'/false/null per member), narrow the " + + "key union, or add an eslint-disable naming what absence means " + + "for genuinely sparse data.", + }, + }, + createOnce(context) { + return { + TSSatisfiesExpression(node) { + if (!isPartialRecordType(node.typeAnnotation)) { + return; + } + if (!isObjectLiteralExpression(node.expression)) { + return; + } + context.report({ node, messageId: "noPartialRecordSatisfies" }); + }, + }; + }, + }, + }, +}); diff --git a/packages/oxlint-plugin/src/no-unsafe-inner-html.ts b/packages/oxlint-plugin/src/no-unsafe-inner-html.ts new file mode 100644 index 0000000..cb53269 --- /dev/null +++ b/packages/oxlint-plugin/src/no-unsafe-inner-html.ts @@ -0,0 +1,282 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; +// Forbid injecting un-proven HTML into the DOM. +// +// Raw HTML may only reach the DOM from a value that is provably sanitized or +// escaped. This precise guard can replace a blanket `react/no-danger` rule: +// approved sanitizer calls remain usable while unescaped stored or user input +// cannot flow into `__html` or `el.innerHTML` unnoticed. +// +// Two sinks share one allowlist: +// • JSX `__html` property of a `dangerouslySetInnerHTML` object literal +// (report on the value expression). +// • `AssignmentExpression` whose LHS is a non-computed `.innerHTML` +// MemberExpression (report on the RHS). +// +// A value is allowed when it is: +// • a string Literal or TemplateLiteral whose interpolations are all +// independently proven safe (static markup, no raw injection), OR +// • a CallExpression whose callee is an HTML/DOM sanitizer or escaper +// (`escapeHtml`, `escapeXml`, `sanitizeHtml`, `sanitizeDom`, +// `DOMPurify.sanitize`), including a `.value` / `.data` / `.html`-style +// member read OFF such a call (some sanitizers return `{ value }`), OR +// • carries an explicit `// safe-html:` escape-hatch comment on the line +// directly above the sink (loc adjacency, like suppression-hygiene.ts). +// +// A value is NOT auto-allowed just because the identifier is named +// `html` / `content` / `headline` / `body` — those are the sinks, not +// proof of safety. Prove safety at the source or annotate with a reason. +// +// Flagged: +//
+//
// unless annotated +// el.innerHTML = html; // unless annotated +// el.innerHTML = data.body; +// +// Allowed: +//
+//
${escapeXml(n)}` }} /> +//
+// el.innerHTML = ""; +// el.innerHTML = " "; +// // safe-html: escaped by escapeAndHighlight() +//
+ +import { + getCalleeName, + getPropertyName, + isIdentifier, + unwrapExpression, +} from "./utils.ts"; + +// Callee names that prove the value was run through an HTML/DOM sanitizer or +// escaper. Keep this narrow: `escapeRegex()` and `sanitizeFilename()` are not +// HTML-safe. +const HTML_SANITIZER_LEAF_RE = + /^(?:escape(?:html|xml)|sanitize(?:html|dom)|purify(?:html|dom)|dompurify)$/iu; + +const DOM_PURIFY_RE = /^dompurify$/iu; + +// Members that some sanitizers expose on their result object +// (`sanitizeHtml(x).value`, `sanitizeHtml(x).html`). Reading one of these OFF a +// sanitizer call is still proven-safe. +const SANITIZER_RESULT_MEMBERS = new Set(["value", "data", "html"]); + +const ESCAPE_HATCH_RE = /^\s*safe-html:/u; + +const isComment = (value) => + typeof value === "object" && + value !== null && + typeof value.value === "string" && + typeof value.loc === "object" && + value.loc !== null; + +const isJsxIdentifier = (node, name) => + typeof node === "object" && + node !== null && + node.type === "JSXIdentifier" && + node.name === name; + +const isHtmlSanitizerCalleeName = (dotted) => { + const parts = dotted.split("."); + const leaf = parts.at(-1); + if (leaf === undefined) { + return false; + } + if (HTML_SANITIZER_LEAF_RE.test(leaf)) { + return true; + } + const objectName = parts.at(-2); + return leaf === "sanitize" && DOM_PURIFY_RE.test(objectName ?? ""); +}; + +const isSanitizerCall = (node) => { + if (node.type !== "CallExpression") { + return false; + } + const dotted = getCalleeName(node.callee); + return dotted !== null && isHtmlSanitizerCalleeName(dotted); +}; + +// A value is proven safe by its own shape (independent of any comment): +// static string, or the result of a sanitizer call (directly, or via a +// `.value`/`.data`/`.html` accessor off the call). +const isProvenSafeValue = (node) => { + if (!node || typeof node.type !== "string") { + return false; + } + if (node.type === "Literal" && typeof node.value === "string") { + return true; + } + if (node.type === "TemplateLiteral") { + return ( + Array.isArray(node.expressions) && + node.expressions.every(isProvenSafeValue) + ); + } + if (isSanitizerCall(node)) { + return true; + } + // `sanitize(x).value`, `purify(x).html` — a result accessor off a call. + if ( + node.type === "MemberExpression" && + node.computed === false && + isProvenSafeMemberLeaf(node) + ) { + return true; + } + return false; +}; + +const isProvenSafeMemberLeaf = (node) => { + const propertyName = getPropertyName(node.property); + if (propertyName === null || !SANITIZER_RESULT_MEMBERS.has(propertyName)) { + return false; + } + return isSanitizerCall(node.object); +}; + +const isDangerouslySetInnerHtmlValue = (property) => { + if (getPropertyName(property.key) !== "__html") { + return false; + } + const objectExpression = property.parent; + if (objectExpression?.type !== "ObjectExpression") { + return false; + } + + let current = objectExpression.parent; + while ( + current?.type === "TSAsExpression" || + current?.type === "TSSatisfiesExpression" + ) { + current = current.parent; + } + + if (current?.type !== "JSXExpressionContainer") { + return false; + } + const attribute = current.parent; + return ( + attribute?.type === "JSXAttribute" && + isJsxIdentifier(attribute.name, "dangerouslySetInnerHTML") + ); +}; + +export default eslintCompatPlugin({ + meta: { name: "no-unsafe-inner-html" }, + rules: { + "no-unsafe-inner-html": { + meta: { + type: "problem", + messages: { + unsafeInnerHtml: + "Raw HTML injected into the DOM must come from a provably " + + "sanitized/escaped value: a static string, an HTML/DOM " + + "sanitize/escape call (or its .value/.data/.html result). " + + "Wrap the value in a sanitizer, or add a `// safe-html: ` comment on the line directly above " + + "if it is escaped at its source.", + unsafeInnerHtmlSpread: + "Do not spread an object into dangerouslySetInnerHTML. Keep " + + "`__html` inline so this rule can prove the HTML value is " + + "sanitized or escaped.", + }, + }, + createOnce(context) { + const escapeHatchLines = new Set(); + + const recordEscapeHatches = (node) => { + const comments = + node && Array.isArray(node.comments) + ? node.comments.filter(isComment) + : []; + for (const comment of comments) { + if (ESCAPE_HATCH_RE.test(comment.value)) { + escapeHatchLines.add(comment.loc.end.line); + } + } + }; + + // The escape-hatch comment sits on the line directly above the + // reported node's first line. Sink expressions inside JSX object + // literals span multiple lines, so anchor on the node's start line. + const hasEscapeHatchAbove = (node) => + escapeHatchLines.has(node.loc.start.line - 1); + + const reportIfUnsafe = (node) => { + if (isProvenSafeValue(node)) { + return; + } + if (hasEscapeHatchAbove(node)) { + return; + } + context.report({ node, messageId: "unsafeInnerHtml" }); + }; + + const reportPayloadSpreads = (objectNode) => { + for (const property of objectNode.properties) { + if (property?.type !== "SpreadElement") { + continue; + } + context.report({ + node: property, + messageId: "unsafeInnerHtmlSpread", + }); + } + }; + + return { + before() { + escapeHatchLines.clear(); + }, + Program(node) { + recordEscapeHatches(node); + }, + + // Reject hoisted payloads such as + // `dangerouslySetInnerHTML={payload}`. Keeping the `__html` object + // inline lets this rule inspect the actual HTML expression. + JSXAttribute(node) { + if (!isJsxIdentifier(node.name, "dangerouslySetInnerHTML")) { + return; + } + const value = node.value; + if (value?.type !== "JSXExpressionContainer") { + return; + } + if (!value.expression) { + return; + } + const expression = unwrapExpression(value.expression); + if (expression?.type === "ObjectExpression") { + reportPayloadSpreads(expression); + return; + } + reportIfUnsafe(value.expression); + }, + + // Sink 1: `dangerouslySetInnerHTML={{ __html: }}`. + Property(node) { + if (!isDangerouslySetInnerHtmlValue(node)) { + return; + } + reportIfUnsafe(node.value); + }, + + // Sink 2: `.innerHTML = ` (non-computed member LHS). + AssignmentExpression(node) { + const target = node.left; + if ( + target.type !== "MemberExpression" || + target.computed !== false || + !isIdentifier(target.property, "innerHTML") + ) { + return; + } + reportIfUnsafe(node.right); + }, + }; + }, + }, + }, +}); diff --git a/packages/oxlint-plugin/src/utils.ts b/packages/oxlint-plugin/src/utils.ts new file mode 100644 index 0000000..a9b34f5 --- /dev/null +++ b/packages/oxlint-plugin/src/utils.ts @@ -0,0 +1,142 @@ +// Shared AST helpers for the rules in this folder. +// +// Oxlint plugin AST nodes are passed in untyped. Each helper narrows from +// `unknown` so rule files can call them without per-call type ceremony or +// shared type-import boilerplate. + +import type { Ranged } from "@oxlint/plugins"; + +export type AstNode = Ranged & { type: string } & Record; + +type FilenameContext = { + filename?: string; + getFilename?: () => string; +}; + +export const filenameForContext = (context: FilenameContext): string => + (context.filename ?? context.getFilename?.() ?? "").replaceAll("\\", "/"); + +export const isAstNode = (node: unknown): node is AstNode => + typeof node === "object" && + node !== null && + "type" in node && + typeof (node as { type: unknown }).type === "string" && + "range" in node; + +export const isIdentifier = ( + node: unknown, + name?: string, +): node is AstNode & { type: "Identifier"; name: string } => { + if (!isAstNode(node) || node.type !== "Identifier") { + return false; + } + if (typeof node.name !== "string") { + return false; + } + return name === undefined || node.name === name; +}; + +export const isStringLiteral = ( + node: unknown, +): node is AstNode & { type: "Literal"; value: string } => + isAstNode(node) && node.type === "Literal" && typeof node.value === "string"; + +// Resolve the static name of a Property or MemberExpression key: +// Identifier.name or string-Literal.value. Returns null for computed keys +// driven by a non-literal expression. +export const getPropertyName = (node: unknown): string | null => { + if (isIdentifier(node)) { + return node.name; + } + if (isStringLiteral(node)) { + return node.value; + } + return null; +}; + +// Match `.` member access where both halves are +// Identifiers and the access is not computed. +export const isMemberAccess = ( + node: unknown, + object: string, + property: string, +): boolean => + isAstNode(node) && + node.type === "MemberExpression" && + node.computed === false && + isIdentifier(node.object, object) && + isIdentifier(node.property, property); + +// Match `CallExpression` whose callee is an Identifier with the given name. +export const isCallTo = (node: unknown, name: string): boolean => + isAstNode(node) && + node.type === "CallExpression" && + isIdentifier(node.callee, name); + +// Resolve the dot-notation name of a callee: an Identifier, or a +// non-computed MemberExpression chain (e.g. `t.String`, `Schema.is`, +// `process.stderr.write`). Returns null when the chain is computed +// or the property name itself can't be resolved. +// +// If the chain is rooted at a non-Identifier expression (e.g. `foo().bar`), +// returns the bare property name ("bar") rather than null. Callers that +// match the result against a fixed allowlist must consider whether they +// need to distinguish `foo().createSafeHandler` from `createSafeHandler`. +export const getCalleeName = (callee: unknown): string | null => { + if (isIdentifier(callee)) { + return callee.name; + } + if (!isAstNode(callee) || callee.type !== "MemberExpression") { + return null; + } + if (callee.computed !== false) { + return null; + } + const objectName = getCalleeName(callee.object); + const propertyName = getPropertyName(callee.property); + if (propertyName === null) { + return null; + } + return objectName === null ? propertyName : `${objectName}.${propertyName}`; +}; + +// Peel TS-only wrapping nodes so a shape check sees the underlying +// expression. Returns the original node when no wrapping is present. +export const unwrapExpression = (node: unknown): AstNode | null => { + if (!isAstNode(node)) { + return null; + } + if ( + node.type === "TSAsExpression" || + node.type === "TSSatisfiesExpression" || + node.type === "ChainExpression" + ) { + return unwrapExpression(node.expression); + } + return node; +}; + +// Resolve an ImportSpecifier's imported binding name (Identifier.name or +// string-Literal.value). Returns null when the specifier shape is unexpected. +export const getImportedName = (specifier: unknown): string | null => { + if (!isAstNode(specifier) || specifier.type !== "ImportSpecifier") { + return null; + } + const imported = specifier.imported; + if (isIdentifier(imported)) { + return imported.name; + } + if (isStringLiteral(imported)) { + return imported.value; + } + return null; +}; + +// Resolve an ImportSpecifier's local binding name. This differs from +// getImportedName for aliased imports such as `import { source as local }`. +export const getImportLocalName = (specifier: unknown): string | null => { + if (!isAstNode(specifier) || specifier.type !== "ImportSpecifier") { + return null; + } + return isIdentifier(specifier.local) ? specifier.local.name : null; +}; diff --git a/packages/oxlint-plugin/tsconfig.json b/packages/oxlint-plugin/tsconfig.json new file mode 100644 index 0000000..ff4a72a --- /dev/null +++ b/packages/oxlint-plugin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@stll/typescript-config/library.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noImplicitAny": false, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/oxlint-plugin/tsdown.config.ts b/packages/oxlint-plugin/tsdown.config.ts new file mode 100644 index 0000000..40c904d --- /dev/null +++ b/packages/oxlint-plugin/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: [ + "src/index.ts", + "src/no-any-casts.ts", + "src/no-dangerous-type-assertions.ts", + "src/no-partial-record-satisfies.ts", + "src/no-unsafe-inner-html.ts", + ], + format: ["esm", "cjs"], + dts: true, + clean: true, + sourcemap: true, + hash: false, + checks: { + legacyCjs: false, + }, + outputOptions: { + exports: "named", + }, +});