Conversation
A `diagnosticSeverity` key naming a rule the running build does not provide was accepted in silence, so a project pinning a rule at `error` believed it was enforced while nothing ran. The same plugin block is read by the JS `@effect/language-service`, whose rule set differs, which is how a config drifts into naming rules this build has never had. Validate the configured names when `compilerOptions.plugins` is parsed, so the diagnostic is anchored on the offending key in the config file that declares it and reaches both `tsc` and the language server through the config-file parsing diagnostics. Both the top-level map and the map inside each `overrides` entry are covered, and a near miss carries the intended name. The diagnostic defaults to `warning` and is configured through `diagnosticSeverity.unknownRuleName`, alongside `unusedDirective` as a severity key that is not a rule in the registry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The enable and severity gate was read from the options of the file being parsed, which is built before any extends hop is merged. Three measured consequences: an inherited `diagnostics: false` was ignored, an inherited `diagnosticSeverity.unknownRuleName` did not silence a name declared in a child, and a package could not silence a name declared in a base config it does not own. Split the work across the two seams where each half is actually available. The offending node exists only while its own file is parsed, so that phase now reports unconditionally and keeps the anchor. The setting that governs the report belongs to the merged configuration, so a second callback runs from parseJsonConfigFileContentWorker once the chain is merged and drops or recategorizes what the first phase produced. Register the callbacks in the tests through the etscheckerhooks init rather than assigning the globals from parallel subtests, which `go test -race` reported as 25 data races. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
FinalizeDiagnostics receives the whole config-error slice and returns a replacement, so it holds drop authority over diagnostics it does not own, restrained only by a code predicate. The rest of the suite reads the Effect diagnostics through a filter that would hide the loss of that predicate, so a regression dropping every other config error in the file could land green. Assert on the unfiltered slice instead, with an unknown compiler option alongside an unknown rule name at `unknownRuleName: "off"`, which is the arm that rebuilds the slice. Removing the predicate turns this test red. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a diagnostic for a
diagnosticSeveritykey that names a rule the running build does not provide. Such a key was previously accepted in silence, so a project pinning a rule aterrorbelieved it was enforced while nothing ran.For example, this configuration now reports:
{ "compilerOptions": { "plugins": [ { "name": "@effect/language-service", "diagnosticSeverity": { "floatingEffect": "error", "importFromBarrel": "error" } } ] } }A near miss carries the intended name instead:
Both the top-level
diagnosticSeveritymap and the map inside eachoverridesentry are covered.How it is wired
Validation runs in two phases, because the two things it needs become available at different points in config parsing.
The offending node exists only while the file that declares it is being parsed, before any
extendshop is merged — soRegisterValidateEffectPluginOptionsCallback, invoked fromonPropertySetwhencompilerOptions.pluginsis set, reports every name that does not resolve and anchors it on the key in the file that declares it. That includes a base config reached throughextends, which is where a shared plugin block usually lives.Whether the diagnostic is enabled, and at which severity, is a property of the merged configuration — so
RegisterFinalizeEffectPluginDiagnosticsCallback, invoked fromparseJsonConfigFileContentWorkeronce the chain is merged, drops or recategorizes what the first phase produced. This is what makesdiagnostics: falseanddiagnosticSeverity.unknownRuleNamework when they are inherited rather than declared in the file carrying the offending name. Splitting it this way is load-bearing: a single-phase check reads the declaring file's unmerged options, which silently ignores an inheriteddiagnostics: falseand leaves a package unable to silence a name declared in a base config it does not own.Both hooks sit beside the existing
RegisterMergeCompilerOptionsCallbackin the tsoptions patch. The diagnostic reachestsc, the language server,--buildand the incremental path through the config-file parsing diagnostics.The accepted name set is
rules.Allplus the severity keys that are not rules in the registry, which isunusedDirectiveand the newunknownRuleName; both are now named constants so the two lists cannot drift apart.Severity
The diagnostic defaults to
warningand is configured throughdiagnosticSeverity.unknownRuleNamelike any other, so"unknownRuleName": "off"silences it. Awarningis deliberate rather than anerror: the same plugin block is read by the JS@effect/language-service, whose rule set differs, so a hard failure would break those projects on upgrade. Note that with the defaultignoreEffectWarningsInTscExitCode: falsea warning already failstsc. One--buildconsequence is worth stating plainly: because these diagnostics land in the config-file parsing diagnostics, an offending config keeps its project out of date on everytsc --build, so every project extending a shared base with a bad key re-typechecks each invocation. That is bounded — measured, there is no cascade to dependent projects — and it matches how every existing warning-severity Effect rule already behaves. Whether warnings should count toward that flag is a general policy question rather than one specific to this diagnostic, so it is left alone here and noted in the issue.Deliberately out of scope
An invalid severity value.
ParseSeverityreturnsSeverityErrorfor any unrecognized string, so"errror"promotes a rule toerrorrather than disabling it. That is loud and wrong rather than silent, so it wants its own decision about whether to report or to reject, and is noted as follow-up in the issue.Also out of scope, both noted in the issue: the
additionalPropertiesin the shipped JSON schema still accepts any key with a valid severity value, andunknownRuleName/unusedDirectiveare absent from the generateddiagnosticSeverityproperties (adding non-rule keys there is a change to a published artifact and a separate call); and the raw-JSON config path (parseOwnConfigOfJson) is unhooked, because with no source file there is no node to anchor on and a location-less warning is worse than none.Tests
internal/effectconfigcheckunit tests cover the top-level map, anoverridesentry, anextendschain, the spelling suggestion,offanderrorseverity, an unrelated plugin,diagnostics: false, and four inheritance cases: a base silencing a child throughunknownRuleName, a base silencing a child throughdiagnostics: false, a child silencing a name declared in a base, and a child raising the severity of a name declared in a base while the anchor stays on the base.effect-v4fixturesunknownRuleName,unknownRuleName_validandunknownRuleName_overridescarry baselines.internal/effecttest/runner.gonow surfacesparsedConfig.Errorsalongside the per-file diagnostics, because a diagnostic reported against the tsconfig never reaches the per-file collections. No existing baseline changed.No generated docs, schema, metadata or Oxlint preset changed, because
unknownRuleNameis not a rule in the registry, which matches howunusedDirectiveis handled. Includes a minor changeset.Validation:
pnpm setup-repo,pnpm lint(0 issues, deadcode clean),pnpm check,pnpm test, andgo test -race ./internal/effectconfigcheck/....pnpm testreports Go 21 packages ok / 0 FAIL, and vitest3 failed | 122 passed; those three failures are pre-existing on macOS intest/experimental-oxlint.test.ts, where the expectation is built from anmkdtemppath under/var/folders/...while the code under test returns the realpath/private/var/folders/.... They are identical on an unmodified checkout ofmainatae1ed026.Closes #747
🤖 Generated with Claude Code