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" done <<< "$git_local_env"
unset git_local_env git_env_name 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" "$REPO_ROOT/scripts/pre-push-ci-gate.sh"
else 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 fi

1
.github/CODEOWNERS vendored
View file

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

View file

@ -23,9 +23,10 @@
- [ ] Recall manifest still passes (`pipe recall --root <dir> --strict`) - [ ] Recall manifest still passes (`pipe recall --root <dir> --strict`)
## Documentation ## Documentation
- [ ] CHANGELOG updated (user-visible changes) - [ ] CHANGELOG entry: automated — release-please builds it from the
- [ ] README updated (rules table / precision numbers) Conventional Commit subjects, so check those read well; do not edit
- [ ] Stability note updated (if interfaces change) CHANGELOG.md by hand
- [ ] README updated (rules table / precision numbers / flags and output)
## AI usage ## AI usage
- [ ] No AI used - [ ] 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 pipe suggest <path> --format agent --budget 3000
``` ```
Common flags: `--format text|json` (`agent|md|json` for `guide`), `-c N` Common flags: `--format text|json` (`agent|md|json` for `guide`,
(concurrency, default 4), `--sdk <path>` (override SDK discovery), `--dir` `text|agent|json` for `suggest`), `-c N` (concurrency, default 4; 8 for
(repeatable), `--include-generated`, `--limit N`, `--rule <name>` (repeatable), `recall`), `--sdk <path>` (override SDK discovery), `--dir` (repeatable,
`--top N`. 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 Command-specific flags: `index --fail-on-error` exits 1 when the resolved project
has analyzer errors (the default reports the count and exits 0); 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 `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` 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 `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 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 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. 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 `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`. `.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 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 `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 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 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 ```text
$ pipe verify . --baseline .pipe-baseline.json $ pipe verify . --baseline .pipe-baseline.json
@ -135,9 +160,25 @@ RESOLVED
Baselines are self-describing (each entry carries the rule plus a readable label), 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. 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 A baseline stores a **fingerprint** per finding (`rule + file + semantic
identity` such as union/variant/provider/owner/member), so moving code down a identity` such as union/variant/provider/owner/member; for two swallowed catches
file does not resurrect accepted findings. `--fail-on-finding` only counts new 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 error-severity findings, and severity counts in reports are computed over new
findings, so a ratcheted baseline does not keep a build red. 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) | | 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. | | `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-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. | | `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). | | `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-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. | | `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. | | `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. | | `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 `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. | | `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 ### 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: 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. 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 Every finding carries `file:line`, a `path:line: excerpt` evidence string (string
confidence, a UX severity, and a remediation. If a rule cannot be validated it is 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. demoted or deleted, not defended.
### Recall, measured not assumed ### 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 into a permanent guard against the real code rather than a deleted
expectation. expectation.
Current result: **5/5 accounted for** (2 open bugs found, 3 fixed bugs still Result as of 2026-09-20: **5/5 accounted for** (2 open bugs found, 3 fixed bugs
quiet), 0 regressions — measured against the **working tree**, so a repo checked still quiet), 0 regressions — measured against the **working tree**, so a repo
out on an unmerged fix branch reports its cases as missed. The two nuwayze cases checked out on an unmerged fix branch reports its cases as missed. At that date
are wired up on branches (`wire/nuwayze-*`) that are not merged yet, so they still the two nuwayze cases were wired up on branches (`wire/nuwayze-*`) that were not
read as `open` here; they flip to `fixed` when those land. 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 Recall is still measured against a small labelled set. It bounds the risk of
false negatives; it does not prove they are absent. 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 `--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 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 choice, the fixes in the brief's rule legend, the `decision:` line in the agent
dead-handler section (`Unreachable handlers - wire up the control` versus brief, the heading of the dead-handler section (`Unreachable handlers - wire up
`Cleanly deletable`). 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. 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. planned, so a ratcheted baseline yields an empty plan.
```text ```text
1. [warning/high] emitted-not-rendered — CommentsState (CommentsBloc) # pipe plan — test/fixtures/stuck_loading
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) ... Static heuristics; confirm each against the cited file. Order is a score, not a mandate.
where: lib/blocs/comment/comment_bloc.dart:143, ... +2 more Coverage: 12 of 12 discovered file(s) analysed (0 failed, 0 skipped by analysis_options or context).
clears 6 finding(s) (CommentSubmitError, CommentSubmitSuccess, CommentSubmitting, 20 findings collapse to 16 root cause(s) across 8 file(s).
CommentsError, CommentsLoaded, CommentsLoading); verify with `pipe check --rule emitted-not-rendered`
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 The score is deliberately simple and always printed, so the order can be argued
with rather than trusted blindly: 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 | | 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) | | 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 | | 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 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 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 ## Agent guidance
`pipe guide` emits a brief designed to be pasted into an agent prompt: findings `pipe guide` emits a brief designed to be pasted into an agent prompt: a coverage
grouped by root cause, dense `file:line` anchors (`file:18,29,32`), a rule legend line, findings grouped by root cause, dense `file:line` anchors
carrying the standard fix, a `decision:` line that resolves "fix vs delete" where (`file:18,29,32`), a rule legend carrying the standard fix for the chosen
the project makes that decidable, and a `verify:` command. Truncation is always `--policy`, a `decision:` line that resolves "fix vs delete" where the project
explicit — never silently dropped. 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 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 access. Five rounds of that found real defects (a legend that did not match the
@ -348,19 +415,25 @@ actionable class.
## Architecture ## Architecture
```text ```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/project.dart package graph validation, source discovery
lib/src/sdk.dart SDK discovery (Flutter-aware) 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/index.dart resolution health, timing, diagnostics
lib/src/model.dart declaration model lib/src/model.dart declaration model
lib/src/state_flow.dart the state-flow IR (unions, emissions, render sites, lib/src/state_flow.dart the state-flow IR (unions, emissions, render sites,
events, flags, Riverpod) events, flags, Riverpod)
lib/src/excerpt.dart bounded, literal-free source excerpts
lib/src/rules.dart breakage detectors, findings, baselines 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/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/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/pipeline.dart shared analyze -> graph -> rules pipeline
lib/src/guide.dart agent brief / markdown report / yams emission lib/src/guide.dart agent brief / markdown report / yams emission
tool/adjudicate.py independent precision adjudicator tool/adjudicate.py independent precision adjudicator
@ -414,7 +487,7 @@ is byte-identical across runs.
dart test 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 negative controls per rule), Riverpod and provider modelling, agent-brief
rendering, fix-order scoring, baseline round-tripping (including legacy rendering, fix-order scoring, baseline round-tripping (including legacy
fingerprints-only baselines), verification, and recall evaluation, against a fingerprints-only baselines), verification, and recall evaluation, against a

View file

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

View file

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

View file

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

View file

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

View file

@ -33,7 +33,8 @@ const Map<String, String> ruleEpithet = {
ruleEmittedNotRendered: ruleEmittedNotRendered:
'A state the bloc emits that no UI code matches, so the user never sees it.', 'A state the bloc emits that no UI code matches, so the user never sees it.',
ruleRenderedNotEmitted: 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: ruleLoadingNotReset:
'A loading state whose failure path never resolves, so the spinner sticks.', 'A loading state whose failure path never resolves, so the spinner sticks.',
ruleLoadingFlagNotReset: ruleLoadingFlagNotReset:
@ -44,7 +45,8 @@ const Map<String, String> ruleEpithet = {
ruleDeadHandler: 'An `on<Event>` registration that nothing ever dispatches.', ruleDeadHandler: 'An `on<Event>` registration that nothing ever dispatches.',
ruleRiverpodAsyncGap: ruleRiverpodAsyncGap:
'An async Riverpod provider read in the UI whose error and/or loading ' '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: ruleNotifierNeverNotifies:
'A ChangeNotifier method that changes state without ever notifying ' 'A ChangeNotifier method that changes state without ever notifying '
'listeners, so the UI keeps showing the old values.', '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)` ' 'A Listenable read with `context.read` / `Provider.of(listen: false)` '
'inside build, so the widget never subscribes and can go stale.', 'inside build, so the widget never subscribes and can go stale.',
ruleNotifierFieldUnread: ruleNotifierFieldUnread:
'A public getter on a consumed ChangeNotifier that nothing reads, so the ' 'A public getter on a consumed ChangeNotifier that nothing in the scanned '
'state it exposes can never affect what the user sees.', 'code reads by name, so the state it exposes likely never reaches the UI.',
ruleProviderCreatedInBuild: ruleProviderCreatedInBuild:
'A provider constructed inside build without `.value`, so a new instance ' 'A provider constructed inside build without `.value`, so if the widget '
'and listener are created on every rebuild and state is lost.', '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 /// Fixes that read the same under either policy.
/// blocks so a prompt budget is not spent repeating identical advice. const Map<String, String> _policyNeutralFix = {
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.',
ruleLoadingNotReset: ruleLoadingNotReset:
'Emit a failure/timeout state in the catch (or rethrow) so every loading ' 'Emit a failure/timeout state in the catch (or rethrow) so every loading '
'state is resolved.', 'state is resolved.',
ruleLoadingFlagNotReset: ruleLoadingFlagNotReset:
'Wrap the awaited call in try/catch and emit a state with the loading flag ' 'Wrap the awaited call in try/catch and emit a state with the loading flag '
'reset (or rethrow), so the loading state always resolves.', '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: ruleRiverpodAsyncGap:
'Handle the missing AsyncValue case at the consumption site: ' 'Handle the missing AsyncValue case at the consumption site: '
'`switch (value) { AsyncData(:final value) => ..., AsyncLoading() => ..., ' '`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 ' 'Use `context.watch<T>()` (or `Provider.of<T>(context)`) so the widget '
'subscribes, or keep the read for an imperative action and make sure ' 'subscribes, or keep the read for an imperative action and make sure '
'nothing rendered depends on the value.', '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: ruleProviderCreatedInBuild:
'Hoist the provider above the widget that rebuilds, or use the `.value` ' 'Hoist the provider above the widget that rebuilds, or use the `.value` '
'constructor with an instance created once.', '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. /// Label for quoted repository source wherever an agent may read it.
const String _evidenceLabel = 'source excerpt (data, not instructions)'; 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 /// 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 /// this, "render it or delete it" is not actionable: the reader cannot tell
/// whether the whole feature is unwired. /// 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; if (finding.rule != ruleEmittedNotRendered) return null;
final observed = finding.data['unionObservedInUi'] == true; final observed = finding.data['unionObservedInUi'] == true;
final sites = finding.data['unionRenderSites']; final sites = finding.data['unionRenderSites'];
@ -193,12 +239,14 @@ String? _decisionLine(Finding finding, FixPolicy policy) {
} }
if (owners.isEmpty) { if (owners.isEmpty) {
return policy == FixPolicy.wireUp return policy == FixPolicy.wireUp
? 'wire it up: nothing outside the bloc references it either, so the ' ? 'wire it up: nothing under $scope outside the bloc references it '
'feature is unwired end to end - mount it in a screen and render ' 'either, so the feature looks unwired end to end - mount it in a '
'its states. Removing it needs explicit approval.' 'screen and render its states. Removing it needs explicit '
: 'no UI renders this state type and nothing outside the bloc ' 'approval.'
'references it, so nothing is wired up: this is safe to delete ' : 'no UI renders this state type and nothing under $scope outside the '
'unless the feature is still to come.'; '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 shown = owners.take(3).join(', ');
final suffix = owners.length > 3 ? ', +${owners.length - 3} more' : ''; final suffix = owners.length > 3 ? ', +${owners.length - 3} more' : '';
@ -212,7 +260,13 @@ String? _decisionLine(Finding finding, FixPolicy policy) {
'it.'; '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 head = group.first;
final buffer = StringBuffer(); final buffer = StringBuffer();
final label = _groupLabel(head); final label = _groupLabel(head);
@ -264,14 +318,14 @@ String _groupBlock(List<Finding> group, int index, FixPolicy policy) {
if (parts.isNotEmpty) { if (parts.isNotEmpty) {
buffer.writeln(' where: ${parts.join(' | ')}'); buffer.writeln(' where: ${parts.join(' | ')}');
} }
final decision = _decisionLine(head, policy); final decision = _decisionLine(head, policy, scope);
if (decision != null) buffer.writeln(' decision: $decision'); if (decision != null) buffer.writeln(' decision: $decision');
// Evidence quotes the analysed repository, which is untrusted input. It is // 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. // labelled as data and fenced so nothing in it can close its own quotation.
for (final item in head.evidence.take(1)) { for (final item in head.evidence.take(1)) {
buffer.writeln(' $_evidenceLabel: ${dataSpan(item)}'); buffer.writeln(' $_evidenceLabel: ${dataSpan(item)}');
} }
buffer.writeln(' verify: pipe check --rule ${head.rule}'); buffer.writeln(' verify: ${verifyFor(head.rule)}');
return buffer.toString(); return buffer.toString();
} }
@ -283,7 +337,16 @@ Guidance buildGuidance({
int budgetChars = 6000, int budgetChars = 6000,
int maxFindings = 12, int maxFindings = 12,
FixPolicy policy = FixPolicy.wireUp, 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 ranked = rankFindings(report.newFindings);
final summary = <String, Object?>{ final summary = <String, Object?>{
'root': root, 'root': root,
@ -353,11 +416,12 @@ Guidance buildGuidance({
// in the report. Its size is known up front, which lets group selection // in the report. Its size is known up front, which lets group selection
// reserve room for it instead of dropping it later. // reserve room for it instead of dropping it later.
final presentRules = report.countByRule.keys.toSet(); final presentRules = report.countByRule.keys.toSet();
final fixes = ruleFixFor(policy);
final glossary = StringBuffer(); final glossary = StringBuffer();
for (final rule in allRules) { for (final rule in allRules) {
if (!presentRules.contains(rule)) continue; if (!presentRules.contains(rule)) continue;
glossary.writeln( glossary.writeln(
'- $rule: ${ruleEpithet[rule] ?? ''} Fix: ${ruleFix[rule] ?? ''}', '- $rule: ${ruleEpithet[rule] ?? ''} Fix: ${fixes[rule] ?? ''}',
); );
} }
const legendHeading = '## How to read the rule names\n'; const legendHeading = '## How to read the rule names\n';
@ -382,7 +446,7 @@ Guidance buildGuidance({
for (var i = 0; i < groups.length; i++) { for (var i = 0; i < groups.length; i++) {
final block = selected.length >= maxFindings final block = selected.length >= maxFindings
? null ? null
: _groupBlock(groups[i], selected.length + 1, policy); : _groupBlock(groups[i], selected.length + 1, policy, scope, verifyFor);
if (block == null || if (block == null ||
runningLength + block.length + noteReserve > budgetChars) { runningLength + block.length + noteReserve > budgetChars) {
// Stop at the first entry that does not fit. Skipping it and packing the // 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 // Dead handlers: once the scan's scope is confirmed, removing one needs no
// surface those anchors even when they did not fit the ranked entries above. // product context, so surface those anchors even when they did not fit the
// ranked entries above.
final dead = report.newFindings final dead = report.newFindings
.where((f) => f.rule == ruleDeadHandler) .where((f) => f.rule == ruleDeadHandler)
.toList(); .toList();
@ -456,11 +521,14 @@ Guidance buildGuidance({
) )
..writeln( ..writeln(
policy == FixPolicy.wireUp policy == FixPolicy.wireUp
? 'Nothing dispatches these, so the handler is unreachable. Wire it ' ? 'Nothing under $scope dispatches these, so the handler is '
'up by adding the control that dispatches the event; removing ' 'unreachable from there. Check that no unscanned directory '
'the handler needs explicit approval: $anchors' 'dispatches them (rerun with --dir), then wire it up by adding '
: 'Nothing dispatches these, so removing the `on<...>` registration ' 'the control that dispatches the event; removing the handler '
'is safe even without product context: $anchors', '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); if (buffer.length + section.length < budgetChars) buffer.write(section);
} }
@ -524,9 +592,11 @@ Guidance buildGuidance({
..writeln() ..writeln()
..write(coverageLine == null ? '' : '$coverageLine\n\n') ..write(coverageLine == null ? '' : '$coverageLine\n\n')
..writeln( ..writeln(
// New findings only, matching the heading below and countBySeverity,
// which already counted only new ones.
'${flow.unions.length} blocs/cubits, ${summary['stateVariants']} state ' '${flow.unions.length} blocs/cubits, ${summary['stateVariants']} state '
'variants, ${report.findings.length} findings ' 'variants, ${report.newFindings.length} new findings '
'(${report.countBySeverity}).', '(${report.countBySeverity})$baselinedNote.',
) )
..writeln() ..writeln()
..writeln('## Findings (${ranked.length})'); ..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 /// 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, /// 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. /// so a report read back from the corpus can be dated and reproduced.

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import 'analysis.dart'; import 'analysis.dart';
import 'guide.dart';
import 'rules.dart'; import 'rules.dart';
/// One fixable root cause: a single action that resolves one or more findings. /// One fixable root cause: a single action that resolves one or more findings.
@ -16,7 +17,8 @@ class FixAction {
required this.why, required this.why,
this.file = '', this.file = '',
this.line, this.line,
}); String? verify,
}) : verify = verify ?? checkCommand(root: '.', rule: rule);
final String rule; final String rule;
final PipeSeverity severity; final PipeSeverity severity;
@ -42,7 +44,9 @@ class FixAction {
final String file; final String file;
final int? line; 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() => { Map<String, Object?> toJson() => {
'rule': rule, 'rule': rule,
@ -159,10 +163,11 @@ class WorkPlan {
final buffer = StringBuffer() final buffer = StringBuffer()
..writeln('# pipe plan — $root') ..writeln('# pipe plan — $root')
..writeln() ..writeln()
..writeln(agentCaution)
..write(coverage == null ? '' : '${coverage!.describe()}\n') ..write(coverage == null ? '' : '${coverage!.describe()}\n')
..writeln( ..writeln(
'$totalFindings findings collapse to $totalCauses root cause(s) across ' '$totalFindings findings collapse to $totalCauses root cause(s) across '
'${files.length} file(s). Work them in this order.', '${files.length} file(s).',
) )
..writeln(); ..writeln();
final actions = ordered; final actions = ordered;
@ -245,6 +250,11 @@ const Map<String, double> _confidenceFactor = {
'low': 0.4, '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. /// Rules whose fix needs no product judgement, so they can just be done.
const Set<String> _certaintyRules = { const Set<String> _certaintyRules = {
ruleDeadHandler, ruleDeadHandler,
@ -253,17 +263,24 @@ const Set<String> _certaintyRules = {
ruleLoadingFlagNotReset, 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 head = group.first;
final base = _severityBase[head.severity] ?? 10; final base = _severityBase[head.severity] ?? 10;
final factor = _confidenceFactor[head.confidence] ?? 1.0; final factor = _confidenceFactor[head.confidence] ?? 1.0;
final breadth = ((group.length - 1).clamp(0, 5)) * 10; final breadth = ((group.length - 1).clamp(0, 5)) * 10;
final uxCritical = head.data['uxCritical'] == true ? 25 : 0; 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; 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 head = group.first;
final base = _severityBase[head.severity] ?? 10; final base = _severityBase[head.severity] ?? 10;
final parts = <String>[ final parts = <String>[
@ -273,8 +290,7 @@ String _whyOf(List<Finding> group, int score) {
'one fix clears ${group.length} findings (+' 'one fix clears ${group.length} findings (+'
'${((group.length - 1).clamp(0, 5)) * 10})', '${((group.length - 1).clamp(0, 5)) * 10})',
if (head.data['uxCritical'] == true) 'user-visible failure (+25)', if (head.data['uxCritical'] == true) 'user-visible failure (+25)',
if (_certaintyRules.contains(head.rule)) if (_isCertain(head.rule, policy)) 'no product judgement needed (+15)',
'no product judgement needed (+15)',
]; ];
return '$score = ${parts.join(', ')}'; return '$score = ${parts.join(', ')}';
} }
@ -308,6 +324,15 @@ List<String> _targetsOf(List<Finding> group) {
final targets = <String>[]; final targets = <String>[];
for (final finding in group) { for (final finding in group) {
final data = finding.data; 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 [ for (final key in const [
'variant', 'variant',
'event', 'event',
@ -328,6 +353,41 @@ List<String> _targetsOf(List<Finding> group) {
return targets; 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) { String _groupKey(Finding finding) {
final data = finding.data; final data = finding.data;
for (final key in const [ 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 /// Build the plan from a report. Only new findings are planned, so a ratcheted
/// baseline yields an empty plan. /// 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 newFindings = report.newFindings;
final order = <String>[]; final order = <String>[];
final groups = <String, List<Finding>>{}; final groups = <String, List<Finding>>{};
@ -369,23 +435,30 @@ WorkPlan buildWorkPlan({required String root, required FindingReport report}) {
final group = groups[key]!; final group = groups[key]!;
final head = group.first; final head = group.first;
final file = head.file ?? '(unknown)'; final file = head.file ?? '(unknown)';
final score = _scoreOf(group); final score = _scoreOf(group, policy);
final targets = _targetsOf(group);
final action = FixAction( final action = FixAction(
rule: head.rule, rule: head.rule,
severity: head.severity, severity: head.severity,
confidence: head.confidence, confidence: head.confidence,
label: _labelOf(group) ?? file, label: _labelOf(group) ?? file,
action: head.remediation ?? head.message, action: _actionOf(group, targets, policy),
locations: [ locations: [
for (final finding in group) for (final finding in group)
if (finding.location.isNotEmpty) finding.location, if (finding.location.isNotEmpty) finding.location,
]..sort(), ]..sort(),
targets: _targetsOf(group), targets: targets,
resolves: group.length, resolves: group.length,
score: score, score: score,
why: _whyOf(group, score), why: _whyOf(group, score, policy),
file: file, file: file,
line: head.line, line: head.line,
verify: checkCommand(
root: root,
rule: head.rule,
baseline: baselinePath,
directories: directories,
),
); );
byFile.putIfAbsent(file, () => []).add(action); byFile.putIfAbsent(file, () => []).add(action);
fileScores.update(file, (value) => value + score, ifAbsent: () => score); fileScores.update(file, (value) => value + score, ifAbsent: () => score);

View file

@ -31,7 +31,7 @@
"match": { "match": {
"provider": "subscriptionStreamProvider" "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" "status": "fixed"
}, },
{ {

View file

@ -39,6 +39,9 @@ void main() {
expect(hits.single.confidence, 'high'); expect(hits.single.confidence, 'high');
expect(hits.single.evidence, isNotEmpty); expect(hits.single.evidence, isNotEmpty);
expect(hits.single.remediation, isNotNull); 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', () { 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', () { test('yams metadata records provenance, not a stale owner', () {
final metadata = yamsMetadata( final metadata = yamsMetadata(
project: 'app', 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', () { test('json summary exposes counts and suppression notes', () {
final summary = guidance().summary; final summary = guidance().summary;
expect(summary['findings'], analysis.report.findings.length); expect(summary['findings'], analysis.report.findings.length);

View file

@ -86,4 +86,38 @@ void main() {
); );
expect(briefWith(FixPolicy.remove), contains('Cleanly deletable')); 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, startsWith('${action.score} = '));
expect(action.why, contains('warning 50')); expect(action.why, contains('warning 50'));
expect(action.why, contains('user-visible failure')); 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); expect(action.action, isNotEmpty);
}); });
@ -331,4 +331,89 @@ void main() {
); );
expect(plan.ordered.map((action) => action.label), ['A2', 'A9', 'M', 'Z']); 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',
);
});
} }