fix(guide,suggest): word agent guidance no stronger than the evidence #2

Merged
trevon merged 13 commits from docs/honest-agent-guidance into main 2026-09-22 21:19:58 +00:00
17 changed files with 541 additions and 110 deletions

View file

@ -11,8 +11,11 @@ while IFS= read -r git_env_name; do
done <<< "$git_local_env"
unset git_local_env git_env_name
if [ "${PIPE_SKIP_PREPUSH_CI_GATE:-0}" != "1" ]; then
# One skip variable, PIPE_SKIP_PREPUSH_GATE, shared with the gate script. The
# hook used to check PIPE_SKIP_PREPUSH_CI_GATE while the script checked
# PIPE_SKIP_PREPUSH_GATE, so each documented name worked in only one place.
if [ "${PIPE_SKIP_PREPUSH_GATE:-0}" != "1" ]; then
"$REPO_ROOT/scripts/pre-push-ci-gate.sh"
else
echo "[pre-push] PIPE_SKIP_PREPUSH_CI_GATE=1; skipping local CI gate" >&2
echo "[pre-push] PIPE_SKIP_PREPUSH_GATE=1; skipping local CI gate" >&2
fi

1
.github/CODEOWNERS vendored
View file

@ -6,6 +6,5 @@
/tool/** @trvon
/bin/** @trvon
/test/** @trvon
/docs/** @trvon
/.github/** @trvon
/scripts/** @trvon

View file

@ -23,9 +23,10 @@
- [ ] Recall manifest still passes (`pipe recall --root <dir> --strict`)
## Documentation
- [ ] CHANGELOG updated (user-visible changes)
- [ ] README updated (rules table / precision numbers)
- [ ] Stability note updated (if interfaces change)
- [ ] CHANGELOG entry: automated — release-please builds it from the
Conventional Commit subjects, so check those read well; do not edit
CHANGELOG.md by hand
- [ ] README updated (rules table / precision numbers / flags and output)
## AI usage
- [ ] No AI used

149
README.md
View file

@ -78,21 +78,45 @@ pipe verify <path> --baseline .pipe-baseline.json --expect-resolved
pipe suggest <path> --format agent --budget 3000
```
Common flags: `--format text|json` (`agent|md|json` for `guide`), `-c N`
(concurrency, default 4), `--sdk <path>` (override SDK discovery), `--dir`
(repeatable), `--include-generated`, `--limit N`, `--rule <name>` (repeatable),
`--top N`.
Common flags: `--format text|json` (`agent|md|json` for `guide`,
`text|agent|json` for `suggest`), `-c N` (concurrency, default 4; 8 for
`recall`), `--sdk <path>` (override SDK discovery), `--dir` (repeatable,
default `lib`; a directory that does not exist is an error, not skipped),
`--[no-]include-generated` (default on: `*.g.dart`, `*.freezed.dart` and
`*.mocks.dart` are scanned unless you pass `--no-include-generated`),
`--limit N`, `--rule <name>` (repeatable), `--top N`.
Command-specific flags: `index --fail-on-error` exits 1 when the resolved project
has analyzer errors (the default reports the count and exits 0);
`suggest --budget N --max-actions N --top N` bound the agent plan and the text
report; `guide --budget N --max-findings N` bound the agent brief, and `--yams`
with `--yams-name <name>` persists that brief to the yams corpus;
with `--yams-name <name>` persists the full markdown report (not the
budget-trimmed brief) to the yams corpus, tagged with the analysed commit and a
generation timestamp;
`recall --manifest <path> --root <dir>` locate the labelled-bug manifest and the
directory holding the repos it names, and `recall --strict` exits non-zero when a
case it expects to still find is missed. Every numeric flag rejects a value that
is not a positive integer rather than falling back to its default.
Output contract:
- **stdout carries only the requested output.** Status lines such as
`wrote baseline to ...` and `persisted to yams as ...`, and warnings such as a
partial scan, go to stderr, so `--format json` output always parses.
- **Every report states its coverage.** `check`, `guide` and `suggest` JSON carry
a `coverage` block `{discovered, selected, resolved, failed, skipped,
complete}`, and the text outputs, the guide brief and the suggest agent plan
open with a one-line coverage note that says PARTIAL whenever a discovered
file was not analysed. `skipped` counts files the analyzer does not cover:
those your `analysis_options.yaml` `exclude:` names, or files outside any
analysis context. The note is part of the brief's header, so budget trimming
never drops it.
- **Exit codes:** 0 success; 1 a gate failed (`check --fail-on`,
`verify --expect-resolved`, `recall --strict`, `index --fail-on-error`, or
`guide --yams` could not persist); 2 pipe could not do what was asked (a bad
path or `--dir`, an unreadable project, a baseline that cannot be read or
written); 64 invalid usage.
`pipe` finds the right SDK itself: it reads `flutterRoot` from the target's
`.dart_tool/package_config.json`, then `FLUTTER_ROOT`, then `flutter` on `PATH`.
Flutter projects need Flutter's bundled Dart SDK because `dart:ui` is not part of
@ -120,7 +144,8 @@ is an alias for it).
`pipe verify` closes the other half of the loop — proving a fix rather than only
reporting a problem. It compares a fresh analysis against the baseline and prints
what is `resolved`, what `still` remains, and what is `new`, exiting non-zero with
`--expect-resolved` while anything accepted is still reported:
`--expect-resolved` while anything accepted is still reported or was not
scanned:
```text
$ pipe verify . --baseline .pipe-baseline.json
@ -135,9 +160,25 @@ RESOLVED
Baselines are self-describing (each entry carries the rule plus a readable label),
so a resolved finding is named rather than shown as an opaque hash.
`verify` only calls a finding resolved when it looked for it:
- With `--rule`, accepted findings of other rules are set aside as out of scope
(counted, not judged), because a rule that did not run reports nothing.
- An accepted finding in a file this scan did not analyse — excluded by
`analysis_options.yaml`, filtered by `--no-include-generated`, or outside every
`--dir` — is listed as **NOT SCANNED**, never as resolved.
- A scan in which any file failed to resolve, or one truncated by `--limit`, is
refused outright.
A baseline stores a **fingerprint** per finding (`rule + file + semantic
identity` such as union/variant/provider/owner/member), so moving code down a
file does not resurrect accepted findings. `--fail-on-finding` only counts new
identity` such as union/variant/provider/owner/member; for two swallowed catches
in one method, the catch's index within that method), so moving code does not
resurrect accepted findings. Baselines are written as `"version": 2`. Version-1
baselines, whose fingerprints embedded a line number for four rules
(`emitted-not-rendered` on a dormant union, `notifier-field-unread`,
`notifier-inert-consumer`, `loading-not-reset`), are still matched, but those
entries stay line-sensitive until you rewrite the baseline with
`--write-baseline`. `--fail-on-finding` only counts new
error-severity findings, and severity counts in reports are computed over new
findings, so a ratcheted baseline does not keep a build red.
@ -148,7 +189,7 @@ Baselines and reports are JSON, so they diff and review like any other file.
| Rule | What it means | Verified precision (with n) |
| --- | --- | --- |
| `emitted-not-rendered` | A state the bloc emits that no UI code matches, so the user never sees it. | **79/79 (100%)** factual on a CLEAN nuwayze main via an independent adjudicator with passing controls (`tool/adjudicate.py`). Reported once per **root cause**, not once per variant: one finding per unobserved union (dormant feature) and one per marker class when the union is observed. When the UI reads the union's payload through **base-class fields** the finding drops to info, because the data does reach the user. On clean nuwayze main: 30 findings covering 79 variant-level claims. |
| `rendered-not-emitted` | A UI branch no state can reach: dead UI. | n=0 on nuwayze (unions whose bloc emits through helper methods are suppressed with a reason). Fires on a fixture with a passing negative control. |
| `rendered-not-emitted` | A UI branch for a variant its bloc never emits in the scanned code: probably dead UI. | n=0 on nuwayze (unions whose bloc emits through helper methods are suppressed with a reason). Fires on a fixture with a passing negative control. |
| `loading-not-reset` | An in-flight state whose failure path never resolves, so the spinner sticks. | n=0 on both dogfood repos, both verified to guard correctly; proven able to fire by fixture. |
| `loading-flag-not-reset` | The same consequence for `copyWith` + `bool loading` state, which has no variant to match on. | **7/7 (100%)** on dex after hand adjudication; 4 characterised false positives were eliminated by the current gate. n=0 on nuwayze, verified correct. |
| `event-without-handler` | A dispatched event with no `on<Event>`, so the interaction does nothing. | n=0 across the corpus; verified non-vacuous (168 dispatch sites on nuwayze all map to a registered handler). |
@ -157,8 +198,8 @@ Baselines and reports are JSON, so they diff and review like any other file.
| `notifier-never-notifies` | A `ChangeNotifier` method that changes observable state without ever notifying listeners. | Fixture contract only: **zero true positives** on the corpus (nuwayze 30 observable mutators, word_alive 36 — all of which notify). Gated on observable fields with lifecycle hooks excluded, after the first run was 9/9 false positives. |
| `notifier-inert-consumer` | A notifier consumed by a `Consumer`/`Selector` with no notifying path at all, so those consumers can never rebuild. | Fixture contract only; no real-code true positives yet. |
| `provider-read-in-build` | A `Listenable` read with `context.read` / `Provider.of(listen: false)` **directly in a build body**, so the widget never subscribes and can render stale state. | 1 finding on nuwayze from **447** raw `context.read` calls; 0 on word_alive/dex. Hand-verified: the first 4 findings were all reads inside **closures** (a `BlocListener` callback, a `Selector` builder), so a closure guard was added and only the direct-in-build case survives. |
| `notifier-field-unread` | A public getter on a consumed `ChangeNotifier` that nothing reads, so the state it exposes cannot affect what the user sees. | 3 on nuwayze (e.g. `UserProvider.isInitializing`, `AppLocaleProvider.preferredLocale`), 0 on word_alive/dex. Reported once per notifier, and only for notifiers the UI actually consumes. **medium** confidence: a name match elsewhere can mask a genuinely unread member. |
| `provider-created-in-build` | A provider constructed inside `build` without `.value`, so `create` reruns on every rebuild and state is lost. | 1 finding on nuwayze — `AppProviders.build` constructs **45** providers — from 45 raw sites, because it is reported **once per build method**. Deliberately **info**, not warning: the claim is factual, but a root `StatelessWidget` rarely rebuilds, so impact depends on rebuild frequency. |
| `notifier-field-unread` | A public getter on a consumed `ChangeNotifier` that nothing reads, so the state it exposes likely never reaches the UI. | 3 on nuwayze (e.g. `UserProvider.isInitializing`, `AppLocaleProvider.preferredLocale`), 0 on word_alive/dex. Reported once per notifier, and only for notifiers the UI actually consumes. **medium** confidence: a name match elsewhere can mask a genuinely unread member. |
| `provider-created-in-build` | A provider constructed inside `build` without `.value`, so if the widget rebuilds, `create` reruns and its state is lost. | 1 finding on nuwayze — `AppProviders.build` constructs **45** providers — from 45 raw sites, because it is reported **once per build method**. Deliberately **info**, not warning: the claim is factual, but a root `StatelessWidget` rarely rebuilds, so impact depends on rebuild frequency. |
### How precision was measured
@ -231,8 +272,9 @@ The fixture has a regression guard (`patternView`) so this cannot come back.
This is also why the precision numbers above are stated as *factual* precision:
they say how often the claim holds, not how often the issue is worth acting on.
Every finding carries `file:line`, a `path:line: snippet` evidence string, a
confidence, a UX severity, and a remediation. If a rule cannot be validated it is
Every finding carries `file:line`, a `path:line: excerpt` evidence string (string
literals elided, at most 120 characters), a confidence, a UX severity, and a
remediation. If a rule cannot be validated it is
demoted or deleted, not defended.
### Recall, measured not assumed
@ -257,11 +299,11 @@ Precision alone is not evidence. Two mechanisms pin recall:
into a permanent guard against the real code rather than a deleted
expectation.
Current result: **5/5 accounted for** (2 open bugs found, 3 fixed bugs still
quiet), 0 regressions — measured against the **working tree**, so a repo checked
out on an unmerged fix branch reports its cases as missed. The two nuwayze cases
are wired up on branches (`wire/nuwayze-*`) that are not merged yet, so they still
read as `open` here; they flip to `fixed` when those land.
Result as of 2026-09-20: **5/5 accounted for** (2 open bugs found, 3 fixed bugs
still quiet), 0 regressions — measured against the **working tree**, so a repo
checked out on an unmerged fix branch reports its cases as missed. At that date
the two nuwayze cases were wired up on branches (`wire/nuwayze-*`) that were not
yet merged, so they read as `open`; they flip to `fixed` when those land.
Recall is still measured against a small labelled set. It bounds the risk of
false negatives; it does not prove they are absent.
@ -284,9 +326,10 @@ approval rather than offered as an equal choice.
`--policy wire-up` (default) / `--policy remove` flips the emphasis for teams that
would rather delete. It changes the wording of every remediation that presents a
choice, the `decision:` line in the agent brief, and the heading of the
dead-handler section (`Unreachable handlers - wire up the control` versus
`Cleanly deletable`).
choice, the fixes in the brief's rule legend, the `decision:` line in the agent
brief, the heading of the dead-handler section (`Unreachable handlers - wire up
the control` versus `Cleanly deletable`), and whether `suggest` scores a dead
handler as needing no product judgement (only under `remove`).
This is a policy, not a finding: pipe still reports the same facts either way.
@ -298,14 +341,29 @@ the plan is presented both globally and **per file**. Only new findings are
planned, so a ratcheted baseline yields an empty plan.
```text
1. [warning/high] emitted-not-rendered — CommentsState (CommentsBloc)
why: 125 = warning 50 x high (x1.0), one fix clears 6 findings (+50), user-visible failure (+25)
fix: Either render CommentsLoading in the consuming widget (or delete it and its emits) ...
where: lib/blocs/comment/comment_bloc.dart:143, ... +2 more
clears 6 finding(s) (CommentSubmitError, CommentSubmitSuccess, CommentSubmitting,
CommentsError, CommentsLoaded, CommentsLoading); verify with `pipe check --rule emitted-not-rendered`
# pipe plan — test/fixtures/stuck_loading
Static heuristics; confirm each against the cited file. Order is a score, not a mandate.
Coverage: 12 of 12 discovered file(s) analysed (0 failed, 0 skipped by analysis_options or context).
20 findings collapse to 16 root cause(s) across 8 file(s).
1. [error/high] event-without-handler — HandlerGapEvent (GapBloc)
why: 115 = error 100 x high (x1.0), no product judgement needed (+15)
fix: Add `on<UnhandledTap>(...)` to GapBloc, or stop dispatching UnhandledTap.
where: lib/gaps.dart:74
clears 1 finding(s) (UnhandledTap); verify with `pipe check test/fixtures/stuck_loading --rule event-without-handler`
2. [warning/high] loading-not-reset — ItemBloc.twoSwallowedAwaits
why: 85 = warning 50 x high (x1.0), one fix clears 3 findings (+20), no product judgement needed (+15)
fix: Emit a failure/timeout state in the catch (or rethrow) so every loading state is resolved. Applies to each of: <closure>, twoSwallowedAwaits.
where: lib/item_bloc.dart:35, lib/item_bloc.dart:40, lib/item_bloc.dart:53
clears 3 finding(s) (<closure>, twoSwallowedAwaits); verify with `pipe check test/fixtures/stuck_loading --rule loading-not-reset`
14 further cause(s) omitted to stay within 1500 characters. Run `pipe suggest` for the full plan.
```
(Real output of `pipe suggest test/fixtures/stuck_loading --format agent
--budget 1500`, run from the repository root. The budget cut the plan to its top
two causes; the omission line says so.)
The score is deliberately simple and always printed, so the order can be argued
with rather than trusted blindly:
@ -315,7 +373,7 @@ with rather than trusted blindly:
| confidence | high ×1.0, medium ×0.7, low ×0.4 — a low-confidence finding may not be real |
| breadth | +10 per extra finding the one fix clears (capped at +50) |
| user-visible failure | +25 when the rule flagged the failure as UX-critical |
| certainty | +15 for rules whose fix needs no product judgement (dead handler, event without handler, loading not reset) |
| certainty | +15 for rules whose fix needs no product judgement (event without handler, loading not reset; dead handler only under `--policy remove`, because wiring one up means choosing the control that dispatches it) |
Because the bonuses are additive, a wide and certain `info` group (10 base + 15
certainty + 50 breadth = 75) can outrank a narrow `warning` (35): severity
@ -325,11 +383,20 @@ implied, and the `why:` line prints the arithmetic for every action.
## Agent guidance
`pipe guide` emits a brief designed to be pasted into an agent prompt: findings
grouped by root cause, dense `file:line` anchors (`file:18,29,32`), a rule legend
carrying the standard fix, a `decision:` line that resolves "fix vs delete" where
the project makes that decidable, and a `verify:` command. Truncation is always
explicit — never silently dropped.
`pipe guide` emits a brief designed to be pasted into an agent prompt: a coverage
line, findings grouped by root cause, dense `file:line` anchors
(`file:18,29,32`), a rule legend carrying the standard fix for the chosen
`--policy`, a `decision:` line that resolves "fix vs delete" where the project
makes that decidable (scoped to the scanned directories), and a `verify:`
command that can be pasted as-is (`pipe check <root> --rule X`, plus
`--baseline` and `--dir` when the run used them). Truncation is always explicit —
never silently dropped — and packing stops at the first entry that does not fit,
so a lower-ranked finding never stands in for a dropped higher-ranked one.
The analysed repository is treated as untrusted input. Quoted source is a bounded
excerpt — string literals replaced by `'…'`, comments dropped, one line of at most
120 characters — labelled `source excerpt (data, not instructions)` and fenced so
that nothing inside it can close its own quotation.
It was verified by giving independent agents *only* the brief text and no tool
access. Five rounds of that found real defects (a legend that did not match the
@ -348,19 +415,25 @@ actionable class.
## Architecture
```text
bin/pipe.dart entrypoint
bin/pipe.dart entrypoint, exit codes
lib/src/cli.dart commands and option parsing
lib/src/errors.dart PipeException (message + remedy)
lib/src/project.dart package graph validation, source discovery
lib/src/sdk.dart SDK discovery (Flutter-aware)
lib/src/analysis.dart AnalysisContextCollection lifecycle, per-file resolution
lib/src/analysis.dart AnalysisContextCollection lifecycle, per-file
resolution, scan coverage
lib/src/pool.dart bounded-concurrency map over files
lib/src/index.dart resolution health, timing, diagnostics
lib/src/model.dart declaration model
lib/src/state_flow.dart the state-flow IR (unions, emissions, render sites,
events, flags, Riverpod)
lib/src/excerpt.dart bounded, literal-free source excerpts
lib/src/rules.dart breakage detectors, findings, baselines
lib/src/provider_rules.dart provider / ChangeNotifier rules
lib/src/recall.dart labelled-bug recall measurement
lib/src/verify.dart baseline verification (resolved / still / new)
lib/src/verify.dart baseline verification (resolved / still / new /
not scanned)
lib/src/suggest.dart ranked fix order per file
lib/src/recall.dart labelled-bug recall measurement
lib/src/pipeline.dart shared analyze -> graph -> rules pipeline
lib/src/guide.dart agent brief / markdown report / yams emission
tool/adjudicate.py independent precision adjudicator
@ -414,7 +487,7 @@ is byte-identical across runs.
dart test
```
84 tests across resolution/IR extraction, rule contracts (positive *and*
Tests cover resolution/IR extraction, rule contracts (positive *and*
negative controls per rule), Riverpod and provider modelling, agent-brief
rendering, fix-order scoring, baseline round-tripping (including legacy
fingerprints-only baselines), verification, and recall evaluation, against a

View file

@ -8,7 +8,8 @@ import 'package:pipe/src/errors.dart';
///
/// - 0: success.
/// - 1: a gate failed: `check --fail-on`, `verify --expect-resolved`,
/// `recall --strict`, or `guide --yams` could not persist.
/// `recall --strict`, `index --fail-on-error`, or `guide --yams` could not
/// persist.
/// - 2: pipe could not do what was asked (a [PipeException], or a filesystem
/// error such as an unreadable or unwritable path).
/// - 64: invalid command-line usage.

View file

@ -8,6 +8,7 @@ module.exports = {
[
'feat', // New feature
'fix', // Bug fix
'security', // Security fix (release-please "Security" section)
'perf', // Performance improvement
'docs', // Documentation
'chore', // Maintenance (deps, CI config, etc.)

View file

@ -7,12 +7,14 @@ export 'src/guide.dart'
show
Guidance,
buildGuidance,
checkCommand,
emitToYams,
findYams,
gitHead,
rankFindings,
ruleEpithet,
ruleFix,
ruleFixFor,
ruleGlossary,
yamsMetadata;
export 'src/index.dart'

View file

@ -647,7 +647,13 @@ class SuggestCommand extends Command<int> {
policy: _readPolicy(results),
);
try {
final plan = buildWorkPlan(root: project.root, report: analysis.report);
final plan = buildWorkPlan(
root: project.root,
report: analysis.report,
policy: _readPolicy(results),
baselinePath: results['baseline'] as String?,
directories: project.directories,
);
// Read every numeric option up front. Each was previously read inside the
// one output branch that uses it, so `suggest --top abc --format agent`
// silently ignored the malformed value instead of rejecting it.
@ -740,6 +746,8 @@ class GuideCommand extends Command<int> {
flow: analysis.flow,
report: analysis.report,
policy: _readPolicy(results),
scannedDirectories: project.directories,
baselinePath: results['baseline'] as String?,
budgetChars: readPositiveInt(results, 'budget', fallback: 6000),
maxFindings: readPositiveInt(results, 'max-findings', fallback: 12),
);

View file

@ -33,7 +33,8 @@ const Map<String, String> ruleEpithet = {
ruleEmittedNotRendered:
'A state the bloc emits that no UI code matches, so the user never sees it.',
ruleRenderedNotEmitted:
'A UI branch that no state can reach, so it is dead code.',
'A UI branch for a variant its bloc never emits in the scanned code, so '
'the branch is probably unreachable.',
ruleLoadingNotReset:
'A loading state whose failure path never resolves, so the spinner sticks.',
ruleLoadingFlagNotReset:
@ -44,7 +45,8 @@ const Map<String, String> ruleEpithet = {
ruleDeadHandler: 'An `on<Event>` registration that nothing ever dispatches.',
ruleRiverpodAsyncGap:
'An async Riverpod provider read in the UI whose error and/or loading '
'state is never rendered, so a failure shows a blank or stale screen.',
'state is never rendered, so a failure can leave a blank or stale screen '
'with no sign that anything went wrong.',
ruleNotifierNeverNotifies:
'A ChangeNotifier method that changes state without ever notifying '
'listeners, so the UI keeps showing the old values.',
@ -55,32 +57,21 @@ const Map<String, String> ruleEpithet = {
'A Listenable read with `context.read` / `Provider.of(listen: false)` '
'inside build, so the widget never subscribes and can go stale.',
ruleNotifierFieldUnread:
'A public getter on a consumed ChangeNotifier that nothing reads, so the '
'state it exposes can never affect what the user sees.',
'A public getter on a consumed ChangeNotifier that nothing in the scanned '
'code reads by name, so the state it exposes likely never reaches the UI.',
ruleProviderCreatedInBuild:
'A provider constructed inside build without `.value`, so a new instance '
'and listener are created on every rebuild and state is lost.',
'A provider constructed inside build without `.value`, so if the widget '
'rebuilds, a new instance and listener are created and its state is lost.',
};
/// The standard remediation for each rule. Hoisted out of the per-finding
/// blocks so a prompt budget is not spent repeating identical advice.
const Map<String, String> ruleFix = {
ruleEmittedNotRendered:
'Render the variant where the state is consumed, or delete the variant '
'and its emits. Prefer a sealed state hierarchy so the compiler enforces '
'exhaustive handling.',
ruleRenderedNotEmitted:
'Delete the unreachable branch, or emit the variant from the owning bloc.',
/// Fixes that read the same under either policy.
const Map<String, String> _policyNeutralFix = {
ruleLoadingNotReset:
'Emit a failure/timeout state in the catch (or rethrow) so every loading '
'state is resolved.',
ruleLoadingFlagNotReset:
'Wrap the awaited call in try/catch and emit a state with the loading flag '
'reset (or rethrow), so the loading state always resolves.',
ruleEventWithoutHandler:
'Add the missing `on<Event>` handler to the bloc, or stop dispatching.',
ruleDeadHandler:
'Remove the dead handler, or dispatch the event from the UI.',
ruleRiverpodAsyncGap:
'Handle the missing AsyncValue case at the consumption site: '
'`switch (value) { AsyncData(:final value) => ..., AsyncLoading() => ..., '
@ -95,14 +86,66 @@ const Map<String, String> ruleFix = {
'Use `context.watch<T>()` (or `Provider.of<T>(context)`) so the widget '
'subscribes, or keep the read for an imperative action and make sure '
'nothing rendered depends on the value.',
ruleNotifierFieldUnread:
'Render the member in the consuming widget, or make it private if it is '
'internal state rather than UI state.',
ruleProviderCreatedInBuild:
'Hoist the provider above the widget that rebuilds, or use the `.value` '
'constructor with an instance created once.',
};
/// `--policy wire-up`: make the unreachable thing reachable; removal is the
/// exception and needs explicit approval.
const Map<String, String> _wireUpFix = {
ruleEmittedNotRendered:
'Wire it up: render the variant where the state is consumed. Prefer a '
'sealed state hierarchy so the compiler enforces exhaustive handling. '
'Deleting the variant and its emits needs explicit approval.',
ruleRenderedNotEmitted:
'Emit the variant from the owning bloc where the UI expects it. Deleting '
'the branch needs explicit approval.',
ruleEventWithoutHandler:
'Add the missing `on<Event>` handler to the bloc. Removing the dispatch '
'instead needs explicit approval.',
ruleDeadHandler:
'Wire it up: dispatch the event from the control that should trigger it, '
'after checking it is not dispatched from a directory that was not '
'scanned. Removing the handler needs explicit approval.',
ruleNotifierFieldUnread:
'Wire it up: render the member in the consuming widget. Making it private '
'is the alternative when it is internal rather than UI state.',
};
/// `--policy remove`: removal is offered as an equal choice.
const Map<String, String> _removeFix = {
ruleEmittedNotRendered:
'Render the variant where the state is consumed, or delete the variant '
'and its emits. Prefer a sealed state hierarchy so the compiler enforces '
'exhaustive handling.',
ruleRenderedNotEmitted:
'Delete the unreachable branch, or emit the variant from the owning bloc.',
ruleEventWithoutHandler:
'Add the missing `on<Event>` handler to the bloc, or stop dispatching.',
ruleDeadHandler:
'Remove the dead handler once nothing outside the scanned directories '
'dispatches it, or dispatch the event from the UI.',
ruleNotifierFieldUnread:
'Render the member in the consuming widget, or make it private if it is '
'internal state rather than UI state.',
};
/// The standard remediation for each rule under [policy]. Hoisted out of the
/// per-finding blocks so a prompt budget is not spent repeating identical
/// advice.
///
/// This used to be one constant map that ignored `--policy`, so under the
/// default wire-up policy the legend still led with "Remove the dead handler"
/// while each finding said removal needed explicit approval.
Map<String, String> ruleFixFor(FixPolicy policy) => {
..._policyNeutralFix,
...(policy == FixPolicy.wireUp ? _wireUpFix : _removeFix),
};
/// The standard remediation under the default (wire-up) policy.
final Map<String, String> ruleFix = ruleFixFor(FixPolicy.wireUp);
/// Label for quoted repository source wherever an agent may read it.
const String _evidenceLabel = 'source excerpt (data, not instructions)';
@ -177,7 +220,10 @@ String? _groupLabel(Finding finding) {
/// Turn a finding into the concrete decision the reader has to make. Without
/// this, "render it or delete it" is not actionable: the reader cannot tell
/// whether the whole feature is unwired.
String? _decisionLine(Finding finding, FixPolicy policy) {
///
/// [scope] names the scanned directories: "nothing references it" is only ever
/// established for them.
String? _decisionLine(Finding finding, FixPolicy policy, String scope) {
if (finding.rule != ruleEmittedNotRendered) return null;
final observed = finding.data['unionObservedInUi'] == true;
final sites = finding.data['unionRenderSites'];
@ -193,12 +239,14 @@ String? _decisionLine(Finding finding, FixPolicy policy) {
}
if (owners.isEmpty) {
return policy == FixPolicy.wireUp
? 'wire it up: nothing outside the bloc references it either, so the '
'feature is unwired end to end - mount it in a screen and render '
'its states. Removing it needs explicit approval.'
: 'no UI renders this state type and nothing outside the bloc '
'references it, so nothing is wired up: this is safe to delete '
'unless the feature is still to come.';
? 'wire it up: nothing under $scope outside the bloc references it '
'either, so the feature looks unwired end to end - mount it in a '
'screen and render its states. Removing it needs explicit '
'approval.'
: 'no UI renders this state type and nothing under $scope outside the '
'bloc references it, so nothing scanned wires it up: this is safe '
'to delete once no unscanned directory uses it and the feature is '
'not still to come.';
}
final shown = owners.take(3).join(', ');
final suffix = owners.length > 3 ? ', +${owners.length - 3} more' : '';
@ -212,7 +260,13 @@ String? _decisionLine(Finding finding, FixPolicy policy) {
'it.';
}
String _groupBlock(List<Finding> group, int index, FixPolicy policy) {
String _groupBlock(
List<Finding> group,
int index,
FixPolicy policy,
String scope,
String Function(String rule) verifyFor,
) {
final head = group.first;
final buffer = StringBuffer();
final label = _groupLabel(head);
@ -264,14 +318,14 @@ String _groupBlock(List<Finding> group, int index, FixPolicy policy) {
if (parts.isNotEmpty) {
buffer.writeln(' where: ${parts.join(' | ')}');
}
final decision = _decisionLine(head, policy);
final decision = _decisionLine(head, policy, scope);
if (decision != null) buffer.writeln(' decision: $decision');
// Evidence quotes the analysed repository, which is untrusted input. It is
// labelled as data and fenced so nothing in it can close its own quotation.
for (final item in head.evidence.take(1)) {
buffer.writeln(' $_evidenceLabel: ${dataSpan(item)}');
}
buffer.writeln(' verify: pipe check --rule ${head.rule}');
buffer.writeln(' verify: ${verifyFor(head.rule)}');
return buffer.toString();
}
@ -283,7 +337,16 @@ Guidance buildGuidance({
int budgetChars = 6000,
int maxFindings = 12,
FixPolicy policy = FixPolicy.wireUp,
List<String> scannedDirectories = const ['lib'],
String? baselinePath,
}) {
final scope = scannedDirectories.join(', ');
String verifyFor(String rule) => checkCommand(
root: root,
rule: rule,
baseline: baselinePath,
directories: scannedDirectories,
);
final ranked = rankFindings(report.newFindings);
final summary = <String, Object?>{
'root': root,
@ -353,11 +416,12 @@ Guidance buildGuidance({
// in the report. Its size is known up front, which lets group selection
// reserve room for it instead of dropping it later.
final presentRules = report.countByRule.keys.toSet();
final fixes = ruleFixFor(policy);
final glossary = StringBuffer();
for (final rule in allRules) {
if (!presentRules.contains(rule)) continue;
glossary.writeln(
'- $rule: ${ruleEpithet[rule] ?? ''} Fix: ${ruleFix[rule] ?? ''}',
'- $rule: ${ruleEpithet[rule] ?? ''} Fix: ${fixes[rule] ?? ''}',
);
}
const legendHeading = '## How to read the rule names\n';
@ -382,7 +446,7 @@ Guidance buildGuidance({
for (var i = 0; i < groups.length; i++) {
final block = selected.length >= maxFindings
? null
: _groupBlock(groups[i], selected.length + 1, policy);
: _groupBlock(groups[i], selected.length + 1, policy, scope, verifyFor);
if (block == null ||
runningLength + block.length + noteReserve > budgetChars) {
// Stop at the first entry that does not fit. Skipping it and packing the
@ -435,8 +499,9 @@ Guidance buildGuidance({
);
}
// Certain edits: a dead handler can be removed without product context, so
// surface those anchors even when they did not fit the ranked entries above.
// Dead handlers: once the scan's scope is confirmed, removing one needs no
// product context, so surface those anchors even when they did not fit the
// ranked entries above.
final dead = report.newFindings
.where((f) => f.rule == ruleDeadHandler)
.toList();
@ -456,11 +521,14 @@ Guidance buildGuidance({
)
..writeln(
policy == FixPolicy.wireUp
? 'Nothing dispatches these, so the handler is unreachable. Wire it '
'up by adding the control that dispatches the event; removing '
'the handler needs explicit approval: $anchors'
: 'Nothing dispatches these, so removing the `on<...>` registration '
'is safe even without product context: $anchors',
? 'Nothing under $scope dispatches these, so the handler is '
'unreachable from there. Check that no unscanned directory '
'dispatches them (rerun with --dir), then wire it up by adding '
'the control that dispatches the event; removing the handler '
'needs explicit approval: $anchors'
: 'Nothing under $scope dispatches these. Once no unscanned '
'directory does either (rerun with --dir), removing the '
'`on<...>` registration needs no product context: $anchors',
);
if (buffer.length + section.length < budgetChars) buffer.write(section);
}
@ -524,9 +592,11 @@ Guidance buildGuidance({
..writeln()
..write(coverageLine == null ? '' : '$coverageLine\n\n')
..writeln(
// New findings only, matching the heading below and countBySeverity,
// which already counted only new ones.
'${flow.unions.length} blocs/cubits, ${summary['stateVariants']} state '
'variants, ${report.findings.length} findings '
'(${report.countBySeverity}).',
'variants, ${report.newFindings.length} new findings '
'(${report.countBySeverity})$baselinedNote.',
)
..writeln()
..writeln('## Findings (${ranked.length})');
@ -587,6 +657,32 @@ Guidance buildGuidance({
);
}
/// A `pipe check` command that re-runs one rule the way an analysis ran: same
/// root, same baseline, same --dir scope. The hint used to be a bare
/// `pipe check --rule X`, which checks the current directory against no
/// baseline over lib/ only - not what produced the finding.
String checkCommand({
required String root,
required String rule,
String? baseline,
List<String> directories = const ['lib'],
}) {
final parts = ['pipe', 'check', _shellQuote(root), '--rule', rule];
if (baseline != null) parts.addAll(['--baseline', _shellQuote(baseline)]);
final defaultScope = directories.length == 1 && directories.single == 'lib';
if (!defaultScope) {
for (final directory in directories) {
parts.addAll(['--dir', _shellQuote(directory)]);
}
}
return parts.join(' ');
}
String _shellQuote(String value) =>
RegExp(r'^[A-Za-z0-9_./:=@+-]+$').hasMatch(value)
? value
: "'${value.replaceAll("'", r"'\''")}'";
/// Metadata for a report persisted to yams: what produced it, from which
/// project, when, and - when the project is a git checkout - at which commit,
/// so a report read back from the corpus can be dated and reproduced.

View file

@ -821,8 +821,9 @@ String runProviderRules({
confidence: 'medium',
message:
'${notifier.name} is consumed in the UI but ${unread.length} of '
'its public member(s) are read nowhere outside the class, so '
'nothing the user sees can depend on them: ${unread.join(', ')}.',
'its public member(s) are read by name nowhere outside the class '
'in the scanned code, so what the user sees likely does not depend '
'on them: ${unread.join(', ')}.',
file: notifier.file,
line: notifier.line,
evidence: [

View file

@ -609,7 +609,8 @@ FindingReport runChecks({
confidence: 'medium',
message:
'$variant is matched in UI code but ${coverage.union.owner} '
'never emits it, so that branch is dead.',
'never emits it in the scanned code, so that branch is probably '
'unreachable.',
file: sites.isEmpty ? null : sites.first.path,
line: sites.isEmpty ? null : sites.first.line,
evidence: [
@ -714,7 +715,10 @@ FindingReport runChecks({
'approval. Before wiring, check the event is not simply '
'dispatched from a directory that was not scanned (add it '
'with --dir): test-only handlers are not missing features.'
: 'Remove the dead handler, or dispatch $event from the UI.',
: 'Remove the dead handler once nothing outside '
'${project.directories.join(', ')} dispatches $event '
'(widen the scan with --dir to check), or dispatch '
'$event from the UI.',
data: {'union': union.name, 'owner': union.owner, 'event': event},
),
);
@ -1168,8 +1172,9 @@ class _FlagLoadingCollector extends RecursiveAstVisitor<void> {
severity: PipeSeverity.warning,
confidence: setsFlagTrue && !awaitedTrySeen ? 'high' : 'medium',
message:
'$owner.$member awaits work after putting ${flag.name}`$fieldList` '
'into its loading state, and no catch clause surfaces a failure, so '
'$owner.$member awaits work after putting '
'`${flag.name}.$fieldList` into its loading state, and no catch '
'clause surfaces a failure, so '
'the UI can keep showing its loading state after an error.',
file: project.relative(path),
line: lineInfo.getLocation(body.offset).lineNumber,

View file

@ -1,4 +1,5 @@
import 'analysis.dart';
import 'guide.dart';
import 'rules.dart';
/// One fixable root cause: a single action that resolves one or more findings.
@ -16,7 +17,8 @@ class FixAction {
required this.why,
this.file = '',
this.line,
});
String? verify,
}) : verify = verify ?? checkCommand(root: '.', rule: rule);
final String rule;
final PipeSeverity severity;
@ -42,7 +44,9 @@ class FixAction {
final String file;
final int? line;
String get verify => 'pipe check --rule $rule';
/// A command that re-checks this rule the way the plan was produced, so it
/// can be pasted as-is.
final String verify;
Map<String, Object?> toJson() => {
'rule': rule,
@ -159,10 +163,11 @@ class WorkPlan {
final buffer = StringBuffer()
..writeln('# pipe plan — $root')
..writeln()
..writeln(agentCaution)
..write(coverage == null ? '' : '${coverage!.describe()}\n')
..writeln(
'$totalFindings findings collapse to $totalCauses root cause(s) across '
'${files.length} file(s). Work them in this order.',
'${files.length} file(s).',
)
..writeln();
final actions = ordered;
@ -245,6 +250,11 @@ const Map<String, double> _confidenceFactor = {
'low': 0.4,
};
/// Opens the agent plan: the order is a heuristic ranking, not an instruction.
const String agentCaution =
'Static heuristics; confirm each against the cited file. Order is a score, '
'not a mandate.';
/// Rules whose fix needs no product judgement, so they can just be done.
const Set<String> _certaintyRules = {
ruleDeadHandler,
@ -253,17 +263,24 @@ const Set<String> _certaintyRules = {
ruleLoadingFlagNotReset,
};
int _scoreOf(List<Finding> group) {
/// Whether [rule]'s fix needs no product judgement under [policy]. Removing a
/// dead handler needs none; wiring one up - the default - means deciding which
/// control should dispatch the event, which is exactly product judgement.
bool _isCertain(String rule, FixPolicy policy) =>
_certaintyRules.contains(rule) &&
!(rule == ruleDeadHandler && policy == FixPolicy.wireUp);
int _scoreOf(List<Finding> group, FixPolicy policy) {
final head = group.first;
final base = _severityBase[head.severity] ?? 10;
final factor = _confidenceFactor[head.confidence] ?? 1.0;
final breadth = ((group.length - 1).clamp(0, 5)) * 10;
final uxCritical = head.data['uxCritical'] == true ? 25 : 0;
final certainty = _certaintyRules.contains(head.rule) ? 15 : 0;
final certainty = _isCertain(head.rule, policy) ? 15 : 0;
return (base * factor).round() + breadth + uxCritical + certainty;
}
String _whyOf(List<Finding> group, int score) {
String _whyOf(List<Finding> group, int score, FixPolicy policy) {
final head = group.first;
final base = _severityBase[head.severity] ?? 10;
final parts = <String>[
@ -273,8 +290,7 @@ String _whyOf(List<Finding> group, int score) {
'one fix clears ${group.length} findings (+'
'${((group.length - 1).clamp(0, 5)) * 10})',
if (head.data['uxCritical'] == true) 'user-visible failure (+25)',
if (_certaintyRules.contains(head.rule))
'no product judgement needed (+15)',
if (_isCertain(head.rule, policy)) 'no product judgement needed (+15)',
];
return '$score = ${parts.join(', ')}';
}
@ -308,6 +324,15 @@ List<String> _targetsOf(List<Finding> group) {
final targets = <String>[];
for (final finding in group) {
final data = finding.data;
// Grouped findings name their members in a list (the variants of a union,
// the unread members of a notifier).
for (final key in const ['variants', 'members']) {
final values = data[key];
if (values is! List) continue;
for (final value in values.whereType<String>()) {
if (value.isNotEmpty && !targets.contains(value)) targets.add(value);
}
}
for (final key in const [
'variant',
'event',
@ -328,6 +353,41 @@ List<String> _targetsOf(List<Finding> group) {
return targets;
}
/// The action for a group. A single finding's remediation names that one
/// finding's target, so for a group of several it would name only the first -
/// "dispatch LoadItems from the UI" for a group of five handlers. A group gets
/// an action built from all of its targets instead.
String _actionOf(List<Finding> group, List<String> targets, FixPolicy policy) {
final head = group.first;
final single = head.remediation ?? head.message;
if (group.length < 2 || targets.length < 2) return single;
final list = targets.join(', ');
final owner = head.data['owner'];
final ownerName = owner is String ? owner : 'the bloc';
return switch (head.rule) {
ruleDeadHandler =>
policy == FixPolicy.wireUp
? 'Wire them up: dispatch each of $list from the control that should '
'trigger it, after checking none is dispatched from a directory '
'that was not scanned (--dir). Removing a handler needs explicit '
'approval.'
: 'Remove the handler for each of $list once nothing outside the '
'scanned directories dispatches it (--dir), or dispatch it from '
'the UI.',
ruleEventWithoutHandler =>
'Add an `on<...>` handler to $ownerName for each of $list, or stop '
'dispatching it.',
ruleRenderedNotEmitted =>
policy == FixPolicy.wireUp
? 'Emit each of $list from $ownerName where the UI expects it. '
'Removing the branches that match them needs explicit approval.'
: 'Remove the branch matching each of $list, or emit it from '
'$ownerName if the UI is right.',
_ =>
'${ruleFixFor(policy)[head.rule] ?? single} Applies to each of: $list.',
};
}
String _groupKey(Finding finding) {
final data = finding.data;
for (final key in const [
@ -349,7 +409,13 @@ String _groupKey(Finding finding) {
/// Build the plan from a report. Only new findings are planned, so a ratcheted
/// baseline yields an empty plan.
WorkPlan buildWorkPlan({required String root, required FindingReport report}) {
WorkPlan buildWorkPlan({
required String root,
required FindingReport report,
FixPolicy policy = FixPolicy.wireUp,
String? baselinePath,
List<String> directories = const ['lib'],
}) {
final newFindings = report.newFindings;
final order = <String>[];
final groups = <String, List<Finding>>{};
@ -369,23 +435,30 @@ WorkPlan buildWorkPlan({required String root, required FindingReport report}) {
final group = groups[key]!;
final head = group.first;
final file = head.file ?? '(unknown)';
final score = _scoreOf(group);
final score = _scoreOf(group, policy);
final targets = _targetsOf(group);
final action = FixAction(
rule: head.rule,
severity: head.severity,
confidence: head.confidence,
label: _labelOf(group) ?? file,
action: head.remediation ?? head.message,
action: _actionOf(group, targets, policy),
locations: [
for (final finding in group)
if (finding.location.isNotEmpty) finding.location,
]..sort(),
targets: _targetsOf(group),
targets: targets,
resolves: group.length,
score: score,
why: _whyOf(group, score),
why: _whyOf(group, score, policy),
file: file,
line: head.line,
verify: checkCommand(
root: root,
rule: head.rule,
baseline: baselinePath,
directories: directories,
),
);
byFile.putIfAbsent(file, () => []).add(action);
fileScores.update(file, (value) => value + score, ifAbsent: () => score);

View file

@ -31,7 +31,7 @@
"match": {
"provider": "subscriptionStreamProvider"
},
"note": "fixed on branch fix/subscription-stream-entitlement (PR #38): the tier is now read from confirmed AsyncData only. Guard holds while that branch is checked out; becomes a default-branch guard once merged.",
"note": "fixed on branch fix/subscription-stream-entitlement (PR #38): the tier is now read from confirmed AsyncData only. | As of 2026-09-20 that branch was unmerged, so the guard holds only while it is checked out; it becomes a default-branch guard once merged.",
"status": "fixed"
},
{

View file

@ -39,6 +39,9 @@ void main() {
expect(hits.single.confidence, 'high');
expect(hits.single.evidence, isNotEmpty);
expect(hits.single.remediation, isNotNull);
// The flag reads as one code span, `FlagState.loading`, not as
// `FlagState` immediately followed by a second span.
expect(hits.single.message, contains('`FlagState.'));
});
test('flag findings carry no false positive for guarded code', () {

View file

@ -159,6 +159,31 @@ void main() {
);
});
test('verify hints are commands that can be pasted as-is', () {
// `pipe check --rule X` checked the current directory, over lib/, against
// no baseline - not the run that produced the brief.
final brief = buildGuidance(
root: '/work/app',
flow: analysis.flow,
report: analysis.report,
baselinePath: 'ci/base.json',
scannedDirectories: const ['lib', 'test'],
).brief;
expect(
brief,
matches(
RegExp(
r'verify: pipe check /work/app --rule [a-z-]+ '
r'--baseline ci/base\.json --dir lib --dir test',
),
),
);
expect(
checkCommand(root: '/work/app', rule: ruleDeadHandler),
'pipe check /work/app --rule dead-handler',
);
});
test('yams metadata records provenance, not a stale owner', () {
final metadata = yamsMetadata(
project: 'app',
@ -175,6 +200,27 @@ void main() {
);
});
test('the markdown summary counts the same findings as its heading', () {
// The summary counted every finding while the heading (and the severity
// counts beside it) counted only new ones, so a fully baselined run read
// "20 findings ({error: 0, warning: 0, info: 0})" over "## Findings (0)".
final baselined = runChecks(
project: analysis.project,
outcomes: analysis.run.outcomes,
flow: analysis.flow,
baseline: {
for (final finding in analysis.report.findings) finding.fingerprint,
},
);
final md = buildGuidance(
root: analysis.project.root,
flow: analysis.flow,
report: baselined,
).markdown;
expect(md, contains(' 0 new findings '));
expect(md, contains('## Findings (0)'));
});
test('json summary exposes counts and suppression notes', () {
final summary = guidance().summary;
expect(summary['findings'], analysis.report.findings.length);

View file

@ -86,4 +86,38 @@ void main() {
);
expect(briefWith(FixPolicy.remove), contains('Cleanly deletable'));
});
test('the legend follows the policy too', () {
// The legend's fixes were one constant map that ignored --policy, so under
// wire-up it still led with "Remove the dead handler" while every finding
// said removal needed explicit approval.
expect(ruleFixFor(FixPolicy.wireUp)[ruleDeadHandler], startsWith('Wire'));
expect(
ruleFixFor(FixPolicy.wireUp)[ruleEmittedNotRendered],
startsWith('Wire'),
);
expect(
ruleFixFor(FixPolicy.remove)[ruleDeadHandler],
startsWith('Remove the dead handler'),
);
expect(briefWith(FixPolicy.wireUp), isNot(contains('Fix: Remove')));
expect(ruleFixFor(FixPolicy.wireUp).keys, containsAll(allRules));
expect(ruleFixFor(FixPolicy.remove).keys, containsAll(allRules));
});
test('removal claims are scoped to the scanned directories', () {
// The scan defaults to --dir lib, so "nothing outside the bloc references
// it" and "safe even without product context" claimed more than was
// checked: a handler dispatched only from test/ read as safe to delete.
for (final policy in FixPolicy.values) {
final brief = briefWith(policy);
expect(brief, contains('nothing under lib'), reason: '$policy');
expect(brief, isNot(contains('without product context')));
expect(brief, isNot(contains('nothing outside the bloc')));
}
final dead = reportWith(
FixPolicy.remove,
).findings.firstWhere((finding) => finding.rule == ruleDeadHandler);
expect(dead.remediation, contains('nothing outside lib'));
});
}

View file

@ -144,7 +144,7 @@ void main() {
expect(action.why, startsWith('${action.score} = '));
expect(action.why, contains('warning 50'));
expect(action.why, contains('user-visible failure'));
expect(action.verify, 'pipe check --rule $ruleEmittedNotRendered');
expect(action.verify, 'pipe check /root --rule $ruleEmittedNotRendered');
expect(action.action, isNotEmpty);
});
@ -331,4 +331,89 @@ void main() {
);
expect(plan.ordered.map((action) => action.label), ['A2', 'A9', 'M', 'Z']);
});
test('the agent plan opens with a caution about what its order means', () {
final plan = buildWorkPlan(
root: '/root',
report: _report([
_finding(
rule: ruleDeadHandler,
severity: PipeSeverity.info,
file: 'lib/a.dart',
line: 1,
data: {'event': 'A'},
),
]),
);
expect(plan.toAgentBlock(), contains('Order is a score, not a mandate.'));
});
test('wiring up a dead handler is not scored as judgement-free', () {
// Under the default wire-up policy the fix is to add a control that
// dispatches the event, which is a product decision; only removal needs
// no judgement.
final findings = [
_finding(
rule: ruleDeadHandler,
severity: PipeSeverity.info,
file: 'lib/a.dart',
line: 1,
data: {'event': 'A'},
),
];
final wireUp = buildWorkPlan(root: '/root', report: _report(findings));
final remove = buildWorkPlan(
root: '/root',
report: _report(findings),
policy: FixPolicy.remove,
);
expect(wireUp.ordered.single.why, isNot(contains('no product judgement')));
expect(
remove.ordered.single.why,
contains('no product judgement needed (+15)'),
);
expect(remove.ordered.single.score, wireUp.ordered.single.score + 15);
});
test('a grouped action names every target, not only the first', () {
final plan = buildWorkPlan(
root: '/root',
report: _report([
for (final event in ['Alpha', 'Beta', 'Gamma'])
_finding(
rule: ruleDeadHandler,
severity: PipeSeverity.info,
file: 'lib/bloc.dart',
line: 1,
data: {'union': 'Events', 'owner': 'Bloc', 'event': event},
remediation: 'Wire it up: dispatch $event from the UI.',
),
]),
);
final action = plan.ordered.single;
expect(action.resolves, 3);
expect(action.action, contains('dispatch each of Alpha, Beta, Gamma'));
});
test('the verify hint reruns the check the way the plan was made', () {
final plan = buildWorkPlan(
root: '/my app',
report: _report([
_finding(
rule: ruleDeadHandler,
severity: PipeSeverity.info,
file: 'lib/a.dart',
line: 1,
data: {'event': 'A'},
),
]),
baselinePath: '.pipe-baseline.json',
directories: const ['lib', 'test'],
);
expect(
plan.ordered.single.verify,
"pipe check '/my app' --rule dead-handler "
'--baseline .pipe-baseline.json --dir lib --dir test',
);
});
}