@ls-stack/fast-lint 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,186 @@
1
- # Temporary Holding Version
1
+ # @ls-stack/fast-lint
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Type-aware lint rules running natively on [tsrs](https://github.com/maschwenk/tsrs), the Rust port of the TypeScript 7
4
+ checker, through [tsrslint](https://github.com/maschwenk/tsrslint)'s rule framework. No ESLint, no Node type checker:
5
+ only these rules run, against one shared program per tsconfig. Type-aware rules live here, not in
6
+ [`@ls-stack/extended-lint`](../extended-lint).
7
+
8
+ ## Rules
9
+
10
+ | rule | origin |
11
+ | --- | --- |
12
+ | `improved-no-unnecessary-condition` | moved from `@ls-stack/extended-lint` |
13
+ | `strict-casting` | moved from `@ls-stack/extended-lint` (see below) |
14
+ | `no-unnecessary-void-on-promise` | moved from `@ls-stack/extended-lint` |
15
+ | `no-leaked-conditional-rendering` | port of `@eslint-react/no-leaked-conditional-rendering` (v2.3.13); option `reactVersion` (default `"19.0.0"`, also used for `""` and `"detect"`: the installed version isn't read) |
16
+
17
+ Options are the same as the former ESLint rules' (see `lib/index.d.ts`). Rule names may carry a plugin prefix, so
18
+ existing `@ls-stack/strict-casting` configs and disable comments keep working.
19
+
20
+ ### `strict-casting`
21
+
22
+ Enforces safe and necessary `String()`/`Number()` casts.
23
+
24
+ Checks performed:
25
+
26
+ - **Unnecessary casts** (auto-fixable): casting a value that is already of the target type
27
+ - **Nullish casts**: `String(value)` produces the strings `"null"`/`"undefined"`, and `Number(value)` produces `0` for `null` and `NaN` for `undefined`. A suggestion is provided to add a `?? ''`/`?? 0` fallback
28
+ - **Invalid `Number()` casts**: casting a `symbol` (throws at runtime) or an object without a numeric `valueOf` (always results in `NaN`)
29
+ - **`any`/`unknown` casts**: casting untyped values is unsafe, type or narrow the value before casting
30
+
31
+ Bad:
32
+
33
+ ```ts
34
+ declare const str: string
35
+ declare const nullable: string | null
36
+ declare const obj: { a: string }
37
+
38
+ String(str) // Error: unnecessary cast
39
+ String(nullable) // Error: may produce "null"
40
+ Number(obj) // Error: results in NaN
41
+ ```
42
+
43
+ Good:
44
+
45
+ ```ts
46
+ String(num)
47
+ String(nullable ?? '')
48
+ Number(date) // Date has a numeric valueOf
49
+ ```
50
+
51
+ Options:
52
+
53
+ ```ts
54
+ type Options = [
55
+ {
56
+ /** Extra functions checked for unnecessary casts */
57
+ additionalCastFunctions?: { name: string; expectedType: 'string' | 'number' }[]
58
+ /** Allow casting possibly nullish values (default: false) */
59
+ allowNullishCasting?: boolean
60
+ /** Allow symbol/object Number() casts (default: false) */
61
+ allowInvalidCasting?: boolean
62
+ /** Allow casting `any` values (default: false) */
63
+ allowAnyCasting?: boolean
64
+ /** Allow casting `unknown` values, e.g. `String(err)` in catch blocks (default: false) */
65
+ allowUnknownCasting?: boolean
66
+ },
67
+ ]
68
+ ```
69
+
70
+ For `String(obj)` producing `"[object Object]"`, use `@typescript-eslint/no-base-to-string`, which covers that case well.
71
+
72
+ ## Usage
73
+
74
+ ```sh
75
+ fast-lint # every file of ./tsconfig.json
76
+ fast-lint src/feature # only files under these paths
77
+ fast-lint --fix # apply autofixes (strict-casting's unnecessary casts)
78
+ fast-lint --format json # stylish (default), compact or json, in ESLint's formats
79
+ fast-lint --max-warnings 0 --timing # --max-warnings -1: no limit
80
+ fast-lint -p packages/app # a tsconfig, or a directory with a tsconfig.json
81
+ fast-lint --color -- -odd-name.ts # force colors; `--` ends the options
82
+ fast-lint --report-unused-disable-directives # warn about fast-lint-disable comments that suppress nothing
83
+ ```
84
+
85
+ A solution-style tsconfig (empty `files` plus `references`) lints its referenced projects; a file in several
86
+ projects is linted once.
87
+
88
+ Paths are printed relative to the current directory. A file with a syntax error gets a single `Parsing error`
89
+ instead of rule diagnostics. `--quiet` hides warnings and doesn't apply their fixes; like ESLint, it skips `warn` rules
90
+ entirely unless `--max-warnings` is given, in which case warnings still count. `--format json` prints ESLint's JSON
91
+ result shape, listing only files with problems or fixes, with the file's `source` when it has problems and wasn't
92
+ fixed.
93
+
94
+ Exit code: `0` clean, `1` errors (or more warnings than `--max-warnings`), `2` fatal error: a tsconfig with a missing
95
+ `files` entry or `extends` target, no files to lint, a path argument that matches no project file, a directory
96
+ argument whose files are all ignored, or a fixed file that couldn't be written (reported after the results). A file
97
+ argument that is ignored, a declaration file or in `node_modules` is skipped with a warning, and so is fixing a file
98
+ that isn't UTF-8 (its problems are still reported).
99
+
100
+ ## Config
101
+
102
+ `fast-lint.config.ts` (or `.mts`/`.mjs`/`.js`, evaluated by the Node launcher) or `fast-lint.config.json`, in the
103
+ current directory or passed with `--config`. Without a config every rule runs as `error` with default options.
104
+
105
+ ```ts
106
+ import { defineConfig } from '@ls-stack/fast-lint'
107
+
108
+ export default defineConfig({
109
+ projects: ['tsconfig.json'],
110
+ ignores: ['dist/**'],
111
+ rules: {
112
+ 'improved-no-unnecessary-condition': ['error', { alternativeForInCondition: 'use objectHasKey' }],
113
+ 'strict-casting': ['warn', { additionalCastFunctions: [{ name: 'castToString', expectedType: 'string' }] }],
114
+ 'no-unnecessary-void-on-promise': 'error',
115
+ },
116
+ overrides: [
117
+ {
118
+ files: ['src/**'],
119
+ ignores: ['**/*.test.ts'],
120
+ rules: { 'strict-casting': 'error', 'no-leaked-conditional-rendering': 'error' },
121
+ },
122
+ ],
123
+ reportUnusedDisableDirectives: true,
124
+ })
125
+ ```
126
+
127
+ Overrides apply in order, like ESLint flat config blocks: a severity-only entry keeps the options set by an earlier
128
+ block. Globs are relative to the config file's directory (`../` for files outside it). Global `ignores` follow ESLint's
129
+ semantics: a pattern matching a directory (`src/gen`, `**/out`, `dist/`, `dist/**`) ignores everything in it, and
130
+ `!pattern` un-ignores files ignored by earlier patterns, but not files inside an ignored directory: to keep a file of
131
+ an ignored directory, ignore its contents with `dir/**/*` instead. An override's `ignores` also accept `!pattern`,
132
+ applied in order. Of the extglobs, only `@(a|b)` is supported (write the others with `{a,b}` or several globs). Rule
133
+ names may carry a plugin prefix (`@ls-stack/strict-casting`), but not both forms of one rule in one block.
134
+
135
+ A script config's default export must be the config object, or a promise of it.
136
+
137
+ ## Disable comments
138
+
139
+ `eslint-disable`, `eslint-enable`, `eslint-disable-line` and `eslint-disable-next-line` comments (and their
140
+ `oxlint-` and `fast-lint-` forms) are honored, with or without a rule list and a `-- description`, so existing
141
+ directives such as `// eslint-disable-next-line @ls-stack/improved-no-unnecessary-condition` keep working. They are
142
+ parsed like ESLint does: the comment text must start with the directive, `disable` and `enable` need a block comment
143
+ (`/* */`), and a multi-line `disable-next-line` block comment applies to the line after the comment ends. Inline
144
+ config comments (`/* eslint rule: "off" */`) are ignored.
145
+
146
+ `--report-unused-disable-directives` (or `reportUnusedDisableDirectives: true` in the config) warns about
147
+ `fast-lint-disable*` directives that suppressed nothing, like ESLint's option of the same name: a directive without a
148
+ rule list when no problem was suppressed, else each listed rule that ran on the file and reported nothing there. Only
149
+ `fast-lint-` directives are checked, since `eslint-` and `oxlint-` ones may belong to rules of those linters. The
150
+ warnings have no rule id and no fix. Under `--quiet` they are hidden, and they aren't computed unless
151
+ `--max-warnings` is given (warn rules don't run then, so their directives would look unused).
152
+
153
+ ### Running alongside ESLint
154
+
155
+ When these rules are moved out of ESLint (set to `off` there, or their plugin removed), ESLint sees their
156
+ `eslint-disable` comments as unused (`reportUnusedDisableDirectives`, a warning by default in ESLint 9), and
157
+ `eslint --fix` deletes them, or as errors for an unknown rule when the plugin isn't loaded. Then fast-lint reports the
158
+ problems again. Either write these rules' directives as `fast-lint-disable*` (or `oxlint-disable*`), which ESLint
159
+ ignores, or set `linterOptions: { reportUnusedDisableDirectives: 'off' }` in the ESLint config, which also stops
160
+ reporting directives that are unused for the remaining ESLint rules.
161
+
162
+ ## Differences from ESLint
163
+
164
+ - `--fix` applies one pass of non-overlapping fixes (ESLint repeats up to 10 passes).
165
+ - `improved-no-unnecessary-condition` reports nothing in a project without `strictNullChecks` (or `strict`), where
166
+ every type may be nullish.
167
+ - Types come from tsrs (TypeScript 7.1-dev semantics); printed types in messages can differ slightly from TS 5.x.
168
+ - Prebuilt binaries ship for macOS (arm64, x64), Linux (x64, arm64; glibc >= 2.17, not musl/Alpine) and Windows
169
+ x64 (also used on Windows arm64, through emulation). Elsewhere, set `FAST_LINT_BINARY` to a `cargo build --release`
170
+ binary. The launcher prints the package version for `--version`; the binary itself prints the crate version.
171
+
172
+ ## Development
173
+
174
+ ```sh
175
+ cargo test # rule tests (tests/*.rs)
176
+ cargo clippy --all-targets
177
+ node scripts/build.mjs # release binaries for every platform into dist/<platform>-<arch>/
178
+ node scripts/build.mjs --clean linux-x64 win32-x64 # some platforms, deleting their build dirs afterwards
179
+ ```
180
+
181
+ Cross-compiling from macOS needs the Rust targets (`rustup target add x86_64-apple-darwin
182
+ x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-pc-windows-msvc`), `zig` + `cargo-zigbuild` for Linux, and
183
+ `cargo-xwin` + Homebrew `llvm` (for `llvm-lib`) for Windows. Each platform takes about 2 minutes (fat LTO).
184
+
185
+ The first build fetches tsrslint's git dependency, including its `typescript-go` submodule (~2 GB, cached in
186
+ `~/.cargo/git`). tsrslint is pinned by commit in `Cargo.toml`; `Cargo.lock` pins the tsrs commit it was tested with.
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { constants } from 'node:os'
5
+ import { dirname, resolve } from 'node:path'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+
8
+ const scriptConfigNames = [
9
+ 'fast-lint.config.ts',
10
+ 'fast-lint.config.mts',
11
+ 'fast-lint.config.mjs',
12
+ 'fast-lint.config.js',
13
+ ]
14
+
15
+ const scriptConfigExtensions = ['.ts', '.mts', '.cts', '.mjs', '.cjs', '.js']
16
+
17
+ function binaryPath() {
18
+ if (process.env.FAST_LINT_BINARY) return process.env.FAST_LINT_BINARY
19
+
20
+ const platform = `${process.platform}-${process.arch}`
21
+ const exe = process.platform === 'win32' ? 'fast-lint.exe' : 'fast-lint'
22
+ const candidates =
23
+ platform === 'win32-arm64' ? [platform, 'win32-x64'] : [platform]
24
+
25
+ for (const candidate of candidates) {
26
+ const path = fileURLToPath(
27
+ new URL(`../dist/${candidate}/${exe}`, import.meta.url),
28
+ )
29
+ if (existsSync(path)) return path
30
+ }
31
+
32
+ throw new Error(
33
+ `no prebuilt binary for ${platform}. Build one with \`cargo build --release\` in packages/fast-lint and point FAST_LINT_BINARY at it`,
34
+ )
35
+ }
36
+
37
+ function findConfigFlag(args) {
38
+ let found
39
+
40
+ for (let i = 0; i < args.length; i++) {
41
+ const arg = args[i]
42
+ if (arg === undefined) continue
43
+
44
+ if (arg === '-c' || arg === '--config') {
45
+ found = { index: i, length: 2, path: args[i + 1] }
46
+ i++
47
+ } else if (arg.startsWith('--config=')) {
48
+ found = { index: i, length: 1, path: arg.slice('--config='.length) }
49
+ }
50
+ }
51
+
52
+ return found
53
+ }
54
+
55
+ function isScriptConfig(path) {
56
+ return scriptConfigExtensions.some((ext) => path.endsWith(ext))
57
+ }
58
+
59
+ async function loadScriptConfig(path) {
60
+ const module = await import(pathToFileURL(path).href)
61
+
62
+ if (!('default' in module)) {
63
+ throw new Error(`${path} has no default export`)
64
+ }
65
+
66
+ const config = await module.default
67
+
68
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
69
+ throw new Error(
70
+ `${path}: the default export must be a config object or a promise of one`,
71
+ )
72
+ }
73
+
74
+ return JSON.stringify(config)
75
+ }
76
+
77
+ function hasInlineConfig(args) {
78
+ return args.some(
79
+ (arg) => arg === '--config-json' || arg.startsWith('--config-json='),
80
+ )
81
+ }
82
+
83
+ function packageVersion() {
84
+ const packageJson = JSON.parse(
85
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
86
+ )
87
+ return packageJson.version
88
+ }
89
+
90
+ async function resolveArgs(args) {
91
+ if (hasInlineConfig(args)) return args
92
+
93
+ const flag = findConfigFlag(args)
94
+
95
+ if (flag) {
96
+ if (!flag.path || !isScriptConfig(flag.path)) return args
97
+
98
+ const path = resolve(flag.path)
99
+ const rest = [...args]
100
+ rest.splice(flag.index, flag.length)
101
+
102
+ return [
103
+ ...rest,
104
+ '--config-json',
105
+ await loadScriptConfig(path),
106
+ '--config-root',
107
+ dirname(path),
108
+ ]
109
+ }
110
+
111
+ const found = scriptConfigNames
112
+ .map((name) => resolve(name))
113
+ .find((path) => existsSync(path))
114
+
115
+ if (!found) return args
116
+
117
+ return [
118
+ ...args,
119
+ '--config-json',
120
+ await loadScriptConfig(found),
121
+ '--config-root',
122
+ dirname(found),
123
+ ]
124
+ }
125
+
126
+ const rawArgs = process.argv.slice(2)
127
+
128
+ if (rawArgs.includes('-v') || rawArgs.includes('--version')) {
129
+ console.log(packageVersion())
130
+ process.exit(0)
131
+ }
132
+
133
+ const isHelp = rawArgs.includes('-h') || rawArgs.includes('--help')
134
+
135
+ let exe
136
+ let args
137
+
138
+ try {
139
+ exe = binaryPath()
140
+ args = isHelp ? rawArgs : await resolveArgs(rawArgs)
141
+ } catch (error) {
142
+ console.error(
143
+ `fast-lint: ${error instanceof Error ? error.message : String(error)}`,
144
+ )
145
+ process.exit(2)
146
+ }
147
+
148
+ const child = spawn(exe, args, { stdio: 'inherit' })
149
+
150
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']
151
+
152
+ for (const signal of signals) {
153
+ process.on(signal, () => child.kill(signal))
154
+ }
155
+
156
+ child.on('error', (error) => {
157
+ const hint =
158
+ process.platform === 'linux' &&
159
+ !process.env.FAST_LINT_BINARY &&
160
+ 'code' in error &&
161
+ error.code === 'ENOENT'
162
+ ? ' (the prebuilt Linux binaries need glibc >= 2.17; on musl/Alpine point FAST_LINT_BINARY at a binary built there)'
163
+ : ''
164
+ console.error(`fast-lint: failed to run ${exe}: ${error.message}${hint}`)
165
+ process.exit(2)
166
+ })
167
+
168
+ child.on('exit', (code, signal) => {
169
+ if (signal) {
170
+ process.exitCode = 128 + (constants.signals[signal] ?? 0)
171
+
172
+ if (signals.includes(signal)) {
173
+ process.removeAllListeners(signal)
174
+ process.kill(process.pid, signal)
175
+ return
176
+ }
177
+
178
+ process.exit()
179
+ }
180
+
181
+ process.exit(code ?? 2)
182
+ })
Binary file
Binary file
Binary file
Binary file
Binary file
package/lib/index.d.ts ADDED
@@ -0,0 +1,51 @@
1
+ export type Severity = 'off' | 'warn' | 'error' | 0 | 1 | 2
2
+
3
+ type RuleEntry<Options> = Severity | [Severity] | [Severity, Options]
4
+
5
+ export type ImprovedNoUnnecessaryConditionOptions = {
6
+ alternativeForInCondition?: string
7
+ }
8
+
9
+ export type StrictCastingOptions = {
10
+ additionalCastFunctions?: {
11
+ name: string
12
+ expectedType: 'string' | 'number'
13
+ }[]
14
+ allowNullishCasting?: boolean
15
+ allowInvalidCasting?: boolean
16
+ allowAnyCasting?: boolean
17
+ allowUnknownCasting?: boolean
18
+ }
19
+
20
+ export type NoLeakedConditionalRenderingOptions = {
21
+ reactVersion?: string
22
+ }
23
+
24
+ type RuleOptions = {
25
+ 'improved-no-unnecessary-condition': ImprovedNoUnnecessaryConditionOptions
26
+ 'strict-casting': StrictCastingOptions
27
+ 'no-unnecessary-void-on-promise': Record<string, never>
28
+ 'no-leaked-conditional-rendering': NoLeakedConditionalRenderingOptions
29
+ }
30
+
31
+ export type RulesConfig = {
32
+ [Name in keyof RuleOptions as Name | `${string}/${Name}`]?: RuleEntry<
33
+ RuleOptions[Name]
34
+ >
35
+ }
36
+
37
+ export type Override = {
38
+ files: string[]
39
+ ignores?: string[]
40
+ rules?: RulesConfig
41
+ }
42
+
43
+ export type FastLintConfig = {
44
+ projects?: string[]
45
+ ignores?: string[]
46
+ rules?: RulesConfig
47
+ overrides?: Override[]
48
+ reportUnusedDisableDirectives?: boolean
49
+ }
50
+
51
+ export function defineConfig(config: FastLintConfig): FastLintConfig
package/lib/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ export function defineConfig(config) {
2
+ return config
3
+ }
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@ls-stack/fast-lint",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "description": "Type-aware lint rules of @ls-stack/extended-lint running natively on tsrs, the Rust port of the TypeScript checker",
6
+ "type": "module",
7
+ "bin": {
8
+ "fast-lint": "./bin/fast-lint.mjs"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./lib/index.d.ts",
13
+ "default": "./lib/index.mjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "bin",
18
+ "lib",
19
+ "dist"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "github:lucaslos/eslint-plugin-extended-lint"
24
+ },
25
+ "author": "Lucas Santos",
26
+ "engines": {
27
+ "node": ">=22.18.0"
28
+ },
29
+ "scripts": {
30
+ "test": "cargo test",
31
+ "lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings",
32
+ "build": "pnpm test && pnpm lint && pnpm run build:no-test",
33
+ "build:no-test": "node scripts/build.mjs",
34
+ "pre-publish": "pnpm build"
35
+ }
6
36
  }