feat(editor): graph-pulse on errors + store-aware install / add-source

Three connected improvements to the analyzer-driven diagnostics:

  - **Pulsing halo on broken graph nodes**. Every step / pseudo-
    node (inputs / outputs) carrying an analyzer issue now
    breathes a red (error) or amber (warning) halo on the graph
    tab — operator sees the problem on the canvas without
    flipping to text. Honours prefers-reduced-motion: reduce-
    motion users get a static halo at the same intensity. The
    canvas reads severity via a new `stepIssueSeverity` map on
    FlowEditorController; pseudo-nodes use `__inputs__` /
    `__outputs__` sentinel ids.

  - **Store-aware install button**. The analyzer now takes a
    second closure, `storeCapabilities`, listing what the public
    store can install. Unknown-capability issues only carry the
    "Install …" quick-fix when the bare cap is in that list;
    otherwise the issue carries an "Add source for …" fix
    instead. Resolves the asymmetry the operator reported: the
    Store didn't show `htw.digiscout/onet.lookup` but the editor
    happily offered to install it (and would have failed). The
    install path no longer lies about itself.

  - **Did-you-mean suggestion**. When the unknown cap is within
    edit-distance two of an installed or store cap (different
    spelling — distance-0 stays hidden because that's an install
    case, not a typo), the analyzer emits a `ReplaceLineValueFix`
    suggesting the closest match. Preserves the version
    constraint by reusing the installed spec when present
    (e.g. `text.echi@^0.1` → `text.echo@^0.1`).

New public surface:

  - `AddModuleSourceFix` + `AddModuleSourceCallback`
  - `FlowEditorPage.storeCapabilities` + `onAddModuleSource`
  - `FlowAnalyzer.stepSeverity` + the `kInputsNodeId` /
    `kOutputsNodeId` sentinels
  - `FlowIssueSeverity` enum + `FlowNode.issueSeverity`

Tests:
  - Install fix only when in store
  - AddModuleSourceFix as fallback when not in store
  - Did-you-mean replaces install when a near miss exists
  - stepSeverity populated for both step ids and pseudo-nodes

All 36 editor tests green. Bumped to 0.18.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-09 00:21:48 +02:00
parent f43c1ac6cf
commit efdfa7dd79
10 changed files with 497 additions and 45 deletions

View file

@ -23,12 +23,28 @@ import 'package:yaml/yaml.dart';
import 'quick_fix.dart';
import 'wire_colors.dart';
/// Reserved step-id sentinels the analyzer uses for issues that
/// live in the YAML's pseudo-nodes (`inputs:` and `outputs:`
/// blocks), so the graph canvas can highlight those nodes the
/// same way it highlights a real step.
const String kInputsNodeId = '__inputs__';
const String kOutputsNodeId = '__outputs__';
class FlowAnalyzer extends AbstractAnalyzer {
/// Returns the names of capabilities the operator has installed.
/// A closure so the analyzer always sees the current list
/// without needing a re-create on every Studio rebuild.
/// Returns the names of capabilities the operator has
/// installed. A closure so the analyzer always sees the
/// current list without needing a re-create on every Studio
/// rebuild.
final List<String> Function() availableCapabilities;
/// Returns the names of capabilities the public store knows
/// how to install. Used to decide whether an unknown-cap
/// issue should carry an Install button clicking the
/// button on a capability the hub can't actually fetch would
/// just fail. Null = "no store available" install offered
/// for every unknown cap (legacy behaviour).
final List<String> Function()? storeCapabilities;
/// Quick fixes attached to the most-recent analyze() pass.
/// Keyed by the same `Issue` instances that landed in
/// `analysisResult.issues`; consumers look up fixes per issue
@ -36,16 +52,46 @@ class FlowAnalyzer extends AbstractAnalyzer {
/// disambiguated.
final Map<Issue, List<QuickFix>> _fixesByIssue = {};
FlowAnalyzer({required this.availableCapabilities});
/// Worst-severity per step / pseudo-node from the most-recent
/// pass. The graph canvas reads this to decide which nodes
/// pulse red (error) / amber (warning). Pseudo-nodes for
/// `inputs:` and `outputs:` use [kInputsNodeId] /
/// [kOutputsNodeId] sentinels.
final Map<String, IssueType> _stepSeverity = {};
FlowAnalyzer({
required this.availableCapabilities,
this.storeCapabilities,
});
/// Read-only view of the fixes computed during the last
/// analyze(). Returns an empty list when no fix is known.
List<QuickFix> fixesFor(Issue issue) =>
List.unmodifiable(_fixesByIssue[issue] ?? const []);
/// Worst-severity (`error` beats `warning` beats `info`)
/// recorded per step / pseudo-node during the last analyze().
/// Caller treats absence as "no issue".
Map<String, IssueType> get stepSeverity =>
Map.unmodifiable(_stepSeverity);
void _bumpSeverity(String stepId, IssueType type) {
final current = _stepSeverity[stepId];
if (current == null || _severityRank(type) > _severityRank(current)) {
_stepSeverity[stepId] = type;
}
}
static int _severityRank(IssueType t) => switch (t) {
IssueType.error => 2,
IssueType.warning => 1,
IssueType.info => 0,
};
@override
Future<AnalysisResult> analyze(Code code) async {
_fixesByIssue.clear();
_stepSeverity.clear();
final issues = <Issue>[];
final text = code.text;
if (text.trim().isEmpty) {
@ -54,6 +100,13 @@ class FlowAnalyzer extends AbstractAnalyzer {
final caps = availableCapabilities();
final knownBareCaps = caps.map(_bareCap).toSet();
// Use both bare names AND fully-qualified `<bare>@<version>`
// as Did-you-mean candidates so the suggestion can preserve
// the version constraint when the user already typed one.
final installedFull = caps.toSet();
final storeCaps =
storeCapabilities?.call() ?? const <String>[];
final storeBare = storeCaps.map(_bareCap).toSet();
YamlNode? doc;
try {
@ -85,32 +138,132 @@ class FlowAnalyzer extends AbstractAnalyzer {
if (useValue is! String) continue;
if (knownBareCaps.isEmpty) continue;
if (!knownBareCaps.contains(_bareCap(useValue))) {
final issueLine = useNode.span.start.line;
final stepId = _stepIdFor(step);
final inStore = storeBare.contains(_bareCap(useValue));
// Did-you-mean candidate: scan installed + store bare
// caps for the closest spelling within edit-distance 2.
// Wins over the install button when present typo
// fixes are cheaper than network round-trips.
// Distance-0 (same word) is never a suggestion
// that case means the bare cap IS in the store
// (just not installed yet), so the Install button
// is the right call and the Did-you-mean would
// confusingly say "use the thing you typed".
final didYouMean = _closestCapability(
_bareCap(useValue),
{...knownBareCaps, ...storeBare}
.where((c) => c != _bareCap(useValue))
.toSet(),
);
// Tailor the message: "unknown" reads identical in
// both cases, but the recovery line nudges toward
// the right action.
final message = inStore
? 'Unknown capability "$useValue". '
'Install via the Fix button or check the spelling.'
: didYouMean != null
? 'Unknown capability "$useValue". '
'Did you mean "$didYouMean"?'
: 'Unknown capability "$useValue". '
'Not in the store — install locally with '
'`fai install --link <path>` or check the spelling.';
final issue = Issue(
line: useNode.span.start.line,
message:
'Unknown capability "$useValue". '
'Install the module that provides it, or check the spelling.',
line: issueLine,
message: message,
type: IssueType.error,
);
issues.add(issue);
_fixesByIssue[issue] = [
InstallCapabilityFix(
capability: useValue,
label: 'Install $useValue',
),
];
if (stepId != null) {
_bumpSeverity(stepId, IssueType.error);
}
final fixes = <QuickFix>[];
if (didYouMean != null) {
// Try to preserve the version constraint the user
// already typed.
final versionTail = _versionTail(useValue);
final fullReplacement = installedFull.firstWhere(
(c) => _bareCap(c) == didYouMean,
orElse: () => '$didYouMean$versionTail',
);
fixes.add(
ReplaceLineValueFix(
line: issueLine,
replacement: fullReplacement,
label: 'Use "$fullReplacement"',
),
);
}
if (inStore) {
fixes.add(
InstallCapabilityFix(
capability: useValue,
label: 'Install $useValue',
),
);
} else if (didYouMean == null) {
// Not in store + no near-miss spelling give the
// operator a path to register the module they
// actually have (local clone, internal URL, ).
// Hidden behind the "Did you mean" suggestion when
// present so the suggested fix stays the primary
// action.
fixes.add(
AddModuleSourceFix(
capability: useValue,
label: 'Add source for $useValue',
),
);
}
if (fixes.isNotEmpty) {
_fixesByIssue[issue] = fixes;
}
}
}
}
_checkFieldTypes(doc['inputs'], 'input', issues);
_checkFieldTypes(doc['outputs'], 'output', issues);
_checkFieldTypes(
doc['inputs'],
'input',
issues,
nodeId: kInputsNodeId,
);
_checkFieldTypes(
doc['outputs'],
'output',
issues,
nodeId: kOutputsNodeId,
);
}
return AnalysisResult(issues: issues);
}
void _checkFieldTypes(YamlNode? block, String kind, List<Issue> out) {
/// Resolve the step's `id` field as a string, or null when
/// the step has none (the analyzer doesn't generate
/// synthetic ids a step without an id is itself a
/// validation issue handled elsewhere).
String? _stepIdFor(YamlMap step) {
final id = step.nodes['id']?.value;
return id is String ? id : null;
}
/// Strip the leading `bareCap` so we keep just the
/// `@<version>` part. Caller stitches it back onto the
/// suggested replacement.
String _versionTail(String full) {
final at = full.indexOf('@');
return at < 0 ? '' : full.substring(at);
}
void _checkFieldTypes(
YamlNode? block,
String kind,
List<Issue> out, {
required String nodeId,
}) {
if (block is! YamlMap) return;
for (final entry in block.nodes.entries) {
final value = entry.value.value;
@ -131,6 +284,7 @@ class FlowAnalyzer extends AbstractAnalyzer {
type: IssueType.warning,
);
out.add(issue);
_bumpSeverity(nodeId, IssueType.warning);
// If the typo is within edit-distance two of a known
// type token, offer a one-click replace as the primary
// fix. The strip and tooltip render this as a button.
@ -149,6 +303,23 @@ class FlowAnalyzer extends AbstractAnalyzer {
}
}
/// Find the closest capability in [candidates] within
/// Levenshtein distance 2. Returns null when nothing is close
/// enough matches the same threshold the type-token nudge
/// uses.
String? _closestCapability(String input, Set<String> candidates) {
String? best;
int bestDistance = 3;
for (final c in candidates) {
final d = _levenshtein(input, c);
if (d < bestDistance) {
bestDistance = d;
best = c;
}
}
return best;
}
/// Find the closest type in [kKnownTypes] within Levenshtein
/// distance 2. Returns `null` when nothing is close enough
/// "totally made up word" should leave the operator picking