mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user