mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Feature/architectural conventions standard (#243)
* feat(conventions): standard schema models + effective-map merge * feat(conventions): tree-sitter Python classifier + placement checks * feat(conventions): TS classifier, hygiene/custom checks, runner + CLI * feat(conventions): ROBOCO_CONVENTIONS_ENABLED flag + cache table + migration * feat(conventions): repo auto-scan + scaffold draft renderer * feat(conventions): ConventionsService (cache/baseline/ambient/scaffold/restore) * feat(conventions): auto-scaffold on project registration (flag-gated) * feat(conventions): TaskDescription.constraints + auto-baseline attach * feat(conventions): ambient architecture-map injection at spawn * test(conventions): subprocess CLI smoke for the agent-image entrypoint * feat(conventions): block i_am_done on block-level convention violations * feat(conventions): block pr_pass on unresolved convention violations * feat(conventions): surface convention findings into QA evidence * docs(prompts): convention awareness for PO/Intake/Dev/QA/PR-reviewer * feat(conventions): panel Conventions tab + flag toggle + parity * test(conventions): end-to-end block, fix, and waiver through the gate * refactor(conventions): extract pr_pass guards to keep pr_gate under the gate * style(conventions): format the baseline-constraints attach in task.create * test(conventions): type-annotate test helpers for the full mypy gate * build(conventions): ignore types-PyYAML in deptry (mypy-only type stub) * docs(conventions): document the standard in CLAUDE.md + PM prompt awareness * fix(conventions): baseline constraints are non-suppressible (dedup-append) * feat(conventions): scaffold on first workspace clone (threaded workspace) * feat(conventions): multi-project ambient map for PO/Intake (per-product) * feat(conventions): persist findings + violations-feed route (migration 044) * feat(conventions): panel violations feed in the Conventions tab * test(conventions): intake-spawn mock accepts the ambient layer kwarg * fix(docker): ollama-init best-effort pull, gate startup on cached models present A degraded/slow ollama registry made the model manifest re-check fail under set -e, so ollama-init exited 1 and blocked the orchestrator's service_completed_successfully gate — taking the whole stack down even though both models were already cached. Pulls are now best-effort; success is gated on the models being present, so a flaky registry can't down a cached deployment. * refactor(content): drop dead TaskDescription.with_baseline_constraints The structured baseline-merge helper had zero production callers. Project-task baseline constraints are attached by the wired string backstop (TaskService._attach_baseline_constraints), and a real task is free-form prose that cannot form a valid TaskDescription (requires a non-trivial objective + non-empty the_work), so the helper was unreachable from any live path — a leftover from the structured-merge -> string-append design pivot. Removing it leaves a single enforcement path. The constraints field itself stays: it is a member of the well-formed-spec schema (Objective / What This Builds / The Work / Notes / Constraints / Acceptance Criteria), rendered by render_markdown and unit-tested. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
conventionsApi,
|
||||
type ConventionsActionResult,
|
||||
type ConventionsStandard,
|
||||
type RuleLevel,
|
||||
} from "@/lib/api/conventions";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function actionToast(verb: string, result: ConventionsActionResult): void {
|
||||
if (result.created && result.pr_number != null) {
|
||||
toast.success(`${verb}: opened PR #${result.pr_number} on ${result.branch}`);
|
||||
} else {
|
||||
toast.success(
|
||||
`${verb}: prepared on ${result.branch} (no remote PR — workspace not cloned)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function ConventionsTab({ projectId }: { projectId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState<ConventionsStandard | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["conventions", projectId],
|
||||
queryFn: () => conventionsApi.get(projectId),
|
||||
});
|
||||
|
||||
const { data: findings } = useQuery({
|
||||
queryKey: ["conventions-findings", projectId],
|
||||
queryFn: () => conventionsApi.findings(projectId),
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["conventions", projectId] });
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (standard: ConventionsStandard) =>
|
||||
conventionsApi.update(projectId, standard),
|
||||
onSuccess: (result) => {
|
||||
actionToast("Saved", result);
|
||||
setDraft(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
`Save failed: ${error instanceof Error ? error.message : "unknown error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
const restore = useMutation({
|
||||
mutationFn: () => conventionsApi.restore(projectId),
|
||||
onSuccess: (result) => {
|
||||
actionToast("Restore", result);
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
`Restore failed: ${error instanceof Error ? error.message : "unknown error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">Loading conventions…</p>
|
||||
);
|
||||
}
|
||||
const standard = draft ?? data?.standard ?? null;
|
||||
if (!standard || !data) {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
No conventions available for this project.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const setRuleLevel = (name: string, level: RuleLevel) =>
|
||||
setDraft({
|
||||
...standard,
|
||||
rules: { ...standard.rules, [name]: { name, level } },
|
||||
});
|
||||
|
||||
const degraded = data.health.status !== "ok";
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
{degraded && (
|
||||
<Card className="border-amber-500/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">
|
||||
Conventions degraded — {data.health.status}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The committed file is missing or unparseable; the effective map
|
||||
fell back to the last-good cache plus auto-derived defaults.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Module boundaries</CardTitle>
|
||||
<CardDescription>
|
||||
Which definition kinds are forbidden in each module.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{standard.modules.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No modules mapped yet.</p>
|
||||
)}
|
||||
{standard.modules.map((module) => (
|
||||
<div
|
||||
key={module.path}
|
||||
className="flex items-start justify-between gap-4 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<code>{module.path}</code>{" "}
|
||||
<span className="text-muted-foreground">— {module.purpose}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{module.forbidden.map((kind) => (
|
||||
<Badge key={kind} variant="secondary">
|
||||
no {kind}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Rules</CardTitle>
|
||||
<CardDescription>
|
||||
Toggle a rule between warn (advisory) and block (refuses the gate).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.values(standard.rules).map((rule) => (
|
||||
<div
|
||||
key={rule.name}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<span className="text-sm">{rule.name.replace(/_/g, " ")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-10 text-right text-xs text-muted-foreground">
|
||||
{rule.level}
|
||||
</span>
|
||||
<Switch
|
||||
checked={rule.level === "block"}
|
||||
onCheckedChange={(checked) =>
|
||||
setRuleLevel(rule.name, checked ? "block" : "warn")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Recent violations</CardTitle>
|
||||
<CardDescription>
|
||||
The latest findings recorded across this project's tasks.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(!findings || findings.length === 0) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No violations recorded yet.
|
||||
</p>
|
||||
)}
|
||||
{(findings ?? []).map((finding, index) => (
|
||||
<div
|
||||
key={`${finding.file}:${finding.line}:${finding.rule}:${index}`}
|
||||
className="flex items-start justify-between gap-4 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<code>
|
||||
{finding.file}:{finding.line}
|
||||
</code>{" "}
|
||||
<span className="text-muted-foreground">{finding.message}</span>
|
||||
</div>
|
||||
<Badge variant={finding.level === "block" ? "destructive" : "secondary"}>
|
||||
{finding.rule}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restore.isPending}
|
||||
onClick={() => restore.mutate()}
|
||||
>
|
||||
Restore from last-good
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={draft == null || save.isPending}
|
||||
onClick={() => draft && save.mutate(draft)}
|
||||
>
|
||||
Save to repo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,13 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import { ConventionsTab } from "@/components/conventions/conventions-tab";
|
||||
import { Key, KeyRound } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Team, type ProjectUpdate, type Project } from "@/types";
|
||||
@@ -342,13 +349,24 @@ export function EditProjectDialog({ projectId, open, onOpenChange }: EditProject
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : project ? (
|
||||
// Key forces remount when project changes, resetting form state
|
||||
<EditProjectForm
|
||||
key={project.id}
|
||||
project={project}
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
<Tabs defaultValue="settings">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
<TabsTrigger value="conventions">Conventions</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="settings">
|
||||
{/* Key forces remount when project changes, resetting form state */}
|
||||
<EditProjectForm
|
||||
key={project.id}
|
||||
project={project}
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="conventions">
|
||||
<ConventionsTab projectId={projectId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground">Project not found</div>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
|
||||
provisioning_enabled: "Auto-provision projects from approved pitches.",
|
||||
toolchain_match_enabled:
|
||||
"Provision each agent workspace with the target project's Python (not RoboCo's) and block delivery gates when its test suite can't be executed.",
|
||||
conventions_enabled:
|
||||
"Enforce a per-project architectural standard (.roboco/conventions.yml): inject the map, attach baseline constraints, and block i_am_done / pr_pass on misplaced definitions or lint suppressions.",
|
||||
rag_auto_update_enabled: "Keep the knowledge base index refreshed automatically.",
|
||||
transcript_prune_enabled: "Run the background sweep that prunes old transcripts.",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import api from "./client";
|
||||
|
||||
// Mirrors roboco.foundation.policy.conventions.models — kept in sync by the
|
||||
// TS<->Python parity test (tests/unit/foundation/policy/conventions/test_ts_parity.py).
|
||||
export type RuleLevel = "warn" | "block";
|
||||
export type DefinitionKind =
|
||||
| "model"
|
||||
| "route"
|
||||
| "helper"
|
||||
| "business_logic"
|
||||
| "component"
|
||||
| "other";
|
||||
|
||||
export interface ConventionsModule {
|
||||
path: string;
|
||||
purpose: string;
|
||||
forbidden: DefinitionKind[];
|
||||
}
|
||||
|
||||
export interface ConventionsRule {
|
||||
name: string;
|
||||
level: RuleLevel;
|
||||
}
|
||||
|
||||
export interface ConventionsCustomRule {
|
||||
id: string;
|
||||
pattern: string;
|
||||
message: string;
|
||||
level: RuleLevel;
|
||||
languages: string[];
|
||||
}
|
||||
|
||||
export interface ConventionsWaiver {
|
||||
path: string;
|
||||
rule: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ConventionsStandard {
|
||||
version: number;
|
||||
languages: string[];
|
||||
modules: ConventionsModule[];
|
||||
rules: Record<string, ConventionsRule>;
|
||||
custom: ConventionsCustomRule[];
|
||||
waivers: ConventionsWaiver[];
|
||||
}
|
||||
|
||||
export interface ConventionsHealth {
|
||||
status: string;
|
||||
head_sha: string;
|
||||
last_ok_sha: string | null;
|
||||
}
|
||||
|
||||
export interface ConventionsResponse {
|
||||
standard: ConventionsStandard;
|
||||
health: ConventionsHealth;
|
||||
}
|
||||
|
||||
export interface ConventionsActionResult {
|
||||
pr_number: number | null;
|
||||
branch: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface ConventionFinding {
|
||||
file: string;
|
||||
line: number;
|
||||
rule: string;
|
||||
level: RuleLevel;
|
||||
kind: string | null;
|
||||
message: string;
|
||||
task_id: string | null;
|
||||
detected_at: string;
|
||||
}
|
||||
|
||||
export const conventionsApi = {
|
||||
// GET the project's effective map + health.
|
||||
get: async (projectId: string): Promise<ConventionsResponse> => {
|
||||
const { data } = await api.get<ConventionsResponse>(
|
||||
`/projects/${projectId}/conventions`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
// PUT an edited standard — opens a PR committing it back (PM+).
|
||||
update: async (
|
||||
projectId: string,
|
||||
standard: ConventionsStandard,
|
||||
): Promise<ConventionsActionResult> => {
|
||||
const { data } = await api.put<ConventionsActionResult>(
|
||||
`/projects/${projectId}/conventions`,
|
||||
standard,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
// POST restore — re-commits the file from the last-good map (PM+).
|
||||
restore: async (projectId: string): Promise<ConventionsActionResult> => {
|
||||
const { data } = await api.post<ConventionsActionResult>(
|
||||
`/projects/${projectId}/conventions/restore`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
// GET the recent violations feed for the project.
|
||||
findings: async (projectId: string): Promise<ConventionFinding[]> => {
|
||||
const { data } = await api.get<ConventionFinding[]>(
|
||||
`/projects/${projectId}/conventions/findings`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user