diff --git a/.changeset/unsafe-any-rules.md b/.changeset/unsafe-any-rules.md new file mode 100644 index 00000000..5e636509 --- /dev/null +++ b/.changeset/unsafe-any-rules.md @@ -0,0 +1,6 @@ +--- +"@stll/oxlint-config": major +"@stll/test-kit": patch +--- + +`library()` enables `typescript/no-unsafe-argument`, `no-unsafe-assignment`, `no-unsafe-call`, `no-unsafe-member-access` and `no-unsafe-return`, so an `any` from `JSON.parse`, untyped libraries or an unresolved import can no longer flow into typed code unchecked. Test files keep reporting calls, member access and returns but allow passing and storing `any` values such as asymmetric matchers. JavaScript files are exempt. The rules are exported as `unsafeAnyRules` so a consumer can turn them off per directory while adopting them, and the two exemptions as `unsafeAnyOverrides`; `libraryOverrides` does not include them, so a config that spreads it over another preset keeps that preset's scope for these rules. diff --git a/oxlint.config.ts b/oxlint.config.ts index 8abf767a..5fe8dd85 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,4 +1,4 @@ -import { library } from "./packages/oxlint-config/src/index"; +import { library, unsafeAnyRules } from "./packages/oxlint-config/src/index"; import { bunTestHygienePluginSpecifiers, bunTestHygieneRules, @@ -10,6 +10,10 @@ import { testReliabilityRules, } from "./packages/oxlint-plugin/src/index"; +const unsafeAnyOff = Object.fromEntries( + Object.keys(unsafeAnyRules).map((rule) => [rule, "off" as const]), +); + export default library({ ignorePatterns: [ "packages/oxlint-config/dist/", @@ -23,12 +27,18 @@ export default library({ ...testReliabilityPluginSpecifiers, ], overrides: [ + { + // Preset fixtures call undeclared framework hooks and JSX on purpose. + files: ["packages/oxlint-config/fixtures/**/*.{ts,tsx}"], + rules: unsafeAnyOff, + }, { // Oxlint presents plugin AST nodes as intentionally untyped values. - // Type-aware condition rules cannot add safety inside the visitor - // implementation; the passive fixtures prove rule behavior. + // 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: { + ...unsafeAnyOff, "typescript/strict-boolean-expressions": "off", "typescript/no-unnecessary-condition": "off", }, @@ -53,6 +63,9 @@ export default library({ "import/no-duplicates": "off", "typescript/no-unnecessary-template-expression": "off", "typescript/prefer-promise-reject-errors": "off", + // Fixtures reference undeclared globals such as `React` and `expect`, + // so their values have no resolved type. + ...unsafeAnyOff, }, }, { diff --git a/packages/oxlint-config/README.md b/packages/oxlint-config/README.md index fc479209..01a460f4 100644 --- a/packages/oxlint-config/README.md +++ b/packages/oxlint-config/README.md @@ -63,6 +63,29 @@ breaking: `libraryRules` and `libraryPlugins` are now framework-neutral. Import `reactRules` and add `"react"` to the plugins of a file-scoped override to retain React diagnostics. +The preset reports `any` flowing into typed code through the +`typescript/no-unsafe-*` rules, exported as `unsafeAnyRules`. Test files allow +passing and storing `any` values such as asymmetric matchers, and JavaScript +files are exempt; both exemptions are exported as `unsafeAnyOverrides`. A +config that spreads `libraryRules` and `libraryOverrides` instead of calling +`library()` adds `...unsafeAnyOverrides` next to them. To adopt the rules gradually, turn them off for directories +that are not clean yet and delete the override as each one is fixed: + +```ts +import { library, unsafeAnyRules } from "@stll/oxlint-config"; + +export default library({ + overrides: [ + { + files: ["packages/legacy/**"], + rules: Object.fromEntries( + Object.keys(unsafeAnyRules).map((rule) => [rule, "off" as const]), + ), + }, + ], +}); +``` + ## Shared toolchain and Bun `@stll/oxlint-config/toolchain.json` is a versioned policy for package pins, diff --git a/packages/oxlint-config/src/dependabot-input.test.ts b/packages/oxlint-config/src/dependabot-input.test.ts index 425ca08d..bb666ca3 100644 --- a/packages/oxlint-config/src/dependabot-input.test.ts +++ b/packages/oxlint-config/src/dependabot-input.test.ts @@ -18,12 +18,17 @@ test.each(supportedDependabotEcosystems)( updates: [{ ecosystem, directories: ["/"] }], }); const output = generateDependabotConfig({ files: {}, policy, updates }); - expect(parseDocument(output).toJS().updates).toEqual([ + const rendered: unknown = parseDocument(output).toJS(); + expect(rendered).toEqual( expect.objectContaining({ - "package-ecosystem": ecosystem, - directory: "/", + updates: [ + expect.objectContaining({ + "package-ecosystem": ecosystem, + directory: "/", + }), + ], }), - ]); + ); }, ); diff --git a/packages/oxlint-config/src/engine-support-cli.ts b/packages/oxlint-config/src/engine-support-cli.ts index 15c217f4..8f5aab7b 100644 --- a/packages/oxlint-config/src/engine-support-cli.ts +++ b/packages/oxlint-config/src/engine-support-cli.ts @@ -24,7 +24,10 @@ const parseBase = (args: string[]) => { throw new Error("Usage: stll-engine-support --base "); }; -const packageNodeRange = ({ packageJson }: Package) => { +// Base manifests come from `git show` and are only known to be objects. +type Manifest = { readonly packageJson: object }; + +const packageNodeRange = ({ packageJson }: Manifest) => { const engines = "engines" in packageJson ? packageJson.engines : undefined; return typeof engines === "object" && engines !== null && @@ -34,10 +37,16 @@ const packageNodeRange = ({ packageJson }: Package) => { : undefined; }; -const publishedPackages = (packages: Package[], ignored: Set) => { +const publishedPackages = ( + packages: readonly Manifest[], + ignored: Set, +) => { const collected = new Map(); for (const pkg of packages) { - const { name, private: isPrivate } = pkg.packageJson; + const { packageJson } = pkg; + const name = "name" in packageJson ? packageJson.name : undefined; + const isPrivate = + "private" in packageJson ? packageJson.private : undefined; if (isPrivate === true || typeof name !== "string") continue; if (collected.has(name)) throw new Error(`Duplicate published package name: ${name}`); @@ -66,7 +75,7 @@ const basePackage = (root: string, base: string, pkg: Package) => { ]).trim() === "" ) return undefined; - const packageJson = JSON.parse( + const packageJson: unknown = JSON.parse( git(root, ["show", `${base}:${relativeManifest}`]), ); if (typeof packageJson !== "object" || packageJson === null) diff --git a/packages/oxlint-config/src/index.ts b/packages/oxlint-config/src/index.ts index 0c696ac2..8c948d26 100644 --- a/packages/oxlint-config/src/index.ts +++ b/packages/oxlint-config/src/index.ts @@ -62,6 +62,18 @@ export const reactCompilerRules = { "react/void-use-memo": "error", } satisfies Rules; +// `no-explicit-any` stops only a written `any`. These stop one that arrives +// from `JSON.parse`, untyped libraries or an unresolved import from flowing +// into typed code. Exported so a consumer can turn them off for directories +// that are not clean yet and remove that override as each one is fixed. +export const unsafeAnyRules = { + "typescript/no-unsafe-argument": "error", + "typescript/no-unsafe-assignment": "error", + "typescript/no-unsafe-call": "error", + "typescript/no-unsafe-member-access": "error", + "typescript/no-unsafe-return": "error", +} satisfies Rules; + export const libraryRules = { "no-console": "off", "no-shadow": "error", @@ -106,6 +118,7 @@ export const libraryRules = { "oxc/branches-sharing-code": "error", "typescript/no-explicit-any": "error", + ...unsafeAnyRules, "typescript/no-dynamic-delete": "error", "typescript/no-misused-promises": [ "error", @@ -249,15 +262,17 @@ export const reactRules = { ...reactCompilerRules, } satisfies Rules; +// Override globs are anchored at the config directory, so test folders need a +// leading `**/` to match inside packages. +const libraryTestFiles = [ + "**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + "**/__test__/**", + "**/__tests__/**", +]; + export const libraryOverrides = [ { - // Override globs are anchored at the config directory, so test folders - // need a leading `**/` to match inside packages. - files: [ - "**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", - "**/__test__/**", - "**/__tests__/**", - ], + files: libraryTestFiles, rules: { "no-console": "off", "require-await": "off", @@ -266,10 +281,35 @@ export const libraryOverrides = [ }, ] satisfies OxlintOverride[]; +// Scoped relaxations of `unsafeAnyRules`. They stay separate from +// `libraryOverrides` so a config that takes these rules from another preset +// keeps its own scope; spread both exports together. +export const unsafeAnyOverrides = [ + { + // Asymmetric matchers such as `expect.stringContaining()` are typed `any`, + // so passing or storing one is the normal way to write a test. Calling, + // indexing or returning an `any` is still reported. + files: libraryTestFiles, + rules: { + "typescript/no-unsafe-argument": "off", + "typescript/no-unsafe-assignment": "off", + }, + }, + { + // JavaScript has no annotation to turn an `any` into `unknown`, so these + // rules could only be silenced there, not satisfied. + files: ["**/*.{js,jsx,mjs,cjs}"], + rules: Object.fromEntries( + Object.keys(unsafeAnyRules).map((rule) => [rule, "off" as const]), + ), + }, +] satisfies OxlintOverride[]; + export const library = (options: LibraryOptions = {}): OxlintConfig => { const plugins = new Set([...libraryPlugins, ...(options.plugins ?? [])]); const presetOverrides = [ ...libraryOverrides, + ...unsafeAnyOverrides, ...(options.react === undefined ? [] : [ diff --git a/packages/oxlint-config/src/library-overrides.test.ts b/packages/oxlint-config/src/library-overrides.test.ts index 6e8aea4e..334069c8 100644 --- a/packages/oxlint-config/src/library-overrides.test.ts +++ b/packages/oxlint-config/src/library-overrides.test.ts @@ -6,7 +6,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { library } from "./index"; +import { + library, + libraryOverrides, + libraryRules, + unsafeAnyOverrides, + unsafeAnyRules, +} from "./index"; const oxlintBin = fileURLToPath( new URL("./bin/oxlint", import.meta.resolve("oxlint/package.json")), @@ -69,3 +75,27 @@ test("test overrides reach nested test folders and every module extension", asyn await rm(directory, { recursive: true, force: true }); } }, 15_000); + +test("unsafe any rules are on, relaxed for test matchers and off in JavaScript, only through their own overrides", () => { + expect(libraryRules).toEqual(expect.objectContaining(unsafeAnyRules)); + const [tests, javascript] = unsafeAnyOverrides; + expect(tests?.files).toEqual(libraryOverrides[0]?.files); + expect(tests?.rules).toEqual( + expect.objectContaining({ + "typescript/no-unsafe-argument": "off", + "typescript/no-unsafe-assignment": "off", + }), + ); + expect(tests?.rules).not.toHaveProperty(["typescript/no-unsafe-call"]); + // Configs that spread `libraryOverrides` over a preset with its own + // any-flow scope must not have those rules relaxed. + expect( + libraryOverrides.flatMap((override) => Object.keys(override.rules)), + ).not.toEqual(expect.arrayContaining(Object.keys(unsafeAnyRules))); + expect(javascript?.files).toEqual(["**/*.{js,jsx,mjs,cjs}"]); + expect(javascript?.rules).toEqual( + Object.fromEntries( + Object.keys(unsafeAnyRules).map((rule) => [rule, "off"]), + ), + ); +}); diff --git a/packages/oxlint-config/src/plugin.ts b/packages/oxlint-config/src/plugin.ts index e2bc87fd..7b4d5503 100644 --- a/packages/oxlint-config/src/plugin.ts +++ b/packages/oxlint-config/src/plugin.ts @@ -218,7 +218,7 @@ export default { } const headIsContinuation = isJsxExpressionContinuation(node); for (let i = 0; i < quasis.length; i++) { - const quasi = quasis[i]; + const quasi: unknown = quasis[i]; const value = quasiText(quasi); if (value === null) { continue; diff --git a/packages/oxlint-config/src/publish-build-target-nuxt.test.ts b/packages/oxlint-config/src/publish-build-target-nuxt.test.ts index b8207cea..ad056b50 100644 --- a/packages/oxlint-config/src/publish-build-target-nuxt.test.ts +++ b/packages/oxlint-config/src/publish-build-target-nuxt.test.ts @@ -25,6 +25,22 @@ import { nuxtModuleTargetTargets, } from "./publish-build-target-nuxt-helper"; +// `typeof === "function"` narrows to `Function`, whose calls return `any`. +const callable = ( + value: unknown, +): value is (...parameters: readonly unknown[]) => unknown => + typeof value === "function"; + +const property = (value: unknown, ...keys: readonly string[]): unknown => { + let current = value; + for (const key of keys) + current = + typeof current === "object" && current !== null + ? Reflect.get(current, key) + : undefined; + return current; +}; + test("Nuxt module and runtime targets are resolved independently", () => { expect( resolvedNuxtTarget({ @@ -261,10 +277,7 @@ test.skipIf( nuxtModuleTarget: makeTarget, isNuxtModuleTargetHook: recognizeTarget, } = module; - if ( - typeof makeTarget !== "function" || - typeof recognizeTarget !== "function" - ) + if (!callable(makeTarget) || !callable(recognizeTarget)) throw new Error("Built Nuxt helper exports must be functions"); return { nuxtModuleTarget: makeTarget, @@ -285,11 +298,19 @@ test.skipIf( cjsConfig, `const {nuxtModuleTarget}=require(${JSON.stringify(builtCjsHelper)}); module.exports={hooks:{'build:before':nuxtModuleTarget('es2022')}};`, ); - const esmHook: unknown = (await import(pathToFileURL(esmConfig).href)) - .default.hooks["build:before"]; - const cjsHook: unknown = createRequire(import.meta.url)(cjsConfig).hooks[ - "build:before" - ]; + const esmConfigModule: unknown = await import( + pathToFileURL(esmConfig).href + ); + const cjsConfigModule: unknown = createRequire(import.meta.url)( + cjsConfig, + ); + const esmHook = property( + esmConfigModule, + "default", + "hooks", + "build:before", + ); + const cjsHook = property(cjsConfigModule, "hooks", "build:before"); expect(esmHelper.isNuxtModuleTargetHook(cjsHook)).toBe(true); expect(cjsHelper.isNuxtModuleTargetHook(esmHook)).toBe(true); expect( diff --git a/packages/oxlint-config/src/publish-build-target-vite-dts.test.ts b/packages/oxlint-config/src/publish-build-target-vite-dts.test.ts index 799358f5..f9e069ef 100644 --- a/packages/oxlint-config/src/publish-build-target-vite-dts.test.ts +++ b/packages/oxlint-config/src/publish-build-target-vite-dts.test.ts @@ -13,6 +13,12 @@ import { snapshotDeclarationDirectory, } from "./publish-build-target-vite-dts"; +// `typeof === "function"` narrows to `Function`, whose calls return `any`. +const callable = ( + value: unknown, +): value is (...parameters: readonly unknown[]) => unknown => + typeof value === "function"; + const directories: string[] = []; afterEach(() => { for (const directory of directories.splice(0)) @@ -191,7 +197,7 @@ test("the emission ledger rejects deferred JavaScript and foreign asset updates" // The native bundler context is withheld, not forwarded. expect(Reflect.get(context, "getNativeBuildContext")).toBeUndefined(); const emit = context["emitFile"]; - if (typeof emit !== "function") throw new Error("Missing fixture emitFile"); + if (!callable(emit)) throw new Error("Missing fixture emitFile"); expect(() => emit({ type: "asset", fileName: "index.js", source: "deferred" }), ).toThrow("Declaration plugin"); @@ -203,8 +209,7 @@ test("the emission ledger rejects deferred JavaScript and foreign asset updates" expect(reference).toBe("asset-0"); expect(() => ledger.audit()).not.toThrow(); const update = context["setAssetSource"]; - if (typeof update !== "function") - throw new Error("Missing fixture setAssetSource"); + if (!callable(update)) throw new Error("Missing fixture setAssetSource"); expect(() => update(reference, "updated declaration")).not.toThrow(); expect(() => update("foreign", "modified JS")).toThrow("foreign"); expect(() => Reflect.get(context, "emitChunk")).toThrow("Declaration plugin"); @@ -221,7 +226,7 @@ test("a shared emitter ledger audits deferred references in output hooks", async }; const context = ledger.wrapContext(host); const emit = context["emitFile"]; - if (typeof emit !== "function") throw new Error("Missing fixture emitFile"); + if (!callable(emit)) throw new Error("Missing fixture emitFile"); emit({ type: "asset", fileName: "index.d.ts" }); const guarded = guardDeclarationHook({ hook: () => undefined, diff --git a/packages/oxlint-config/src/publish-build-target-vite-dts.ts b/packages/oxlint-config/src/publish-build-target-vite-dts.ts index c4b9702d..fb2961f1 100644 --- a/packages/oxlint-config/src/publish-build-target-vite-dts.ts +++ b/packages/oxlint-config/src/publish-build-target-vite-dts.ts @@ -11,10 +11,10 @@ const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const staticEntries = (value: unknown): [string, unknown][] => { - if ( - !record(value) || - ![Object.prototype, null].includes(Object.getPrototypeOf(value)) - ) + const prototype: unknown = record(value) + ? Object.getPrototypeOf(value) + : undefined; + if (!record(value) || (prototype !== Object.prototype && prototype !== null)) throw new Error("Declaration plugin options must be plain static records"); return Reflect.ownKeys(value).map((key) => { const descriptor = Object.getOwnPropertyDescriptor(value, key); @@ -384,7 +384,8 @@ const dataEntries = (value: Record, keys: string[]) => throw new Error("Invalid reviewed declaration hook"); if (!("value" in descriptor)) throw new Error("Declaration hooks cannot contain accessors"); - return [key, descriptor.value] as const; + const entry: unknown = descriptor.value; + return [key, entry] as const; }); /** diff --git a/packages/oxlint-config/src/publish-build-target-vite.ts b/packages/oxlint-config/src/publish-build-target-vite.ts index d535a7f7..3608fdad 100644 --- a/packages/oxlint-config/src/publish-build-target-vite.ts +++ b/packages/oxlint-config/src/publish-build-target-vite.ts @@ -13,6 +13,14 @@ const supportedViteVersion = "8.3.5"; const declarationHelperImport = "@stll/oxlint-config/declaration-only-dts"; const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +// `Array.isArray` narrows to `any[]` and `typeof === "function"` to +// `Function`; these keep values read from untyped ASTs and configs `unknown`. +const array = (value: unknown): value is readonly unknown[] => + Array.isArray(value); +const callable = ( + value: unknown, +): value is (...parameters: readonly unknown[]) => unknown => + typeof value === "function"; type WalkAstOptions = { value: unknown; @@ -48,12 +56,9 @@ const astLiteral = (value: unknown): unknown => { (typeof value["value"] === "string" || typeof value["value"] === "boolean") ) return value["value"]; - if (value["type"] === "ArrayExpression" && Array.isArray(value["elements"])) + if (value["type"] === "ArrayExpression" && array(value["elements"])) return value["elements"].map(astLiteral); - if ( - value["type"] === "ObjectExpression" && - Array.isArray(value["properties"]) - ) { + if (value["type"] === "ObjectExpression" && array(value["properties"])) { const entries: [string, unknown][] = []; for (const property of value["properties"]) { if ( @@ -96,7 +101,7 @@ export const reviewedDtsConfiguration = ({ transformSync(source, { loader, format: "esm", target: "esnext" }).code, { ecmaVersion: "latest", sourceType: "module" }, ); - if (!record(ast) || !Array.isArray(ast["body"])) + if (!record(ast) || !array(ast["body"])) throw new Error("Invalid Vite configuration AST"); const bindings = new Set(); const defineConfigBindings = new Set(); @@ -116,7 +121,7 @@ export const reviewedDtsConfiguration = ({ throw new Error( "Raw declaration emitters cannot accompany the reviewed helper", ); - if (Array.isArray(statement["specifiers"])) + if (array(statement["specifiers"])) for (const specifier of statement["specifiers"]) { if (!record(specifier)) continue; const local = astName(specifier["local"]); @@ -140,7 +145,7 @@ export const reviewedDtsConfiguration = ({ } if ( statement["type"] === "VariableDeclaration" && - Array.isArray(statement["declarations"]) + array(statement["declarations"]) ) for (const declaration of statement["declarations"]) if (record(declaration)) @@ -149,7 +154,7 @@ export const reviewedDtsConfiguration = ({ exported = statement["declaration"]; if ( statement["type"] === "ExportNamedDeclaration" && - Array.isArray(statement["specifiers"]) + array(statement["specifiers"]) ) for (const specifier of statement["specifiers"]) if (record(specifier) && astName(specifier["exported"]) === "default") @@ -165,14 +170,14 @@ export const reviewedDtsConfiguration = ({ record(exported) && exported["type"] === "CallExpression" && defineConfigBindings.has(String(astName(exported["callee"]))) && - Array.isArray(exported["arguments"]) && + array(exported["arguments"]) && exported["arguments"].length === 1 ) exported = exported["arguments"].at(0); if ( !record(exported) || exported["type"] !== "ObjectExpression" || - !Array.isArray(exported["properties"]) + !array(exported["properties"]) ) throw new Error( "Declaration helper requires a directly exported static Vite configuration", @@ -191,7 +196,7 @@ export const reviewedDtsConfiguration = ({ property["kind"] !== "init" || !record(property["value"]) || property["value"]["type"] !== "ArrayExpression" || - !Array.isArray(property["value"]["elements"]) + !array(property["value"]["elements"]) ) throw new Error("Declaration helper requires a literal plugins array"); const calls = property["value"]["elements"] @@ -210,7 +215,7 @@ export const reviewedDtsConfiguration = ({ if ( calls.length !== 1 || !record(call) || - !Array.isArray(call["arguments"]) || + !array(call["arguments"]) || call["arguments"].length !== 1 ) throw new Error("Declare exactly one direct declaration helper call"); @@ -231,7 +236,7 @@ export const reviewedDtsConfiguration = ({ if ( !record(argument) || argument["type"] !== "ObjectExpression" || - !Array.isArray(argument["properties"]) + !array(argument["properties"]) ) throw new Error("Declaration helper options must be a literal object"); const directoryProperties = argument["properties"].filter( @@ -369,13 +374,13 @@ const plugins = (config: unknown): unknown[] => { ]; if ( !record(environment) || - !Array.isArray(environment["plugins"]) || - !Array.isArray(config["plugins"]) + !array(environment["plugins"]) || + !array(config["plugins"]) ) throw new Error("Missing resolved Vite environment plugins"); const options = config["build"]["rolldownOptions"]; const extra = record(options) ? options["plugins"] : undefined; - if (extra !== undefined && !Array.isArray(extra)) + if (extra !== undefined && !array(extra)) throw new Error( "Dynamic Vite rolldown plugins require a supported target resolver", ); @@ -674,7 +679,7 @@ export const resolveVitePublishTarget = async ( const tool: unknown = await import( pathToFileURL(realpathSync(require.resolve("vite"))).href ); - if (!record(tool) || typeof tool["resolveConfig"] !== "function") + if (!record(tool) || !callable(tool["resolveConfig"])) throw new Error( "Installed Vite does not expose its configuration resolver", ); @@ -729,7 +734,7 @@ export const resolveVitePublishTarget = async ( const vue: unknown = await import( pathToFileURL(realpathSync(require.resolve("@vitejs/plugin-vue"))).href ); - if (!record(vue) || typeof vue["default"] !== "function") + if (!record(vue) || !callable(vue["default"])) throw new Error("Invalid installed Vue plugin factory"); const plugin: unknown = vue["default"](); if (!record(plugin)) throw new Error("Invalid installed Vue plugin"); diff --git a/packages/oxlint-config/src/toolchain-dependabot-ignores.test.ts b/packages/oxlint-config/src/toolchain-dependabot-ignores.test.ts index b30e3742..e619de08 100644 --- a/packages/oxlint-config/src/toolchain-dependabot-ignores.test.ts +++ b/packages/oxlint-config/src/toolchain-dependabot-ignores.test.ts @@ -120,7 +120,9 @@ const withIgnore = ( throw new Error("generated Dependabot configuration has no updates"); const { updates } = parsed; if (!Array.isArray(updates)) throw new Error("updates must be an array"); - for (const update of updates) { + // `Array.isArray` narrows to `any[]`; read each entry as `unknown`. + const entries: readonly unknown[] = updates; + for (const update of entries) { if ( typeof update !== "object" || update === null || diff --git a/packages/oxlint-config/src/toolchain-runtime.ts b/packages/oxlint-config/src/toolchain-runtime.ts index 0df7f0e6..7a374dbf 100644 --- a/packages/oxlint-config/src/toolchain-runtime.ts +++ b/packages/oxlint-config/src/toolchain-runtime.ts @@ -1930,7 +1930,7 @@ export const checkRuntimeFile = ({ }; try { // Resolve aliases first with an expansion bound before visiting executable fields. - const workflow = document.toJS({ maxAliasCount: 100 }); + const workflow: unknown = document.toJS({ maxAliasCount: 100 }); reviewedWorkflow = workflow; if (automationKind === "workflow") { for (const finding of checkWorkflowTrust(workflow)) { diff --git a/packages/test-kit/src/network.test.ts b/packages/test-kit/src/network.test.ts index aed9b3ba..4c3a70ad 100644 --- a/packages/test-kit/src/network.test.ts +++ b/packages/test-kit/src/network.test.ts @@ -425,9 +425,9 @@ describe("network guard", () => { if (typeof connect !== "function") { throw new TypeError("Socket#connect is not a function"); } - expect(() => Reflect.apply(connect, socket, [normalised])).toThrow( - NetworkAccessError, - ); + expect(() => { + Reflect.apply(connect, socket, [normalised]); + }).toThrow(NetworkAccessError); }); test("Bun.connect to a remote host rejects", async () => { diff --git a/scripts/check-lockfile-workspace-versions.ts b/scripts/check-lockfile-workspace-versions.ts index 383df2f3..219fb6de 100644 --- a/scripts/check-lockfile-workspace-versions.ts +++ b/scripts/check-lockfile-workspace-versions.ts @@ -26,8 +26,14 @@ import { syncWorkspaceToolchainPins } from "./lib/toolchain-workspace-versions"; const ROOT = join(import.meta.dirname, ".."); -const readJson = async (path: string): Promise> => - JSON.parse(await Bun.file(path).text()); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const readJson = async (path: string): Promise> => { + const value: unknown = JSON.parse(await Bun.file(path).text()); + if (!isRecord(value)) throw new Error(`${path} must contain a JSON object`); + return value; +}; const packagesDir = join(ROOT, "packages"); const entries = await readdir(packagesDir, { withFileTypes: true }); diff --git a/scripts/release-generated-paths.test.ts b/scripts/release-generated-paths.test.ts index c4df6402..0c0108f9 100644 --- a/scripts/release-generated-paths.test.ts +++ b/scripts/release-generated-paths.test.ts @@ -37,7 +37,10 @@ test("an owned plugin release can update policy after consuming its changeset", workspaceVersions: new Map([["@stll/oxlint-plugin", "0.3.0"]]), }); expect(result.text).not.toBe(sourcePolicy); - expect(JSON.parse(result.text)["@stll/oxlint-plugin"]).toBe("0.3.0"); + const versions: unknown = JSON.parse(result.text); + expect(versions).toEqual( + expect.objectContaining({ "@stll/oxlint-plugin": "0.3.0" }), + ); const generated = new Set( releaseGeneratedPaths(["oxlint-config", "oxlint-plugin"]), );