chain-studio-flow-editor/lib/src/flow_analyzer.dart
flemming-it 2535c28fce fix: never claim 'not in store' while the store state is unknown
A failed/unloaded store snapshot used to be indistinguishable from
a known-empty store, so every missing capability was labelled 'not
in store' the moment the hub or store endpoint was unreachable — a
wrong claim. storeCapabilities is now nullable (null = unknown):
missing caps then get the plain missing chip with an honest
tooltip, no install offer and no not-in-store claim; the analyzer
message says the store cannot be checked right now (EN+DE). Split
and badge covered by new unit + widget tests.

Signed-off-by: flemming-it <sf@flemming.it>
2026-07-22 14:02:28 +02:00

377 lines
13 KiB
Dart

// FlowAnalyzer — feeds AbstractAnalyzer-shaped issues into the
// flow editor's CodeController so the bottom diagnostic strip
// and the hover tooltip can light up broken lines.
//
// Issue kinds reported today:
//
// - YAML parse errors. The exception's source span pins the
// line; the message goes straight through.
// - `use:` lines pointing at a capability the operator hasn't
// installed. Comparison is on the bare `provider/name` (no
// version) against the host-supplied capability list, so
// `text.echo@^1` matches an installed `text.echo`.
// - inputs/outputs values that aren't known type tokens. Catches
// `pdf: byes` before it fails at run time.
//
// On top of issues, the analyzer also publishes a parallel
// `Map<Issue, List<QuickFix>>` so the editor's tooltip and
// strip can render one-click remediations next to the message.
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:yaml/yaml.dart';
import 'l10n.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.
final List<String> Function() availableCapabilities;
/// Returns the names of capabilities the store can actually
/// install, or null when the store state is UNKNOWN (snapshot
/// not loaded / store unreachable). Drives whether an
/// unknown-cap issue carries an Install button (in store), the
/// "not in store" recovery message (known, absent) or the
/// neutral store-unknown wording (null) — the analyzer never
/// claims "no store provides it" without a loaded snapshot.
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
/// rather than per line so a line with multiple issues stays
/// disambiguated.
final Map<Issue, List<QuickFix>> _fixesByIssue = {};
/// 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 = {};
/// Localised string builders the analyzer pipes its messages
/// through. Default is English so the analyzer behaves the
/// same in tests + host-less callers.
final AnalyzerStrings strings;
FlowAnalyzer({
required this.availableCapabilities,
this.storeCapabilities,
this.strings = AnalyzerStrings.english,
});
/// 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) {
return const AnalysisResult(issues: []);
}
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();
final storeKnown = storeCaps != null;
final storeBare =
(storeCaps ?? const <String>[]).map(_bareCap).toSet();
YamlNode? doc;
try {
doc = loadYamlNode(text);
} on YamlException catch (e) {
issues.add(
Issue(
line: e.span?.start.line ?? 0,
message: strings.yamlError(e.message),
type: IssueType.error,
),
);
return AnalysisResult(issues: issues);
} catch (e) {
issues.add(
Issue(
line: 0,
message: strings.yamlError(e.toString()),
type: IssueType.error,
),
);
return AnalysisResult(issues: issues);
}
if (doc is YamlMap) {
final steps = doc['steps'];
if (steps is YamlList) {
for (final step in steps.nodes) {
if (step is! YamlMap) continue;
final useNode = step.nodes['use'];
if (useNode == null) continue;
final useValue = useNode.value;
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. Localised via [strings].
final message = inStore
? strings.unknownCapInStore(useValue)
: didYouMean != null
? strings.unknownCapTypo(useValue, didYouMean)
: storeKnown
? strings.unknownCapNotInStore(useValue)
: strings.unknownCapStoreUnknown(useValue);
final issue = Issue(
line: issueLine,
message: message,
type: IssueType.error,
);
issues.add(issue);
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: strings.fixUseInstead(fullReplacement),
),
);
}
if (inStore) {
fixes.add(
InstallCapabilityFix(
capability: useValue,
label: strings.fixInstallCap(useValue),
),
);
} else if (didYouMean == null) {
fixes.add(
AddModuleSourceFix(
capability: useValue,
label: strings.fixAddSource(useValue),
),
);
}
if (fixes.isNotEmpty) {
_fixesByIssue[issue] = fixes;
}
}
}
}
_checkFieldTypes(
doc['inputs'],
strings.inputKind(),
issues,
nodeId: kInputsNodeId,
);
_checkFieldTypes(
doc['outputs'],
strings.outputKind(),
issues,
nodeId: kOutputsNodeId,
);
}
return AnalysisResult(issues: issues);
}
/// 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;
if (value is! String) continue;
// Skip reference expressions like `$inputs.name` or
// `$step.field` — only flow `outputs:` use these and they
// aren't type declarations.
if (value.startsWith(r'$')) continue;
// Skip plainly empty values — the operator is typing, not
// declaring a typo.
if (value.trim().isEmpty) continue;
if (!kKnownTypes.contains(value)) {
final issue = Issue(
line: entry.value.span.start.line,
message: strings.unknownType(
kind,
value,
kKnownTypes.join(", "),
),
type: IssueType.warning,
);
out.add(issue);
_bumpSeverity(nodeId, IssueType.warning);
final closest = _closestKnownType(value);
if (closest != null) {
_fixesByIssue[issue] = [
ReplaceLineValueFix(
line: entry.value.span.start.line,
replacement: closest,
label: strings.fixChangeTo(closest),
),
];
}
}
}
}
}
/// 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
/// from the full list rather than being nudged toward a poor
/// match.
String? _closestKnownType(String input) {
String? best;
int bestDistance = 3; // exclusive upper bound
for (final candidate in kKnownTypes) {
final d = _levenshtein(input, candidate);
if (d < bestDistance) {
bestDistance = d;
best = candidate;
}
}
return best;
}
/// Classic dynamic-programming Levenshtein distance — small
/// enough to inline since the strings we hit are 4-8 chars.
int _levenshtein(String a, String b) {
if (a == b) return 0;
if (a.isEmpty) return b.length;
if (b.isEmpty) return a.length;
final n = a.length;
final m = b.length;
var prev = List<int>.generate(m + 1, (i) => i);
var curr = List<int>.filled(m + 1, 0);
for (var i = 1; i <= n; i++) {
curr[0] = i;
for (var j = 1; j <= m; j++) {
final cost = a.codeUnitAt(i - 1) == b.codeUnitAt(j - 1) ? 0 : 1;
curr[j] = [
curr[j - 1] + 1,
prev[j] + 1,
prev[j - 1] + cost,
].reduce((x, y) => x < y ? x : y);
}
final swap = prev;
prev = curr;
curr = swap;
}
return prev[m];
}
/// Strip the `@<version>` tail from a capability spec so
/// `text.echo@^1` and `text.echo` compare equal.
String _bareCap(String full) {
final at = full.indexOf('@');
return at < 0 ? full : full.substring(0, at);
}