feat(panel): full Conventions editor — manage modules, rules, custom rules, waivers

The Conventions tab was read-mostly: it listed modules and toggled rule levels, but you could not add a module, a custom rule, or a waiver from the UI — you had to hand-edit YAML, which defeated the point of a managed standard. It is now a real editor: add / edit / remove module boundaries (with click-to-toggle forbidden kinds), add / edit / remove custom regex rules and their level, and add / edit / remove waivers (path + rule + reason). Saving commits the edited map back to the repo via PR, the same as before.
This commit is contained in:
Renn F
2026-06-22 14:31:41 +02:00
parent cee0b458a8
commit c54bca4c21
@@ -5,7 +5,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
conventionsApi, conventionsApi,
type ConventionsActionResult, type ConventionsActionResult,
type ConventionsCustomRule,
type ConventionsModule,
type ConventionsStandard, type ConventionsStandard,
type ConventionsWaiver,
type DefinitionKind,
type RuleLevel, type RuleLevel,
} from "@/lib/api/conventions"; } from "@/lib/api/conventions";
import { import {
@@ -17,9 +21,18 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { toast } from "sonner"; import { toast } from "sonner";
const FORBIDDABLE_KINDS: DefinitionKind[] = [
"model",
"route",
"helper",
"business_logic",
"component",
];
function actionToast(verb: string, result: ConventionsActionResult): void { function actionToast(verb: string, result: ConventionsActionResult): void {
if (result.created && result.pr_number != null) { if (result.created && result.pr_number != null) {
toast.success(`${verb}: opened PR #${result.pr_number} on ${result.branch}`); toast.success(`${verb}: opened PR #${result.pr_number} on ${result.branch}`);
@@ -87,11 +100,50 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
); );
} }
const edit = (next: Partial<ConventionsStandard>) =>
setDraft({ ...standard, ...next });
const setRuleLevel = (name: string, level: RuleLevel) => const setRuleLevel = (name: string, level: RuleLevel) =>
setDraft({ edit({ rules: { ...standard.rules, [name]: { name, level } } });
...standard,
rules: { ...standard.rules, [name]: { name, level } }, const updateModule = (index: number, next: Partial<ConventionsModule>) =>
edit({
modules: standard.modules.map((m, i) => (i === index ? { ...m, ...next } : m)),
}); });
const addModule = () =>
edit({ modules: [...standard.modules, { path: "", purpose: "", forbidden: [] }] });
const removeModule = (index: number) =>
edit({ modules: standard.modules.filter((_, i) => i !== index) });
const toggleForbidden = (index: number, kind: DefinitionKind) => {
const current = standard.modules[index].forbidden;
const forbidden = current.includes(kind)
? current.filter((k) => k !== kind)
: [...current, kind];
updateModule(index, { forbidden });
};
const updateCustom = (index: number, next: Partial<ConventionsCustomRule>) =>
edit({
custom: standard.custom.map((c, i) => (i === index ? { ...c, ...next } : c)),
});
const addCustom = () =>
edit({
custom: [
...standard.custom,
{ id: "", pattern: "", message: "", level: "warn", languages: [] },
],
});
const removeCustom = (index: number) =>
edit({ custom: standard.custom.filter((_, i) => i !== index) });
const updateWaiver = (index: number, next: Partial<ConventionsWaiver>) =>
edit({
waivers: standard.waivers.map((w, i) => (i === index ? { ...w, ...next } : w)),
});
const addWaiver = () =>
edit({ waivers: [...standard.waivers, { path: "", rule: "", reason: "" }] });
const removeWaiver = (index: number) =>
edit({ waivers: standard.waivers.filter((_, i) => i !== index) });
const status = data.health.status; const status = data.health.status;
// "degraded" is the only problem state: a committed file that won't parse. // "degraded" is the only problem state: a committed file that won't parse.
@@ -133,31 +185,57 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
<CardHeader> <CardHeader>
<CardTitle className="text-sm">Module boundaries</CardTitle> <CardTitle className="text-sm">Module boundaries</CardTitle>
<CardDescription> <CardDescription>
Which definition kinds are forbidden in each module. Which definition kinds are forbidden in each module. Click a kind to
toggle it.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-3">
{standard.modules.length === 0 && ( {standard.modules.length === 0 && (
<p className="text-sm text-muted-foreground">No modules mapped yet.</p> <p className="text-sm text-muted-foreground">No modules mapped yet.</p>
)} )}
{standard.modules.map((module) => ( {standard.modules.map((module, index) => (
<div <div
key={module.path} key={index}
className="flex items-start justify-between gap-4 text-sm" className="space-y-2 rounded-md border border-border p-3"
> >
<div className="min-w-0"> <div className="flex items-center gap-2">
<code>{module.path}</code>{" "} <Input
<span className="text-muted-foreground"> {module.purpose}</span> value={module.path}
placeholder="path/to/module"
onChange={(e) => updateModule(index, { path: e.target.value })}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeModule(index)}
>
Remove
</Button>
</div> </div>
<div className="flex flex-wrap justify-end gap-1"> <Input
{module.forbidden.map((kind) => ( value={module.purpose}
<Badge key={kind} variant="secondary"> placeholder="what this module is for"
onChange={(e) => updateModule(index, { purpose: e.target.value })}
/>
<div className="flex flex-wrap gap-1">
{FORBIDDABLE_KINDS.map((kind) => (
<Badge
key={kind}
variant={
module.forbidden.includes(kind) ? "destructive" : "outline"
}
className="cursor-pointer"
onClick={() => toggleForbidden(index, kind)}
>
no {kind} no {kind}
</Badge> </Badge>
))} ))}
</div> </div>
</div> </div>
))} ))}
<Button variant="outline" size="sm" onClick={addModule}>
Add module
</Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -191,6 +269,106 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Custom rules</CardTitle>
<CardDescription>
Project-specific regex rules a pattern, a message, and a level.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{standard.custom.map((rule, index) => (
<div
key={index}
className="space-y-2 rounded-md border border-border p-3"
>
<div className="flex items-center gap-2">
<Input
value={rule.id}
placeholder="rule-id"
onChange={(e) => updateCustom(index, { id: e.target.value })}
/>
<span className="w-10 text-right text-xs text-muted-foreground">
{rule.level}
</span>
<Switch
checked={rule.level === "block"}
onCheckedChange={(checked) =>
updateCustom(index, { level: checked ? "block" : "warn" })
}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeCustom(index)}
>
Remove
</Button>
</div>
<Input
value={rule.pattern}
placeholder="regex pattern"
onChange={(e) => updateCustom(index, { pattern: e.target.value })}
/>
<Input
value={rule.message}
placeholder="message shown when it matches"
onChange={(e) => updateCustom(index, { message: e.target.value })}
/>
</div>
))}
<Button variant="outline" size="sm" onClick={addCustom}>
Add custom rule
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Waivers</CardTitle>
<CardDescription>
Accountable escapes exempt a file from a rule with a reason
(reviewed in the PR, never a silent in-code suppression).
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{standard.waivers.map((waiver, index) => (
<div
key={index}
className="space-y-2 rounded-md border border-border p-3"
>
<div className="flex items-center gap-2">
<Input
value={waiver.path}
placeholder="path/to/file.py"
onChange={(e) => updateWaiver(index, { path: e.target.value })}
/>
<Input
value={waiver.rule}
placeholder="rule name"
onChange={(e) => updateWaiver(index, { rule: e.target.value })}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeWaiver(index)}
>
Remove
</Button>
</div>
<Input
value={waiver.reason}
placeholder="why this is waived"
onChange={(e) => updateWaiver(index, { reason: e.target.value })}
/>
</div>
))}
<Button variant="outline" size="sm" onClick={addWaiver}>
Add waiver
</Button>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm">Recent violations</CardTitle> <CardTitle className="text-sm">Recent violations</CardTitle>
@@ -215,7 +393,9 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
</code>{" "} </code>{" "}
<span className="text-muted-foreground">{finding.message}</span> <span className="text-muted-foreground">{finding.message}</span>
</div> </div>
<Badge variant={finding.level === "block" ? "destructive" : "secondary"}> <Badge
variant={finding.level === "block" ? "destructive" : "secondary"}
>
{finding.rule} {finding.rule}
</Badge> </Badge>
</div> </div>