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..d198e3d5 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..a45e2084 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..d056df19 100644 --- a/internal/rules/no_floating_promises/no_floating_promises.go +++ b/internal/rules/no_floating_promises/no_floating_promises.go @@ -1,6 +1,7 @@ package no_floating_promises import ( + "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" @@ -9,13 +10,77 @@ 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 +// 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." @@ -87,24 +152,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