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"
|
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
1
.github/CODEOWNERS
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
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`)
|
- [ ] 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
149
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.)
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue