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

Merged
trevon merged 13 commits from docs/honest-agent-guidance into main 2026-09-22 21:19:58 +00:00
4 changed files with 54 additions and 18 deletions
Showing only changes of commit cb302bbe12 - Show all commits

fix(guide): scope "nothing references it" to the directories scanned

The brief said "nothing outside the bloc references it" and, under
--policy remove, that removing a dead handler "is safe even without product
context", while the scan defaults to --dir lib. The dead-handler finding
itself was scoped in f0fc57e; these three statements were not. A handler
dispatched only from test/ still read as safe to delete.

The decision line and the dead-handler section now say "nothing under <dirs>"
and tell the reader to rule out unscanned directories (rerun with --dir)
before wiring or removing; the remove-policy remediation in rules.dart says
the same. buildGuidance takes the scanned directories, which the guide
command passes from the project.
trvon 2026-09-22 12:00:49 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

View file

@ -740,6 +740,7 @@ 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,
budgetChars: readPositiveInt(results, 'budget', fallback: 6000), budgetChars: readPositiveInt(results, 'budget', fallback: 6000),
maxFindings: readPositiveInt(results, 'max-findings', fallback: 12), maxFindings: readPositiveInt(results, 'max-findings', fallback: 12),
); );

View file

@ -220,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'];
@ -236,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' : '';
@ -255,7 +260,12 @@ 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,
) {
final head = group.first; final head = group.first;
final buffer = StringBuffer(); final buffer = StringBuffer();
final label = _groupLabel(head); final label = _groupLabel(head);
@ -307,7 +317,7 @@ 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.
@ -326,7 +336,9 @@ 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'],
}) { }) {
final scope = scannedDirectories.join(', ');
final ranked = rankFindings(report.newFindings); final ranked = rankFindings(report.newFindings);
final summary = <String, Object?>{ final summary = <String, Object?>{
'root': root, 'root': root,
@ -426,7 +438,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);
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
@ -479,8 +491,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();
@ -500,11 +513,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);
} }

View file

@ -715,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},
), ),
); );

View file

@ -104,4 +104,20 @@ void main() {
expect(ruleFixFor(FixPolicy.wireUp).keys, containsAll(allRules)); expect(ruleFixFor(FixPolicy.wireUp).keys, containsAll(allRules));
expect(ruleFixFor(FixPolicy.remove).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'));
});
} }