Skip to content

Report unused operators by default, and remove --also operators - #157

Open
Komoszek wants to merge 26 commits into
mainfrom
claude/vigilant-goldberg-exgu1n
Open

Komoszek wants to merge 26 commits into
mainfrom
claude/vigilant-goldberg-exgu1n

Conversation

@Komoszek

@Komoszek Komoszek commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

ciach skipped operators unless --also operators was set, and then it reported every operator that only other libraries used. The reference search misses those uses: it finds a + b only in the library that declares +, it finds no relational pattern such as case < 0, and for == it lists every use of Object.== in the SDK. This pull request finds the uses of operators through the analysis server, reports unused operators by default and removes --also operators. It builds on the renaming that #151 added for call methods.

How it works

  • Hierarchy. For each operator, operator_hierarchies.dart collects the operators that it overrides and that override it, through every supertype and subtype of its class. An operator that a subtype inherits along with it joins too: abstract class Both implements Left, Right {} joins Left.+ and Right.+. When one of them is outside the analysis root, such as Object.== or ListBase.[], the operator is never reported: its uses can't be found, and the SDK calls == from Set and Map.
  • Rename. In unsaved copies of the files, the operator keyword of every member becomes o$$$$$$$ and the name becomes spaces, so no position moves. Every new error is a use, and it counts for every member of the hierarchy, as the reference search counts a call through a subclass.
  • Rounds. Each hierarchy is renamed in a round of its own: after a + b fails, (a + b) * 2 has no error at *.
  • The call and operator lookups each restore the files that they changed, through withUnsavedChanges. While they change files, the open files are closed, because the server analyzes a change about three times faster then.

LspClient.didChange now opens a document that isn't open, and waitForChangesAnalyzed waits through a hover request, which the server can answer only after it resolves the changed file. The foldingRange request that it used before could be answered before the new errors were published.

--also operators and also: [operators] are errors that say to drop the value, and --no-operators says to use // ciach:ignore. MIGRATION.md covers the change, including FinderOptions.skipOperators for library users.

Cost

Each round costs one analysis of the libraries that import the renamed files:

  • xml 7.1.0: 13-19 s before, 18 s after, with 10 fewer findings, all of them used operators such as XPathDuration.+ and XmlCache.[]. Without closing the files, it took 32 s.
  • fixnum 1.1.1: 0.7 s before, 3 s after, with 6 fewer findings, such as IntX.%.

Limits

  • A use through dynamic gives no error, as for every member.
  • A use of + through B also counts for C.+ when C is a sibling of B, although the use can't call it. So with --also overrides, C.+ isn't reported, even when nothing calls it.

Tests

test/finder/operators_test.dart scans example/lib/scenarios/operators*.dart, which cover every operator, compound assignments, relational patterns, extensions, enums, mixins, extension types, overrides through one and two supertypes, and an override in a file that isn't scanned. Small packages written by the tests cover a three-level override chain, an SDK operator that a class overrides through an abstract class that doesn't declare it, a class that inherits + from two interfaces, and an operator whose name follows operator on the next line. Without renaming the whole hierarchy, the first test also reports Round.-, which code uses only through its subclass. Without the walk through all supertypes, the SDK operator test reports Leaf.[] and Leaf.[]=. Without joining the operators that a subtype inherits, the two-interface test reports Left.+ and Right.+, which code uses through Both. test/lsp/lsp_client_test.dart checks that closeAll closes every open document.

With Dart 3.13.5, dart analyze --fatal-infos, dart format and all tests pass. In website/, images/og.png fails on this machine, as noted in #151.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5

The reference search misses the uses of an operator in other libraries and
relational patterns, and lists every use of Object.== for a == override.
Each operator's override hierarchy is now renamed in unsaved copies of its
files, one hierarchy per round, and every new error counts as a use.
--also operators is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
…the review findings

- Collect an operator's hierarchy through every supertype of the declaring
  type, not only the one member that the super request names, in
  operator_hierarchies.dart, with the requests in a pool.
- Open a file before sending changed content for it, so that an override in
  a file that the scan skips no longer crashes the run.
- Wait for the analysis behind a hover request, which needs the resolved
  file, instead of a folding range request, which the server can answer
  before it reports that it is analyzing.
- Record a failed lookup once, and report the uses only after the files are
  restored.
- Give every member of a hierarchy the same new name, padded with spaces.
- Share the check for a removed also value, which now trims each value.
- Update example/README.md with a real run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
Base automatically changed from ccr-6e5b8698-g8sdgs to main October 17, 2026 19:47
…rchy lookups

A comparison returns a bool, which no operator of a class, mixin, enum or
extension type can take as its receiver, so a failed comparison hides no
other use. Hierarchies whose operators all return bool now share a round,
one per operator name, and the text of each error says which one it belongs
to. The lookups of an operator hierarchy are cached per type, and an
override found through the implementation request gets no request of its
own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
- LspClient.didChange opens a document that isn't open, instead of
  UnsavedChanges opening it before each change.
- The call and operator lookups share one UnsavedChanges, and
  withImplicitUses restores the files once. UnsavedChanges.show replaces
  change, restoreExcept and revert.
- Both lookups return an entry for every candidate that they checked, so
  withImplicitUses merges them in one way.
- One DeclarationName class replaces OperatorDeclaration and the record for
  a type's name, the root check uses analysisRootContains, and the symbol
  lookups use symbolCovering and symbolNamedAt, which move to symbols.dart
  from superclasses.dart.
- The rename of each hierarchy is computed once, and the rule for shared
  rounds sits next to the rounds.
- The also values are split in one place, in option_values.dart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
- Restore the files that the call lookup changed before the operator lookup
  asks about positions in them, and send the content on disk in a finally.
- Merge the hierarchies that share a member, so that the result doesn't
  depend on the order of the operators.
- Leave out == before any lookup: it always overrides Object.==.
- Share rounds only between hierarchies whose members all return bool by
  their outlines, and never for [] and []=, whose errors cover the index.
- Leave out a hierarchy when a line break or an offset outside the content
  on disk would move positions in the rename, instead of crashing or
  shifting lines.
- Use classes instead of record typedefs, a pattern instead of a when
  guard, one text for the operators advice, and restore the doc comment of
  PositionGeometry.
- MIGRATION.md says that library code drops FinderOptions.skipOperators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
- Plan the operator rounds before the call lookup changes any file, so the
  files are no longer restored and analyzed between the two lookups.
- Rename the operator keyword and blank the name through the semantic
  tokens, which leaves line breaks and comments in between as they are, so
  such operators are checked too.
- Share the outline, symbol and token lookups: lastCovering, Outline.namedAt
  (moved from overrides.dart), lastCodeBefore, SourceIndex.offsetsOf and
  contentWith, which the call lookup uses as well.
- Record a failed restore whenever it happens, with one helper that returns
  the uses of both lookups.
- Drop the namespace check: the server gives an extension no type
  hierarchy, and an extension type has one.
- Check the removed operators value directly, and add writeTempPackage to
  test/support for the finder tests that write a package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
Comment thread MIGRATION.md Outdated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
Comment thread CHANGELOG.md Outdated
@Komoszek

Copy link
Copy Markdown
Contributor Author

Are operatora talent into account in --no-public and exported-packages?

Comment thread lib/src/lsp/lsp_client.dart Outdated
Comment thread example/lib/scenarios/operators_ring.dart Outdated
Comment thread lib/src/fetch/operator_hierarchies.dart
… and test an SDK operator reached through a class that doesn't declare it

The `supertypes` request keeps its result in a local, as its neighbors do: without the type argument, `_guard` infers a non-nullable list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
Every operator hierarchy gets a round of its own, so the rounds that `bool` operators shared, and the attribution of errors by their text, are gone. The call and operator lookups each restore their own unsaved changes, so the operator lookup no longer plans its rounds ahead. The hierarchy lookup asks every member for its overrides and drops the caches that the shortcut needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
The analysis server analyzes each change about three times faster while the files are closed: a scan of `xml` 7.1.0 takes 18 s instead of 32 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
else if (usesOf[candidate] case final uses?)
(candidate, [...refs[i], ...uses]),
];
await semanticTokensFor(client, usesOf.values.toList());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now semantin tokens are always fetched. Is it intentional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The call is unconditional now, but it fetches tokens only for the files of the uses that the two lookups found, and it skips each file whose tokens are already cached. When a lookup fails, it adds no uses, and when neither finds any, semanticTokensFor returns before it sends a request. That is what main did for the call uses: it skipped the call only when the lookup failed, and then there were no uses to fetch tokens for. The tokens are still needed, because the verdict reads the comments and annotations around each use, as it does for the references that references returns.


Generated by Claude Code

Comment thread lib/src/fetch/operator_uses.dart Outdated
Comment thread lib/src/fetch/operator_hierarchies.dart Outdated
Comment thread lib/src/fetch/operator_hierarchies.dart Outdated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
`Location` holds the same URI and range and already compares by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
.new(textDocument: .new(uri: changed.toString())),
),
);
await hover(changed, const .new(line: 0, character: 0));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was it changed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because foldingRange didn't make the wait reliable. The server answers foldingRange from the parsed file alone, so the answer could arrive before the server reported that it had started analyzing the change. The wait then ended at once, and a round read the errors from before the rename. During this pull request, the operator tests reported used operators in some runs because of it. The server can answer hover only after it resolves the changed file, and it reports isAnalyzing: true before it starts. So once the hover answer arrives, waiting until the server is idle covers the whole analysis.


Generated by Claude Code

Comment thread lib/src/fetch/unsaved_changes.dart Outdated
/// starts a new analysis of everything that imports it.
final _changed = <String>{};

/// Sends [texts], by path, and restores every other changed file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does it mean?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

show gives the server new content for some files, and it puts back the content on disk of every file that it changed before and that this call leaves out. For example, round 1 calls show({a: renamedA}), and round 2 calls show({b: renamedB}). Round 2 then sends renamedB for b and the content on disk for a, so only b's operator stays renamed. show(const {}) restores everything.

ae58b34 rewrites the doc comment to say this: "Sends [texts], the new content of files by path, to the server. A file that an earlier call changed and [texts] leaves out gets its content on disk back."


Generated by Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
);
}

/// Returns the ranges of [errors] in [path] that aren't at a declaration's

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand. Is the method name correct? Is the comment understandable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither was good: _outsideNames said where the errors aren't, not what the method returns. It drops the errors at a declaration's name, because those come from the declaration itself, such as a class that no longer implements its interface after its operator is renamed, and they aren't uses.

Done in 9c81cb3: the method is now _rangesOfUses, documented as "Returns the ranges of [errors] in [path] that mark uses. An error at a declaration's name isn't a use: it comes from the declaration itself, such as a class that no longer implements its interface." The doc of newErrors now says only that it returns the ranges of the new errors that mark uses.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at other methods and comments and recheck them

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2eeac44. I reread every name and comment that this pull request adds, and renamed those that didn't say what they do:

  • unsaved_changes.dart: show → send, newErrors → usesByPath, _errors → _publishedErrors, _changed → _changedPaths, _change → _send.
  • operator_hierarchies.dart: _overriddenOf → _overriddenBy (the operators that a member overrides), _overriddenIn → _operatorsInSupertypes, _operatorIn → _declaredOperator, _isInside → _isInsideAnalysisRoot, and OperatorHierarchy.operators → candidates, because it holds the candidates among the members. The parameters of type _OperatorKind are now all named kind.
  • operator_uses.dart: _newName → _renamedKeyword, _renamed → _renamedContent.
  • semantic_tokens.dart: lastCodeBefore → lastNonCommentBefore.

Their doc comments now match the new names. For example, OperatorHierarchy is "One override hierarchy: operators that override each other", and _publishedErrors says that warnings and hints are left out because a use of a renamed declaration is always an error. All tests pass.


Generated by Claude Code

`abstract class Both implements Left, Right {}` inherits `+` from both, so a use of `+` through `Both` needs both, and they are renamed in one round. Before, each was renamed alone, the use compiled through the other one, and both were reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5
Comment thread lib/src/fetch/unsaved_changes.dart Outdated

/// Returns the ranges of the new errors that mark uses, by path, once the
/// server has analyzed the changes.
Future<Map<String, Set<Range>>> usesByPath() async {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name doesn't actually reflect what it does. It makes sense obył when news error === use from the perspective of the caller. Please give 5 proposals how we could improve that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed: the method returns the ranges of the errors that the changes added, without those at a declaration's name, and only the callers read them as uses. Five options, each paired with a name for the private helper, which has the same problem:

  1. newErrorRanges(), with the helper _rangesNotAtDeclarationNames(path, errors). The doc says that errors at declaration names are left out, and the callers keep the name usesByPath for their variable.
  2. addedErrorRanges(), with the same helper. "Added" says that the errors are compared with those before the changes.
  3. newErrorRangesByPath(), with the same helper, if the key of the map should show in the name.
  4. newErrorsOutsideDeclarations(), with the helper _outsideDeclarationNames(path, errors). This names the filter, but it is long, and it says where the errors aren't rather than what is returned.
  5. Split it: newErrors() returns the new Diagnostics by path, and a separate withoutDeclarationNames(errors) in each caller drops those at declaration names. The names are exact, but both callers repeat the filter call.

I'd take 1: it says what comes back, and the filter is a detail of the doc comment, as the left-out warnings are in _publishedErrors. Which one should I use?


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, shouldn't this helper _rangesNotAtDeclarationNames be reused for both implicit calls and operators? Those things are pretty similar

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f2b8995: the method is now newErrorRanges(), and its helper is _rangesNotAtDeclarationNames.

It is already shared: both findImplicitCalls and findOperatorUses call changes.newErrorRanges(), and that is the only caller of the helper. On main, implicit_calls.dart had its own copy of this filter, _usesAmong. This pull request moved it into UnsavedChanges, so that both lookups use one copy.


Generated by Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWsUwSKJvdgiVh4gAHboH5

This branch was successfully deployed

1 active deployment
Preview — f2b89955 Deployed Oct 23, 2026 by Komoszek via Check and deploy preview #364
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants