fix(guide,suggest): word agent guidance no stronger than the evidence #2
17 changed files with 541 additions and 110 deletions
|
|
@ -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
1
.github/CODEOWNERS
vendored
|
|
@ -6,6 +6,5 @@
|
|||
/tool/** @trvon
|
||||
/bin/** @trvon
|
||||
/test/** @trvon
|
||||
/docs/** @trvon
|
||||
/.github/** @trvon
|
||||
/scripts/** @trvon
|
||||
|
|
|
|||
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -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
149
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue