Skip to main content

harn_parser/diagnostic_codes/
repairs.rs

1//! Repair vocabulary attached to a diagnostic code.
2//!
3//! A diagnostic says what is wrong; a repair says what an agent or editor may
4//! do about it and how far that action is allowed to reach. The safety ladder
5//! here is a contract surface — `harn fix --safety <class>` and IDE auto-apply
6//! ceilings both compare against it — so the wire strings are as stable as the
7//! codes themselves.
8//!
9//! Split out of `diagnostic_codes.rs` (#6126), which had reached its
10//! source-length ceiling and could not accept another code. The registry keeps
11//! the codes; this module keeps everything that answers "and what do I do".
12
13use std::{fmt, str::FromStr};
14
15use super::Code;
16
17/// Autonomy ceiling of a proposed repair.
18///
19/// Agents and IDEs use this class to auto-apply, suggest, or escalate a fix. Variants
20/// are ordered from least to most disruptive — call sites can compare
21/// with `<=` to enforce a configured ceiling like
22/// `"apply anything up to behavior-preserving"`.
23///
24/// The wire-format strings (`format-only`, `behavior-preserving`, …) are
25/// the contract surface; renaming a variant string is a breaking change.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub enum RepairSafety {
28    /// Whitespace, trivia, or canonical layout only. No code structure
29    /// changes; safe to auto-apply.
30    FormatOnly,
31    /// Intended not to change observable runtime behavior (e.g. delete an
32    /// unreachable branch, drop a redundant cast).
33    BehaviorPreserving,
34    /// Confined to the current local scope or file. Runtime behavior may
35    /// change, but the blast radius does not cross a declaration boundary
36    /// or a public surface.
37    ScopeLocal,
38    /// Touches a signature, export, or call-site surface that other files
39    /// or external consumers can observe.
40    SurfaceChanging,
41    /// Required capabilities or sandbox profile may change as a result of
42    /// applying the repair (e.g. swapping `provider: "openai"` for a
43    /// capability flag widens the routing surface).
44    CapabilityChanging,
45    /// Planning hint only — agents should propose, never auto-apply.
46    /// Aligned with the `AutonomyTier::Suggest`/`ActWithApproval` rungs
47    /// in `trust_graph.rs`.
48    NeedsHuman,
49}
50
51impl RepairSafety {
52    pub const ALL: &'static [RepairSafety] = &[
53        RepairSafety::FormatOnly,
54        RepairSafety::BehaviorPreserving,
55        RepairSafety::ScopeLocal,
56        RepairSafety::SurfaceChanging,
57        RepairSafety::CapabilityChanging,
58        RepairSafety::NeedsHuman,
59    ];
60
61    /// Stable wire-format string. The contract surface — do not rename
62    /// without coordinating with `harn fix --safety <…>` callers and
63    /// downstream LSP/IDE clients.
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            RepairSafety::FormatOnly => "format-only",
67            RepairSafety::BehaviorPreserving => "behavior-preserving",
68            RepairSafety::ScopeLocal => "scope-local",
69            RepairSafety::SurfaceChanging => "surface-changing",
70            RepairSafety::CapabilityChanging => "capability-changing",
71            RepairSafety::NeedsHuman => "needs-human",
72        }
73    }
74
75    /// True when `self` sits at or below `ceiling`. Used by
76    /// `harn fix --apply --safety <ceiling>` and IDE auto-apply policies
77    /// to decide whether a repair clears the configured autonomy bar.
78    pub const fn is_at_most(self, ceiling: RepairSafety) -> bool {
79        (self as u8) <= (ceiling as u8)
80    }
81
82    /// Whether an editor or `harn lint --fix` may apply this repair without
83    /// an explicit safety opt-in.
84    pub const fn is_machine_applicable(self) -> bool {
85        self.is_at_most(RepairSafety::BehaviorPreserving)
86    }
87}
88
89impl fmt::Display for RepairSafety {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.write_str(self.as_str())
92    }
93}
94
95/// Error returned when parsing an unknown repair-safety string.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct ParseRepairSafetyError;
98
99impl fmt::Display for ParseRepairSafetyError {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        f.write_str("unknown Harn repair-safety class")
102    }
103}
104
105impl std::error::Error for ParseRepairSafetyError {}
106
107impl FromStr for RepairSafety {
108    type Err = ParseRepairSafetyError;
109
110    fn from_str(value: &str) -> Result<Self, Self::Err> {
111        RepairSafety::ALL
112            .iter()
113            .copied()
114            .find(|safety| safety.as_str() == value)
115            .ok_or(ParseRepairSafetyError)
116    }
117}
118
119/// Namespaced kebab-case repair identifier (e.g. `imports/fix-path`).
120///
121/// Wraps a `Cow` so registry-driven values reuse a `'static` literal and
122/// per-site overrides can still attach an owned string. The wire-format
123/// string is the contract surface — never normalize or reformat on read.
124#[derive(Debug, Clone, PartialEq, Eq, Hash)]
125pub struct RepairId(std::borrow::Cow<'static, str>);
126
127impl RepairId {
128    pub const fn from_static(s: &'static str) -> Self {
129        RepairId(std::borrow::Cow::Borrowed(s))
130    }
131
132    pub fn from_owned(s: String) -> Self {
133        RepairId(std::borrow::Cow::Owned(s))
134    }
135
136    pub fn as_str(&self) -> &str {
137        &self.0
138    }
139}
140
141impl fmt::Display for RepairId {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        f.write_str(&self.0)
144    }
145}
146
147/// A structured repair proposal attached to a diagnostic.
148///
149/// `id` and `summary` are agent-readable metadata; `safety` is the
150/// dispatch dimension that decides whether the repair clears an
151/// autonomy ceiling. The concrete edits, when known statically, live on
152/// the diagnostic's `fix: Option<Vec<FixEdit>>`; this `Repair` is the
153/// classifier above those edits, not a replacement for them.
154#[derive(Debug, Clone)]
155pub struct Repair {
156    pub id: RepairId,
157    pub summary: String,
158    pub safety: RepairSafety,
159}
160
161impl Repair {
162    pub fn from_template(template: &RepairTemplate) -> Self {
163        Repair {
164            id: RepairId::from_static(template.id),
165            summary: template.summary.to_string(),
166            safety: template.safety,
167        }
168    }
169}
170
171/// Static-lifetime repair template bound to a diagnostic code.
172///
173/// Stored in the registry alongside `Code`. Construction sites can
174/// materialize a `Repair` via [`Repair::from_template`] or override
175/// `summary` for instance-specific detail by building a `Repair`
176/// directly.
177#[derive(Debug, Clone, Copy)]
178pub struct RepairTemplate {
179    pub id: &'static str,
180    pub summary: &'static str,
181    pub safety: RepairSafety,
182}
183
184impl Code {
185    /// Look up the default repair template attached to this diagnostic
186    /// code, or `None` if no actionable fix shape is registered.
187    pub const fn repair_template(self) -> Option<&'static RepairTemplate> {
188        match self {
189            // --- TYP: type mismatches & coercions -------------------------
190            Code::TypeMismatch
191            | Code::ReturnTypeMismatch
192            | Code::AssignmentTypeMismatch
193            | Code::ArgumentTypeMismatch
194            | Code::VariableTypeMismatch
195            | Code::ClosureReturnTypeMismatch
196            | Code::FieldTypeMismatch
197            | Code::MethodTypeMismatch
198            | Code::InvalidIndexType => Some(&REPAIR_INSERT_EXPLICIT_CONVERSION),
199            Code::StringInterpolationRewrite => Some(&REPAIR_REWRITE_STRING_INTERPOLATION),
200            Code::UnknownTypeName => Some(&REPAIR_IMPORTS_FIX_PATH),
201            Code::ImplicitAnyParameter => Some(&REPAIR_TYPES_ANNOTATE_PARAMETER),
202            Code::InvalidCast => Some(&REPAIR_CASTS_REMOVE_UNCHECKED),
203
204            // --- NAM / IMP: imports & names -------------------------------
205            Code::UndefinedVariable
206            | Code::UndefinedFunction
207            | Code::UnknownField
208            | Code::UnknownMethod
209            | Code::UnknownBuiltin
210            | Code::UnknownDeclaration => Some(&REPAIR_BINDINGS_RENAME_TO_CLOSEST),
211            Code::InvalidMainSignature => Some(&REPAIR_BINDINGS_THREAD_HARNESS_NEEDS_PARAM),
212            Code::DeprecatedFunction => Some(&REPAIR_STDLIB_MIGRATE_RENAMED),
213            Code::RemovedBuiltinMethod => Some(&REPAIR_METHODS_MIGRATE_REMOVED),
214            Code::ModuleImportUnresolved | Code::ImportResolutionFailed => {
215                Some(&REPAIR_IMPORTS_FIX_PATH)
216            }
217            Code::ModuleImportUnused => Some(&REPAIR_IMPORTS_REMOVE_UNUSED),
218            Code::ModuleImportOrder => Some(&REPAIR_IMPORTS_REORDER),
219
220            // --- CAP / RCV: capabilities & error recovery -----------------
221            Code::CapabilityResultUnchecked => Some(&REPAIR_ERRORS_CHECK_OR_RESCUE),
222            Code::CapabilityBindingInvalid => Some(&REPAIR_MANUAL_REVIEW_CAPABILITY),
223            Code::EffectInheritanceViolation => Some(&REPAIR_POLICY_NARROW_CHILD_EFFECTS),
224            Code::RescueOutsideFunction | Code::TryOutsideFunction => {
225                Some(&REPAIR_ERRORS_WRAP_IN_FN)
226            }
227
228            // --- LLM / PRM: model + prompt contract -----------------------
229            Code::LlmSchemaMissing => Some(&REPAIR_LLM_ADD_SCHEMA),
230            Code::LlmProviderIdentityBranch | Code::PromptProviderIdentityBranch => {
231                Some(&REPAIR_LLM_USE_CAPABILITY_FLAG)
232            }
233            Code::PromptInjectionRisk => Some(&REPAIR_PROMPTS_ESCAPE_INJECTION),
234            Code::PromptToolSurfaceUnknown | Code::PromptToolSurfaceDeferredReference => {
235                Some(&REPAIR_PROMPTS_ADD_TOOL_TO_SURFACE)
236            }
237            Code::PromptVariantExplosion => Some(&REPAIR_MANUAL_NEEDS_HUMAN),
238
239            // --- STD: stdlib usage ----------------------------------------
240            Code::DeprecatedStdlibSymbol => Some(&REPAIR_STDLIB_MIGRATE_RENAMED),
241            Code::LintMissingStdlibMetadata => Some(&REPAIR_DOC_ADD_STDLIB_METADATA),
242
243            // --- OWN: ownership & mutability ------------------------------
244            Code::ImmutableAssignment => Some(&REPAIR_BINDINGS_MAKE_MUTABLE),
245            Code::MutableNeverReassigned => Some(&REPAIR_BINDINGS_MAKE_IMMUTABLE),
246
247            // --- MAT: match exhaustiveness --------------------------------
248            Code::NonExhaustiveMatch => Some(&REPAIR_MATCH_ADD_MISSING_ARMS),
249            Code::DuplicateMatchArm => Some(&REPAIR_MATCH_REMOVE_DUPLICATE_ARM),
250
251            // --- ORC: orchestration ---------------------------------------
252            Code::UnreachableCode => Some(&REPAIR_DEAD_CODE_REMOVE),
253
254            // --- FMT: formatter -------------------------------------------
255            Code::FormatterWouldReformat | Code::FormatterTrailingComma => {
256                Some(&REPAIR_FORMAT_REFORMAT)
257            }
258
259            // --- LNT: lints with structured fixes -------------------------
260            Code::LintUnusedVariable
261            | Code::LintUnusedPatternBinding
262            | Code::LintUnusedParameter => Some(&REPAIR_BINDINGS_RENAME_UNUSED),
263            Code::LintUnusedPipelineInput => Some(&REPAIR_BINDINGS_REMOVE_UNUSED_PIPELINE_INPUT),
264            Code::LintCapabilityParameterName => Some(&REPAIR_BINDINGS_NAME_CAPABILITY_PARAMETER),
265            Code::LintUnusedImport => Some(&REPAIR_IMPORTS_REMOVE_UNUSED),
266            Code::LintUnusedFunction | Code::LintUnusedType => {
267                Some(&REPAIR_DECLARATIONS_REMOVE_UNUSED)
268            }
269            Code::LintMutableNeverReassigned => Some(&REPAIR_BINDINGS_MAKE_IMMUTABLE),
270            Code::LintImportOrder => Some(&REPAIR_IMPORTS_REORDER),
271            Code::LintBlankLineBetweenItems
272            | Code::LintTrailingComma
273            | Code::LintUnnecessaryParentheses
274            | Code::LintRequireFileHeader => Some(&REPAIR_FORMAT_REFORMAT),
275            Code::LintLegacyDocComment => Some(&REPAIR_DOC_COMMENT_MIGRATE),
276            Code::LintEmptyBlock => Some(&REPAIR_BLOCK_REMOVE_EMPTY),
277            Code::LintUnnecessaryElseReturn | Code::LintLetThenReturn => {
278                Some(&REPAIR_CONTROL_FLOW_FLATTEN)
279            }
280            Code::LintNilCoalesceNoop
281            | Code::LintNilCoalesceSelfFallback
282            | Code::LintRedundantNilTernary
283            | Code::LintUnnecessarySafeNavigation
284            | Code::LintUnnecessaryNonNullAssert
285            | Code::LintPreferOptionalShorthand
286            | Code::LintComparisonToBool
287            | Code::LintPointlessComparison
288            | Code::LintConstantLogicalOperand => Some(&REPAIR_EXPRESSION_SIMPLIFY),
289            Code::LintPreferPick => Some(&REPAIR_RECORDS_PICK_FIELDS),
290            Code::LintUnnecessaryCast => Some(&REPAIR_CASTS_REMOVE_REDUNDANT),
291            Code::LintRedundantClone => Some(&REPAIR_CLONE_REMOVE_REDUNDANT),
292            Code::LintEagerCollectionConversion => Some(&REPAIR_COLLECTION_PREFER_LAZY),
293            Code::LintDeadCodeAfterReturn => Some(&REPAIR_DEAD_CODE_REMOVE),
294            Code::LintRenamedStdlibSymbol => Some(&REPAIR_STDLIB_MIGRATE_RENAMED),
295            Code::LintAmbientClockBuiltin => Some(&REPAIR_BINDINGS_THREAD_HARNESS_CLOCK),
296            Code::LintAmbientFsBuiltin => Some(&REPAIR_BINDINGS_THREAD_HARNESS_FS),
297            Code::LintAmbientEnvBuiltin => Some(&REPAIR_BINDINGS_THREAD_HARNESS_ENV),
298            Code::LintAmbientRandomBuiltin => Some(&REPAIR_BINDINGS_THREAD_HARNESS_RANDOM),
299            Code::LintAmbientNetBuiltin => Some(&REPAIR_BINDINGS_THREAD_HARNESS_NET),
300            Code::LintAmbientStdioBuiltin => Some(&REPAIR_BINDINGS_THREAD_HARNESS),
301            Code::LintAmbientHarnessMethod => Some(&REPAIR_BINDINGS_THREAD_HARNESS_METHOD),
302            Code::LintBroadHarnessParameter => Some(&REPAIR_BINDINGS_ATTENUATE_HARNESS),
303            Code::LintRemovedLlmOptions => Some(&REPAIR_LLM_MIGRATE_REMOVED_OPTION),
304            Code::LintTemplateProviderIdentityBranch => Some(&REPAIR_LLM_USE_CAPABILITY_FLAG),
305            Code::LintPromptInjectionRisk => Some(&REPAIR_PROMPTS_ESCAPE_INJECTION),
306            Code::LintShadowVariable => Some(&REPAIR_BINDINGS_RENAME_SHADOW),
307            Code::LintNamingConvention => Some(&REPAIR_STYLE_RENAME_TO_CONVENTION),
308            Code::LintUnhandledApprovalResult => Some(&REPAIR_ERRORS_CHECK_OR_RESCUE),
309            Code::LintMissingHarndoc => Some(&REPAIR_DOC_ADD_HARNDOC),
310            Code::LintDuplicateMatchArm => Some(&REPAIR_MATCH_REMOVE_DUPLICATE_ARM),
311            // HARN-LNT-029 is the lint face of the boundary-validation rule, so
312            // its repair has to validate. HARN-LNT-060 below is a different
313            // complaint — an inline options dict bypassing the typed option
314            // constructors — where naming the shape is the whole fix and no
315            // untrusted payload is involved.
316            Code::LintUntypedDictAccess => Some(&REPAIR_TYPES_VALIDATE_BOUNDARY_VALUE),
317            Code::LintUnnormalizedOptions => Some(&REPAIR_TYPES_ADD_SHAPE_ANNOTATION),
318            Code::LintMcpToolAnnotations => Some(&REPAIR_MANUAL_NEEDS_HUMAN),
319            Code::LintTemplateVariantExplosion | Code::LintLongRunningWithoutCleanup => {
320                Some(&REPAIR_MANUAL_NEEDS_HUMAN)
321            }
322
323            // Everything else: no statically known repair shape. Agents
324            // should treat these as "diagnose only" until a repair is
325            // registered.
326            _ => None,
327        }
328    }
329}
330
331// Repair-id catalog. Each `RepairTemplate` carries a kebab-case
332// namespaced id (`<namespace>/<verb-noun>`), a one-line summary written
333// in the imperative voice, and a `RepairSafety` class.
334//
335// Conventions:
336//   - Namespaces stay short: `bindings/`, `imports/`, `errors/`, `casts/`,
337//     `format/`, `llm/`, `prompts/`, `match/`, `stdlib/`, `lint/`,
338//     `doc/`, `style/`, `types/`, `manual/`.
339//   - Summary starts with a verb ("Replace…", "Remove…", "Insert…").
340//   - Safety must be the most permissive class that is still always true
341//     for every site this template attaches to. When unsure, pick the
342//     stricter class — agents tighten too-loose policies later, never
343//     too-tight ones.
344
345const REPAIR_INSERT_EXPLICIT_CONVERSION: RepairTemplate = RepairTemplate {
346    id: "casts/insert-explicit-conversion",
347    summary: "Insert an explicit conversion or correct the operand type",
348    safety: RepairSafety::ScopeLocal,
349};
350
351const REPAIR_REWRITE_STRING_INTERPOLATION: RepairTemplate = RepairTemplate {
352    id: "style/string-interpolation",
353    summary: "Rewrite string concatenation as an interpolation literal",
354    safety: RepairSafety::BehaviorPreserving,
355};
356
357const REPAIR_CASTS_REMOVE_UNCHECKED: RepairTemplate = RepairTemplate {
358    id: "casts/remove-unchecked",
359    summary: "Remove the unchecked cast or guard it with a type test",
360    safety: RepairSafety::ScopeLocal,
361};
362
363const REPAIR_CASTS_REMOVE_REDUNDANT: RepairTemplate = RepairTemplate {
364    id: "casts/remove-redundant",
365    summary: "Remove the redundant cast",
366    safety: RepairSafety::BehaviorPreserving,
367};
368
369const REPAIR_BINDINGS_RENAME_TO_CLOSEST: RepairTemplate = RepairTemplate {
370    id: "bindings/rename-to-closest",
371    summary: "Rename to the closest in-scope identifier",
372    safety: RepairSafety::ScopeLocal,
373};
374
375const REPAIR_BINDINGS_MAKE_MUTABLE: RepairTemplate = RepairTemplate {
376    id: "bindings/make-mutable",
377    summary: "Declare the binding with `let` so it can be reassigned",
378    safety: RepairSafety::ScopeLocal,
379};
380
381const REPAIR_BINDINGS_MAKE_IMMUTABLE: RepairTemplate = RepairTemplate {
382    id: "bindings/make-immutable",
383    summary: "Declare the never-reassigned binding with `const` instead of `let`",
384    safety: RepairSafety::BehaviorPreserving,
385};
386
387const REPAIR_BINDINGS_RENAME_UNUSED: RepairTemplate = RepairTemplate {
388    id: "bindings/rename-unused",
389    summary: "Mark an unused binding without changing callable arity",
390    safety: RepairSafety::BehaviorPreserving,
391};
392
393const REPAIR_BINDINGS_REMOVE_UNUSED_PIPELINE_INPUT: RepairTemplate = RepairTemplate {
394    id: "bindings/remove-unused-pipeline-input",
395    summary: "Remove an explicitly unused test pipeline input",
396    safety: RepairSafety::SurfaceChanging,
397};
398
399const REPAIR_BINDINGS_NAME_CAPABILITY_PARAMETER: RepairTemplate = RepairTemplate {
400    id: "bindings/name-capability-parameter",
401    summary: "Rename the capability parameter and its references after the capability it carries",
402    safety: RepairSafety::SurfaceChanging,
403};
404
405const REPAIR_BINDINGS_RENAME_SHADOW: RepairTemplate = RepairTemplate {
406    id: "bindings/rename-shadow",
407    summary: "Rename the shadowing binding to a distinct name",
408    safety: RepairSafety::ScopeLocal,
409};
410
411const REPAIR_BINDINGS_THREAD_HARNESS: RepairTemplate = RepairTemplate {
412    id: "bindings/thread-harness",
413    summary: "Thread the existing `harness` binding through local helper calls and replace the ambient stdio builtin with `harness.stdio.*`",
414    safety: RepairSafety::ScopeLocal,
415};
416
417const REPAIR_BINDINGS_THREAD_HARNESS_NEEDS_PARAM: RepairTemplate = RepairTemplate {
418    id: "bindings/thread-harness-needs-param",
419    summary: "Add a `harness: Harness` parameter where the stdio capability handle is required and update local callers",
420    safety: RepairSafety::SurfaceChanging,
421};
422
423const REPAIR_BINDINGS_THREAD_HARNESS_METHOD: RepairTemplate = RepairTemplate {
424    id: "bindings/thread-harness-method",
425    summary: "Replace the ambient runtime builtin with its typed `harness.*` method and thread authority through local callers",
426    safety: RepairSafety::ScopeLocal,
427};
428
429const REPAIR_BINDINGS_THREAD_HARNESS_CLOCK: RepairTemplate = RepairTemplate {
430    id: "bindings/thread-harness-clock",
431    summary: "Replace the ambient clock builtin with the corresponding `harness.clock.*` method",
432    safety: RepairSafety::ScopeLocal,
433};
434
435const REPAIR_BINDINGS_THREAD_HARNESS_FS: RepairTemplate = RepairTemplate {
436    id: "bindings/thread-harness-fs",
437    summary: "Replace the ambient fs builtin with the corresponding `harness.fs.*` method",
438    safety: RepairSafety::ScopeLocal,
439};
440
441const REPAIR_BINDINGS_THREAD_HARNESS_ENV: RepairTemplate = RepairTemplate {
442    id: "bindings/thread-harness-env",
443    summary: "Replace the ambient env builtin with the corresponding `harness.env.*` method",
444    safety: RepairSafety::ScopeLocal,
445};
446
447const REPAIR_BINDINGS_THREAD_HARNESS_RANDOM: RepairTemplate = RepairTemplate {
448    id: "bindings/thread-harness-random",
449    summary: "Replace the ambient random builtin with the corresponding `harness.random.*` method",
450    safety: RepairSafety::ScopeLocal,
451};
452
453const REPAIR_BINDINGS_THREAD_HARNESS_NET: RepairTemplate = RepairTemplate {
454    id: "bindings/thread-harness-net",
455    summary: "Replace the ambient net builtin with the corresponding `harness.net.*` method",
456    safety: RepairSafety::ScopeLocal,
457};
458
459const REPAIR_DECLARATIONS_REMOVE_UNUSED: RepairTemplate = RepairTemplate {
460    id: "declarations/remove-unused",
461    summary: "Remove the unused declaration",
462    safety: RepairSafety::SurfaceChanging,
463};
464
465const REPAIR_IMPORTS_FIX_PATH: RepairTemplate = RepairTemplate {
466    id: "imports/fix-path",
467    summary: "Replace the import path with a resolvable target",
468    safety: RepairSafety::ScopeLocal,
469};
470
471const REPAIR_IMPORTS_REMOVE_UNUSED: RepairTemplate = RepairTemplate {
472    id: "imports/remove-unused",
473    summary: "Remove the unused import",
474    safety: RepairSafety::BehaviorPreserving,
475};
476
477const REPAIR_IMPORTS_REORDER: RepairTemplate = RepairTemplate {
478    id: "imports/reorder",
479    summary: "Reorder imports into canonical grouping",
480    safety: RepairSafety::FormatOnly,
481};
482
483const REPAIR_ERRORS_CHECK_OR_RESCUE: RepairTemplate = RepairTemplate {
484    id: "errors/check-or-rescue",
485    summary: "Check the result or wrap the call in a `rescue` block",
486    safety: RepairSafety::ScopeLocal,
487};
488
489const REPAIR_ERRORS_WRAP_IN_FN: RepairTemplate = RepairTemplate {
490    id: "errors/wrap-in-fn",
491    summary: "Move the construct inside a function body",
492    safety: RepairSafety::SurfaceChanging,
493};
494
495const REPAIR_MATCH_ADD_MISSING_ARMS: RepairTemplate = RepairTemplate {
496    id: "match/add-missing-arms",
497    summary: "Add arms covering the missing variants",
498    safety: RepairSafety::ScopeLocal,
499};
500
501const REPAIR_MATCH_REMOVE_DUPLICATE_ARM: RepairTemplate = RepairTemplate {
502    id: "match/remove-duplicate-arm",
503    summary: "Remove the duplicated match arm",
504    safety: RepairSafety::BehaviorPreserving,
505};
506
507const REPAIR_FORMAT_REFORMAT: RepairTemplate = RepairTemplate {
508    id: "format/reformat",
509    summary: "Apply canonical formatting",
510    safety: RepairSafety::FormatOnly,
511};
512
513const REPAIR_DOC_COMMENT_MIGRATE: RepairTemplate = RepairTemplate {
514    id: "doc/migrate-comment-style",
515    summary: "Migrate the legacy comment to canonical doc syntax",
516    safety: RepairSafety::FormatOnly,
517};
518
519const REPAIR_DOC_ADD_HARNDOC: RepairTemplate = RepairTemplate {
520    id: "doc/add-harndoc",
521    summary: "Add a `///` doc comment describing this declaration",
522    safety: RepairSafety::BehaviorPreserving,
523};
524
525const REPAIR_DOC_ADD_STDLIB_METADATA: RepairTemplate = RepairTemplate {
526    id: "doc/add-stdlib-metadata",
527    summary: "Add `@effects` and `@errors` fields to the stdlib function's doc block",
528    safety: RepairSafety::BehaviorPreserving,
529};
530
531const REPAIR_BLOCK_REMOVE_EMPTY: RepairTemplate = RepairTemplate {
532    id: "blocks/remove-empty",
533    summary: "Remove the empty block or fill in an explicit body",
534    safety: RepairSafety::ScopeLocal,
535};
536
537const REPAIR_CONTROL_FLOW_FLATTEN: RepairTemplate = RepairTemplate {
538    id: "control-flow/flatten",
539    summary: "Flatten the unnecessary control flow construct",
540    safety: RepairSafety::BehaviorPreserving,
541};
542
543const REPAIR_EXPRESSION_SIMPLIFY: RepairTemplate = RepairTemplate {
544    id: "expressions/simplify",
545    summary: "Simplify the expression to its canonical form",
546    safety: RepairSafety::BehaviorPreserving,
547};
548
549const REPAIR_RECORDS_PICK_FIELDS: RepairTemplate = RepairTemplate {
550    id: "records/pick-fields",
551    summary: "Replace the field-by-field record copy with `pick`",
552    safety: RepairSafety::BehaviorPreserving,
553};
554
555const REPAIR_CLONE_REMOVE_REDUNDANT: RepairTemplate = RepairTemplate {
556    id: "clones/remove-redundant",
557    summary: "Remove the redundant clone",
558    safety: RepairSafety::BehaviorPreserving,
559};
560
561const REPAIR_COLLECTION_PREFER_LAZY: RepairTemplate = RepairTemplate {
562    id: "collections/prefer-lazy",
563    summary: "Replace the eager collection step with a lazy variant",
564    safety: RepairSafety::ScopeLocal,
565};
566
567const REPAIR_DEAD_CODE_REMOVE: RepairTemplate = RepairTemplate {
568    id: "control-flow/remove-dead",
569    summary: "Remove the unreachable code",
570    safety: RepairSafety::BehaviorPreserving,
571};
572
573const REPAIR_METHODS_MIGRATE_REMOVED: RepairTemplate = RepairTemplate {
574    id: "methods/migrate-removed",
575    summary: "Rewrite the removed builtin method spelling to its canonical replacement",
576    safety: RepairSafety::BehaviorPreserving,
577};
578
579const REPAIR_STDLIB_MIGRATE_RENAMED: RepairTemplate = RepairTemplate {
580    id: "stdlib/migrate-renamed",
581    summary: "Rename the call to the renamed stdlib symbol",
582    safety: RepairSafety::ScopeLocal,
583};
584
585const REPAIR_BINDINGS_ATTENUATE_HARNESS: RepairTemplate = RepairTemplate {
586    id: "bindings/attenuate-harness",
587    summary: "Replace the root Harness parameter with the single capability the helper uses",
588    safety: RepairSafety::SurfaceChanging,
589};
590
591const REPAIR_LLM_MIGRATE_REMOVED_OPTION: RepairTemplate = RepairTemplate {
592    id: "llm/migrate-removed-option",
593    summary: "Replace the removed option with its supported equivalent",
594    safety: RepairSafety::ScopeLocal,
595};
596
597const REPAIR_LLM_ADD_SCHEMA: RepairTemplate = RepairTemplate {
598    id: "llm/add-schema",
599    summary: "Add a typed output schema to the LLM call",
600    safety: RepairSafety::SurfaceChanging,
601};
602
603const REPAIR_LLM_USE_CAPABILITY_FLAG: RepairTemplate = RepairTemplate {
604    id: "llm/use-capability-flag",
605    summary: "Branch on a capability flag instead of provider identity",
606    safety: RepairSafety::CapabilityChanging,
607};
608
609const REPAIR_PROMPTS_ESCAPE_INJECTION: RepairTemplate = RepairTemplate {
610    id: "prompts/escape-injection",
611    summary: "Pass the untrusted input through a structured placeholder",
612    safety: RepairSafety::ScopeLocal,
613};
614
615const REPAIR_PROMPTS_ADD_TOOL_TO_SURFACE: RepairTemplate = RepairTemplate {
616    id: "prompts/add-tool-to-surface",
617    summary: "Add the referenced tool to the declared tool surface",
618    safety: RepairSafety::SurfaceChanging,
619};
620
621const REPAIR_STYLE_RENAME_TO_CONVENTION: RepairTemplate = RepairTemplate {
622    id: "style/rename-to-convention",
623    summary: "Rename to match the casing convention for this kind",
624    safety: RepairSafety::SurfaceChanging,
625};
626
627const REPAIR_TYPES_ADD_SHAPE_ANNOTATION: RepairTemplate = RepairTemplate {
628    id: "types/add-shape-annotation",
629    summary: "Annotate the dict with a concrete shape type",
630    safety: RepairSafety::SurfaceChanging,
631};
632
633/// The repair for a parameter that never got a type.
634///
635/// Surface-changing rather than scope-local: the annotation lands on a
636/// signature, and once it is there every caller is checked against it. That is
637/// the point of the repair, and it is also why it is not auto-applied under a
638/// lower ceiling. `harn fix` infers the type from the body and the call sites,
639/// so the mechanical part of the migration is covered; `unknown` is what it
640/// writes when it can prove nothing, preserving the need to narrow dynamic
641/// values before use.
642const REPAIR_TYPES_ANNOTATE_PARAMETER: RepairTemplate = RepairTemplate {
643    id: "types/annotate-parameter",
644    summary: "Annotate the parameter with the inferred type, or `unknown` and narrow it at the dynamic boundary",
645    safety: RepairSafety::SurfaceChanging,
646};
647
648/// The repair for a boundary value read without validation.
649///
650/// Since harn#6252 a binding annotation *is* enforced, so annotating a
651/// `json_parse` result does validate it — the original reason for keeping
652/// `types/add-shape-annotation` away from this rule (that the annotation was
653/// erased, and so removed the diagnostic without removing the hazard) no longer
654/// holds.
655///
656/// This still offers schema validation rather than the annotation, for a
657/// different and narrower reason: a binding assertion reports the declared type
658/// and the value's kind, while `schema_expect` reports which field failed and
659/// why. For an untrusted payload that difference is the whole diagnosis. The
660/// annotation is now a correct fix; it is not the most informative one, and a
661/// one-step automated repair should offer the most informative.
662const REPAIR_TYPES_VALIDATE_BOUNDARY_VALUE: RepairTemplate = RepairTemplate {
663    id: "types/validate-boundary-value",
664    summary: "Validate the parsed value with schema_expect() or schema_check() before reading it",
665    safety: RepairSafety::ScopeLocal,
666};
667
668const REPAIR_MANUAL_REVIEW_CAPABILITY: RepairTemplate = RepairTemplate {
669    id: "manual/review-capability-binding",
670    summary: "Review the capability binding; the fix is not mechanical",
671    safety: RepairSafety::NeedsHuman,
672};
673
674const REPAIR_POLICY_NARROW_CHILD_EFFECTS: RepairTemplate = RepairTemplate {
675    id: "policy/narrow-child-effects",
676    summary: "Narrow the child agent's effects to a subset of the parent's, or widen the parent's declared effects",
677    safety: RepairSafety::SurfaceChanging,
678};
679
680const REPAIR_MANUAL_NEEDS_HUMAN: RepairTemplate = RepairTemplate {
681    id: "manual/needs-human",
682    summary: "Plan a human-led change; auto-apply is not safe here",
683    safety: RepairSafety::NeedsHuman,
684};
685
686/// Every repair template registered by [`Code::repair_template`], in source
687/// order for catalog generation and health checks.
688pub const REPAIR_REGISTRY: &[&RepairTemplate] = &[
689    &REPAIR_INSERT_EXPLICIT_CONVERSION,
690    &REPAIR_REWRITE_STRING_INTERPOLATION,
691    &REPAIR_CASTS_REMOVE_UNCHECKED,
692    &REPAIR_CASTS_REMOVE_REDUNDANT,
693    &REPAIR_BINDINGS_RENAME_TO_CLOSEST,
694    &REPAIR_BINDINGS_MAKE_MUTABLE,
695    &REPAIR_BINDINGS_MAKE_IMMUTABLE,
696    &REPAIR_BINDINGS_RENAME_UNUSED,
697    &REPAIR_BINDINGS_REMOVE_UNUSED_PIPELINE_INPUT,
698    &REPAIR_BINDINGS_NAME_CAPABILITY_PARAMETER,
699    &REPAIR_BINDINGS_RENAME_SHADOW,
700    &REPAIR_BINDINGS_THREAD_HARNESS,
701    &REPAIR_BINDINGS_THREAD_HARNESS_NEEDS_PARAM,
702    &REPAIR_BINDINGS_THREAD_HARNESS_METHOD,
703    &REPAIR_BINDINGS_THREAD_HARNESS_CLOCK,
704    &REPAIR_BINDINGS_THREAD_HARNESS_FS,
705    &REPAIR_BINDINGS_THREAD_HARNESS_ENV,
706    &REPAIR_BINDINGS_THREAD_HARNESS_RANDOM,
707    &REPAIR_BINDINGS_THREAD_HARNESS_NET,
708    &REPAIR_DECLARATIONS_REMOVE_UNUSED,
709    &REPAIR_IMPORTS_FIX_PATH,
710    &REPAIR_IMPORTS_REMOVE_UNUSED,
711    &REPAIR_IMPORTS_REORDER,
712    &REPAIR_ERRORS_CHECK_OR_RESCUE,
713    &REPAIR_ERRORS_WRAP_IN_FN,
714    &REPAIR_MATCH_ADD_MISSING_ARMS,
715    &REPAIR_MATCH_REMOVE_DUPLICATE_ARM,
716    &REPAIR_FORMAT_REFORMAT,
717    &REPAIR_DOC_COMMENT_MIGRATE,
718    &REPAIR_DOC_ADD_HARNDOC,
719    &REPAIR_DOC_ADD_STDLIB_METADATA,
720    &REPAIR_BLOCK_REMOVE_EMPTY,
721    &REPAIR_CONTROL_FLOW_FLATTEN,
722    &REPAIR_EXPRESSION_SIMPLIFY,
723    &REPAIR_RECORDS_PICK_FIELDS,
724    &REPAIR_CLONE_REMOVE_REDUNDANT,
725    &REPAIR_COLLECTION_PREFER_LAZY,
726    &REPAIR_DEAD_CODE_REMOVE,
727    &REPAIR_STDLIB_MIGRATE_RENAMED,
728    &REPAIR_METHODS_MIGRATE_REMOVED,
729    &REPAIR_BINDINGS_ATTENUATE_HARNESS,
730    &REPAIR_LLM_MIGRATE_REMOVED_OPTION,
731    &REPAIR_LLM_ADD_SCHEMA,
732    &REPAIR_LLM_USE_CAPABILITY_FLAG,
733    &REPAIR_PROMPTS_ESCAPE_INJECTION,
734    &REPAIR_PROMPTS_ADD_TOOL_TO_SURFACE,
735    &REPAIR_STYLE_RENAME_TO_CONVENTION,
736    &REPAIR_TYPES_ADD_SHAPE_ANNOTATION,
737    &REPAIR_TYPES_ANNOTATE_PARAMETER,
738    &REPAIR_TYPES_VALIDATE_BOUNDARY_VALUE,
739    &REPAIR_MANUAL_REVIEW_CAPABILITY,
740    &REPAIR_MANUAL_NEEDS_HUMAN,
741    &REPAIR_POLICY_NARROW_CHILD_EFFECTS,
742];
743
744#[cfg(test)]
745mod tests {
746    use super::super::Category;
747    use super::{Code, ParseRepairSafetyError, RepairSafety, REPAIR_REGISTRY};
748    use std::collections::HashSet;
749    use std::str::FromStr;
750
751    #[test]
752    fn parses_registered_code() {
753        assert_eq!(Code::from_str("HARN-TYP-014"), Ok(Code::TypeParameterArity));
754    }
755
756    #[test]
757    fn registry_has_unique_identifiers() {
758        let mut seen = HashSet::new();
759        for entry in Code::registry() {
760            assert!(
761                seen.insert(entry.identifier),
762                "duplicate diagnostic code {}",
763                entry.identifier
764            );
765            assert_eq!(entry.code.as_str(), entry.identifier);
766            assert_eq!(entry.code.category(), entry.category);
767            let expected_prefix = format!("HARN-{}-", entry.category);
768            assert!(entry.identifier.starts_with(&expected_prefix));
769            let suffix = entry.identifier.trim_start_matches(&expected_prefix);
770            assert_eq!(suffix.len(), 3);
771            assert!(suffix.chars().all(|ch| ch.is_ascii_digit()));
772            assert!(!entry.summary.is_empty());
773        }
774        assert!(Code::registry().len() >= 40);
775    }
776
777    #[test]
778    fn every_category_is_populated() {
779        for category in Category::ALL {
780            assert!(
781                Code::registry()
782                    .iter()
783                    .any(|entry| entry.category == *category),
784                "missing diagnostic code category {category}"
785            );
786        }
787    }
788
789    #[test]
790    fn every_code_has_non_empty_explanation() {
791        for entry in Code::registry() {
792            let body = entry.code.explanation();
793            assert!(
794                !body.trim().is_empty(),
795                "diagnostic code {} has an empty explanation file",
796                entry.identifier
797            );
798            assert!(
799                body.contains(entry.identifier),
800                "explanation for {} should reference its identifier",
801                entry.identifier
802            );
803        }
804    }
805
806    #[test]
807    fn related_codes_are_registered_and_non_self() {
808        for entry in Code::registry() {
809            for &other in entry.code.related() {
810                assert_ne!(
811                    other, entry.code,
812                    "{} lists itself as a related code",
813                    entry.identifier
814                );
815                assert!(
816                    Code::registry().iter().any(|e| e.code == other),
817                    "{} lists unregistered related code {}",
818                    entry.identifier,
819                    other
820                );
821            }
822        }
823    }
824
825    #[test]
826    fn repair_safety_string_roundtrip() {
827        for safety in RepairSafety::ALL {
828            let parsed = RepairSafety::from_str(safety.as_str()).unwrap();
829            assert_eq!(parsed, *safety);
830            assert_eq!(parsed.to_string(), safety.as_str());
831        }
832        assert_eq!(
833            RepairSafety::from_str("not-a-safety-class"),
834            Err(ParseRepairSafetyError)
835        );
836    }
837
838    #[test]
839    fn repair_safety_ordering_is_monotonic_low_to_high() {
840        // The is_at_most ceiling check relies on this ordering being
841        // least-to-most disruptive; a regression here flips the meaning
842        // of `harn fix --safety <ceiling>` for every caller.
843        let order = RepairSafety::ALL;
844        for window in order.windows(2) {
845            assert!(
846                window[0] < window[1],
847                "{:?} should be safer than {:?}",
848                window[0],
849                window[1]
850            );
851            assert!(window[0].is_at_most(window[1]));
852            assert!(!window[1].is_at_most(window[0]));
853        }
854    }
855
856    #[test]
857    fn only_behavior_preserving_repairs_are_machine_applicable() {
858        assert!(RepairSafety::FormatOnly.is_machine_applicable());
859        assert!(RepairSafety::BehaviorPreserving.is_machine_applicable());
860        for safety in [
861            RepairSafety::ScopeLocal,
862            RepairSafety::SurfaceChanging,
863            RepairSafety::CapabilityChanging,
864            RepairSafety::NeedsHuman,
865        ] {
866            assert!(!safety.is_machine_applicable(), "{safety}");
867        }
868    }
869
870    #[test]
871    fn repair_registry_has_at_least_twenty_entries() {
872        assert!(
873            REPAIR_REGISTRY.len() >= 20,
874            "expected ≥20 repair templates, found {}",
875            REPAIR_REGISTRY.len()
876        );
877    }
878
879    #[test]
880    fn repair_ids_are_kebab_case_namespaced_and_unique() {
881        let mut seen = HashSet::new();
882        for template in REPAIR_REGISTRY {
883            assert!(
884                seen.insert(template.id),
885                "duplicate repair id {}",
886                template.id
887            );
888            let (namespace, leaf) = template.id.split_once('/').unwrap_or_else(|| {
889                panic!(
890                    "repair id `{}` is missing `<namespace>/` prefix",
891                    template.id
892                )
893            });
894            assert!(
895                !namespace.is_empty() && !leaf.is_empty(),
896                "repair id `{}` has empty namespace or leaf",
897                template.id
898            );
899            for ch in template.id.chars() {
900                assert!(
901                    ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '/',
902                    "repair id `{}` has non-kebab character {ch:?}",
903                    template.id
904                );
905            }
906            assert!(
907                !template.summary.is_empty(),
908                "repair {} has empty summary",
909                template.id
910            );
911            // Summaries are imperative: start with a capital ASCII letter.
912            let first = template.summary.chars().next().unwrap();
913            assert!(
914                first.is_ascii_uppercase(),
915                "repair {} summary `{}` should start with a capital",
916                template.id,
917                template.summary
918            );
919        }
920    }
921
922    #[test]
923    fn manual_namespace_is_needs_human() {
924        for template in REPAIR_REGISTRY {
925            if let Some(("manual", _)) = template.id.split_once('/') {
926                assert_eq!(
927                    template.safety,
928                    RepairSafety::NeedsHuman,
929                    "manual/* repair {} must be NeedsHuman",
930                    template.id
931                );
932            }
933        }
934    }
935
936    #[test]
937    fn known_codes_carry_expected_safety_class() {
938        // Spot-check: the autonomy contract for several representative
939        // diagnostics. Lock in the safety class so cross-repo agents that
940        // dispatch on these don't silently drift when the catalog moves.
941        let expected: &[(Code, RepairSafety, &str)] = &[
942            (
943                Code::FormatterWouldReformat,
944                RepairSafety::FormatOnly,
945                "format/reformat",
946            ),
947            (
948                Code::ModuleImportUnused,
949                RepairSafety::BehaviorPreserving,
950                "imports/remove-unused",
951            ),
952            (
953                Code::ImmutableAssignment,
954                RepairSafety::ScopeLocal,
955                "bindings/make-mutable",
956            ),
957            (
958                Code::LintUnusedFunction,
959                RepairSafety::SurfaceChanging,
960                "declarations/remove-unused",
961            ),
962            (
963                Code::LlmProviderIdentityBranch,
964                RepairSafety::CapabilityChanging,
965                "llm/use-capability-flag",
966            ),
967            (
968                Code::PromptVariantExplosion,
969                RepairSafety::NeedsHuman,
970                "manual/needs-human",
971            ),
972            (
973                Code::NonExhaustiveMatch,
974                RepairSafety::ScopeLocal,
975                "match/add-missing-arms",
976            ),
977            (
978                Code::LintAmbientClockBuiltin,
979                RepairSafety::ScopeLocal,
980                "bindings/thread-harness-clock",
981            ),
982            (
983                Code::LintAmbientStdioBuiltin,
984                RepairSafety::ScopeLocal,
985                "bindings/thread-harness",
986            ),
987            (
988                Code::InvalidMainSignature,
989                RepairSafety::SurfaceChanging,
990                "bindings/thread-harness-needs-param",
991            ),
992        ];
993        for (code, safety, repair_id) in expected {
994            let template = code
995                .repair_template()
996                .unwrap_or_else(|| panic!("{code} should have a repair template"));
997            assert_eq!(template.safety, *safety, "{code} safety class drifted");
998            assert_eq!(template.id, *repair_id, "{code} repair id drifted");
999        }
1000    }
1001
1002    #[test]
1003    fn repair_templates_cover_at_least_twenty_codes() {
1004        let covered = Code::ALL
1005            .iter()
1006            .filter(|code| code.repair_template().is_some())
1007            .count();
1008        assert!(
1009            covered >= 20,
1010            "expected ≥20 codes with a repair template, found {covered}"
1011        );
1012    }
1013
1014    /// A rule about validating a boundary value cannot be repaired by an
1015    /// annotation.
1016    ///
1017    /// An annotation is erased before the value exists: annotating a
1018    /// `json_parse` result reads an int out of a field declared `string` and
1019    /// accepts an array as a record, with no diagnostic at either compile time
1020    /// or run time. Offering it as the one-step repair for these codes would
1021    /// auto-apply the escape the codes exist to close, and a repair is applied
1022    /// more readily than help text is read (harn#6234).
1023    ///
1024    /// `LintUnnormalizedOptions` is deliberately not in this list. It is about
1025    /// an inline options dict bypassing the typed option constructors, where no
1026    /// untrusted payload is involved and naming the shape is the whole fix.
1027    #[test]
1028    fn boundary_validation_codes_offer_the_most_informative_repair() {
1029        for code in [Code::BoundaryValueUnvalidated, Code::LintUntypedDictAccess] {
1030            let Some(template) = code.repair_template() else {
1031                continue;
1032            };
1033            assert_ne!(
1034                template.id, "types/add-shape-annotation",
1035                "{code:?} is a boundary-validation rule; an annotation validates the value \
1036                 (harn#6252) but reports only the declared type and the value's kind, so the \
1037                 offered one-step repair must be the one that names the failing field"
1038            );
1039        }
1040    }
1041
1042    #[test]
1043    fn every_registered_repair_is_referenced_by_some_code() {
1044        let referenced: HashSet<&'static str> = Code::ALL
1045            .iter()
1046            .filter_map(|code| code.repair_template())
1047            .map(|template| template.id)
1048            .collect();
1049        for template in REPAIR_REGISTRY {
1050            assert!(
1051                referenced.contains(template.id),
1052                "repair {} is in REPAIR_REGISTRY but no Code maps to it",
1053                template.id
1054            );
1055        }
1056    }
1057
1058    #[test]
1059    fn every_referenced_repair_template_is_in_registry() {
1060        let registered: HashSet<&'static str> =
1061            REPAIR_REGISTRY.iter().map(|template| template.id).collect();
1062        for code in Code::ALL {
1063            let Some(template) = code.repair_template() else {
1064                continue;
1065            };
1066            assert!(
1067                registered.contains(template.id),
1068                "repair {} (used by {}) is missing from REPAIR_REGISTRY",
1069                template.id,
1070                code
1071            );
1072        }
1073    }
1074}