From ce0891b8aee51f897c09e733a5a7e4ac0648954e Mon Sep 17 00:00:00 2001 From: Cam McHenry Date: Sun, 21 Sep 2025 23:04:17 -0400 Subject: [PATCH 1/2] feat: add support for headless rule config --- cmd/tsgolint/headless.go | 2 +- cmd/tsgolint/payload.go | 3 +- e2e/__snapshots__/snapshot.test.ts.snap | 65 ++++++++++++++ .../basic/rules/no-floating-promises/void.ts | 4 + e2e/snapshot.test.ts | 41 +++++++++ .../no_floating_promises.go | 85 +++++++++++++++---- 6 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 e2e/fixtures/basic/rules/no-floating-promises/void.ts diff --git a/cmd/tsgolint/headless.go b/cmd/tsgolint/headless.go index bcef4854..1b094a01 100644 --- a/cmd/tsgolint/headless.go +++ b/cmd/tsgolint/headless.go @@ -286,7 +286,7 @@ func runHeadless(args []string) int { rules[i] = linter.ConfiguredRule{ Name: r.Name, Run: func(ctx rule.RuleContext) rule.RuleListeners { - return r.Run(ctx, nil) + return r.Run(ctx, headlessRule.Options) }, } } diff --git a/cmd/tsgolint/payload.go b/cmd/tsgolint/payload.go index 8be8f950..cd87c0f2 100644 --- a/cmd/tsgolint/payload.go +++ b/cmd/tsgolint/payload.go @@ -28,7 +28,8 @@ type headlessConfig struct { } type headlessRule struct { - Name string `json:"name"` + Name string `json:"name"` + Options any `json:"options,omitempty"` } func deserializePayload(data []byte) (*headlessPayload, error) { diff --git a/e2e/__snapshots__/snapshot.test.ts.snap b/e2e/__snapshots__/snapshot.test.ts.snap index 0d9d84df..056ef46d 100644 --- a/e2e/__snapshots__/snapshot.test.ts.snap +++ b/e2e/__snapshots__/snapshot.test.ts.snap @@ -722,6 +722,34 @@ exports[`TSGoLint E2E Snapshot Tests > should generate consistent diagnostics sn "rule": "require-await", "suggestions": [], }, + { + "file_path": "fixtures/basic/rules/no-floating-promises/void.ts", + "fixes": [], + "message": { + "description": "This rule requires the \`strictNullChecks\` compiler option to be turned on to function correctly.", + "id": "noStrictNullCheck", + }, + "range": { + "end": 0, + "pos": 0, + }, + "rule": "no-unnecessary-boolean-literal-compare", + "suggestions": [], + }, + { + "file_path": "fixtures/basic/rules/no-floating-promises/void.ts", + "fixes": [], + "message": { + "description": "Function has no 'await' expression.", + "id": "missingAwait", + }, + "range": { + "end": 53, + "pos": 0, + }, + "rule": "require-await", + "suggestions": [], + }, { "file_path": "fixtures/basic/rules/no-for-in-array/index.ts", "fixes": [], @@ -4480,3 +4508,40 @@ If your function does not access \`this\`, you can annotate it with \`this: void }, ] `; + +exports[`TSGoLint E2E Snapshot Tests > supports passing rule config 1`] = ` +[ + { + "file_path": "fixtures/basic/rules/no-floating-promises/void.ts", + "fixes": [], + "message": { + "description": "Promises must be awaited.", + "help": "The promise must end with a call to .catch, or end with a call to .then with a rejection handler.", + "id": "floating", + }, + "range": { + "end": 76, + "pos": 54, + }, + "rule": "no-floating-promises", + "suggestions": [ + { + "fixes": [ + { + "range": { + "end": 58, + "pos": 54, + }, + "text": "await", + }, + ], + "message": { + "description": "Promises must be awaited.", + "help": "The promise must end with a call to .catch, or end with a call to .then with a rejection handler.", + "id": "floating", + }, + }, + ], + }, +] +`; diff --git a/e2e/fixtures/basic/rules/no-floating-promises/void.ts b/e2e/fixtures/basic/rules/no-floating-promises/void.ts new file mode 100644 index 00000000..baa88e6d --- /dev/null +++ b/e2e/fixtures/basic/rules/no-floating-promises/void.ts @@ -0,0 +1,4 @@ +async function returnsPromise() { + return 'value'; +} +void returnsPromise(); \ No newline at end of file diff --git a/e2e/snapshot.test.ts b/e2e/snapshot.test.ts index 3daecf16..acbc87db 100644 --- a/e2e/snapshot.test.ts +++ b/e2e/snapshot.test.ts @@ -145,6 +145,10 @@ async function getTestFiles(testPath: string): Promise { return allFiles; } +function resolveTestFilePath(relativePath: string): string { + return join(FIXTURES_DIR, relativePath); +} + function generateConfig(files: string[], rules: readonly (typeof ALL_RULES)[number][] = ALL_RULES): string { // Headless payload format: // ```json @@ -194,6 +198,43 @@ describe('TSGoLint E2E Snapshot Tests', () => { expect(diagnostics).toMatchSnapshot(); }); + it('supports passing rule config', async () => { + const testFile = resolveTestFilePath('basic/rules/no-floating-promises/void.ts'); + const config = (ignoreVoid: boolean) => ({ + version: 2, + configs: [ + { + file_paths: [testFile], + rules: [ + { + name: 'no-floating-promises', + options: { ignoreVoid }, + }, + ] + } + ], + }) + + let output: Buffer; + output = execFileSync(TSGOLINT_BIN, ['headless'], { + input: JSON.stringify(config(false)), + }); + + let diagnostics = parseHeadlessOutput(output); + diagnostics = sortDiagnostics(diagnostics); + + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics).toMatchSnapshot(); + + // Re-run with ignoreVoid=true, should have no diagnostics + output = execFileSync(TSGOLINT_BIN, ['headless'], { + input: JSON.stringify(config(true)), + }); + + diagnostics = parseHeadlessOutput(output); + expect(diagnostics.length).toBe(0); + }); + it.runIf(process.platform === 'win32')( 'should not panic with mixed forward/backslash paths from Rust (issue #143)', async () => { diff --git a/internal/rules/no_floating_promises/no_floating_promises.go b/internal/rules/no_floating_promises/no_floating_promises.go index 2ca913b3..8625a4bf 100644 --- a/internal/rules/no_floating_promises/no_floating_promises.go +++ b/internal/rules/no_floating_promises/no_floating_promises.go @@ -1,6 +1,8 @@ package no_floating_promises import ( + "encoding/json" + "github.com/microsoft/typescript-go/shim/ast" "github.com/microsoft/typescript-go/shim/checker" "github.com/microsoft/typescript-go/shim/scanner" @@ -18,6 +20,70 @@ type NoFloatingPromisesOptions struct { IgnoreVoid *bool } +// parseNoFloatingPromisesOptions converts a loosely-typed options value into a +// fully-populated NoFloatingPromisesOptions struct. Missing fields are filled +// in with defaults, and if the input is not understood, defaults are returned. +func parseNoFloatingPromisesOptions(raw any) NoFloatingPromisesOptions { + // Base defaults + defaults := NoFloatingPromisesOptions{ + AllowForKnownSafeCalls: []utils.TypeOrValueSpecifier{}, + AllowForKnownSafeCallsInline: []string{}, + AllowForKnownSafePromises: []utils.TypeOrValueSpecifier{}, + AllowForKnownSafePromisesInline: []string{}, + CheckThenables: utils.Ref(false), + IgnoreIIFE: utils.Ref(false), + IgnoreVoid: utils.Ref(true), + } + + // Helper to normalize zero-value / nil internals + normalize := func(o NoFloatingPromisesOptions) NoFloatingPromisesOptions { + if o.AllowForKnownSafeCalls == nil { + o.AllowForKnownSafeCalls = defaults.AllowForKnownSafeCalls + } + if o.AllowForKnownSafeCallsInline == nil { + o.AllowForKnownSafeCallsInline = defaults.AllowForKnownSafeCallsInline + } + if o.AllowForKnownSafePromises == nil { + o.AllowForKnownSafePromises = defaults.AllowForKnownSafePromises + } + if o.AllowForKnownSafePromisesInline == nil { + o.AllowForKnownSafePromisesInline = defaults.AllowForKnownSafePromisesInline + } + if o.CheckThenables == nil { + o.CheckThenables = defaults.CheckThenables + } + if o.IgnoreIIFE == nil { + o.IgnoreIIFE = defaults.IgnoreIIFE + } + if o.IgnoreVoid == nil { + o.IgnoreVoid = defaults.IgnoreVoid + } + return o + } + + if raw == nil { + return defaults + } + + switch v := raw.(type) { + case NoFloatingPromisesOptions: + return normalize(v) + case map[string]any: + // Marshal then unmarshal to leverage JSON tag inference (field names) + b, err := json.Marshal(v) + if err != nil { + return defaults + } + var o NoFloatingPromisesOptions + if err := json.Unmarshal(b, &o); err != nil { + return defaults + } + return normalize(o) + default: + return defaults + } +} + var messageBase = "Promises must be awaited." var messageBaseHelp = "The promise must end with a call to .catch, or end with a call to .then with a rejection handler." @@ -87,24 +153,7 @@ func buildFloatingVoidMessage() rule.RuleMessage { var NoFloatingPromisesRule = rule.Rule{ Name: "no-floating-promises", Run: func(ctx rule.RuleContext, options any) rule.RuleListeners { - opts, ok := options.(NoFloatingPromisesOptions) - if !ok { - opts = NoFloatingPromisesOptions{ - AllowForKnownSafeCalls: []utils.TypeOrValueSpecifier{}, - AllowForKnownSafeCallsInline: []string{}, - AllowForKnownSafePromises: []utils.TypeOrValueSpecifier{}, - AllowForKnownSafePromisesInline: []string{}, - } - } - if opts.CheckThenables == nil { - opts.CheckThenables = utils.Ref(false) - } - if opts.IgnoreIIFE == nil { - opts.IgnoreIIFE = utils.Ref(false) - } - if opts.IgnoreVoid == nil { - opts.IgnoreVoid = utils.Ref(true) - } + opts := parseNoFloatingPromisesOptions(options) isHigherPrecedenceThanUnary := func(node *ast.Node) bool { operator := ast.KindUnknown From 083a2ae22b8081eaa10344bc5fe91c889d265fea Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 04:03:31 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- cmd/tsgolint/payload.go | 2 +- e2e/snapshot.test.ts | 6 +++--- .../no_floating_promises.go | 17 ++++++++--------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/cmd/tsgolint/payload.go b/cmd/tsgolint/payload.go index cd87c0f2..d198e3d5 100644 --- a/cmd/tsgolint/payload.go +++ b/cmd/tsgolint/payload.go @@ -28,7 +28,7 @@ type headlessConfig struct { } type headlessRule struct { - Name string `json:"name"` + Name string `json:"name"` Options any `json:"options,omitempty"` } diff --git a/e2e/snapshot.test.ts b/e2e/snapshot.test.ts index acbc87db..a45e2084 100644 --- a/e2e/snapshot.test.ts +++ b/e2e/snapshot.test.ts @@ -210,10 +210,10 @@ describe('TSGoLint E2E Snapshot Tests', () => { name: 'no-floating-promises', options: { ignoreVoid }, }, - ] - } + ], + }, ], - }) + }); let output: Buffer; output = execFileSync(TSGOLINT_BIN, ['headless'], { diff --git a/internal/rules/no_floating_promises/no_floating_promises.go b/internal/rules/no_floating_promises/no_floating_promises.go index 8625a4bf..d056df19 100644 --- a/internal/rules/no_floating_promises/no_floating_promises.go +++ b/internal/rules/no_floating_promises/no_floating_promises.go @@ -1,8 +1,7 @@ package no_floating_promises import ( - "encoding/json" - + "github.com/go-json-experiment/json" "github.com/microsoft/typescript-go/shim/ast" "github.com/microsoft/typescript-go/shim/checker" "github.com/microsoft/typescript-go/shim/scanner" @@ -11,13 +10,13 @@ import ( ) type NoFloatingPromisesOptions struct { - AllowForKnownSafeCalls []utils.TypeOrValueSpecifier - AllowForKnownSafeCallsInline []string - AllowForKnownSafePromises []utils.TypeOrValueSpecifier - AllowForKnownSafePromisesInline []string - CheckThenables *bool - IgnoreIIFE *bool - IgnoreVoid *bool + AllowForKnownSafeCalls []utils.TypeOrValueSpecifier `json:"allowForKnownSafeCalls"` + AllowForKnownSafeCallsInline []string `json:"allowForKnownSafeCallsInline"` + AllowForKnownSafePromises []utils.TypeOrValueSpecifier `json:"allowForKnownSafePromises"` + AllowForKnownSafePromisesInline []string `json:"allowForKnownSafePromisesInline"` + CheckThenables *bool `json:"checkThenables"` + IgnoreIIFE *bool `json:"ignoreIIFE"` + IgnoreVoid *bool `json:"ignoreVoid"` } // parseNoFloatingPromisesOptions converts a loosely-typed options value into a