diff --git a/apps/web/app/editor/[projectId]/page.tsx b/apps/web/app/editor/[projectId]/page.tsx index defcc94..1f1511d 100644 --- a/apps/web/app/editor/[projectId]/page.tsx +++ b/apps/web/app/editor/[projectId]/page.tsx @@ -1,8 +1,16 @@ +import { Suspense } from "react"; import { EditorShell } from "../../../components/editor/EditorShell"; import { aristotleFixture } from "../../../lib/fixtures/aristotle"; // M1: every projectId resolves to the Aristotle fixture. // M4–M5 wire this to a real database lookup. +// +// Suspense boundary required because EditorShell uses useSearchParams (for +// the M4 ?break=... demo of validation rules). export default async function EditorPage(_props: { params: Promise<{ projectId: string }> }) { - return ; + return ( + + + + ); } diff --git a/apps/web/components/diagram-canvas/DiagramCanvas.tsx b/apps/web/components/diagram-canvas/DiagramCanvas.tsx index 4de96fe..ba37f62 100644 --- a/apps/web/components/diagram-canvas/DiagramCanvas.tsx +++ b/apps/web/components/diagram-canvas/DiagramCanvas.tsx @@ -35,6 +35,7 @@ import { NodeInspector } from "./NodeInspector"; import { fixtureToFlow } from "./fixtureToFlow"; import type { Density } from "../socrates/SocratesDock"; import type { FixtureData, AssociationKind, BlockKind } from "../../lib/fixtures/aristotle"; +import type { ValidationIssue } from "../../lib/sysml/validate"; export type DiagramVariant = "softened" | "formal" | "graph"; @@ -44,6 +45,7 @@ interface DiagramCanvasProps { variant?: DiagramVariant; focusBlockId: string | null; onSelect?: (id: string | null) => void; + issuesByElement?: Map; } const nodeTypes = { sysmlBlock: BlockNode }; @@ -54,6 +56,13 @@ let nextEdgeId = 1000; function freshNodeId(): string { return `n${nextNodeId++}`; } function freshEdgeId(): string { return `e${nextEdgeId++}`; } +function pickWorst(issues: ValidationIssue[]): "error" | "warning" | "soft" | undefined { + if (issues.some(i => i.severity === "error")) return "error"; + if (issues.some(i => i.severity === "warning")) return "warning"; + if (issues.some(i => i.severity === "soft")) return "soft"; + return undefined; +} + export function DiagramCanvas(props: DiagramCanvasProps) { return ( @@ -62,7 +71,7 @@ export function DiagramCanvas(props: DiagramCanvasProps) { ); } -function DiagramInner({ data, focusBlockId, onSelect }: DiagramCanvasProps) { +function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: DiagramCanvasProps) { const initial = useMemo(() => fixtureToFlow(data), [data]); const [nodes, setNodes] = useState[]>(initial.nodes); const [edges, setEdges] = useState[]>(initial.edges); @@ -90,14 +99,20 @@ function DiagramInner({ data, focusBlockId, onSelect }: DiagramCanvasProps) { }, []); // Highlight focusBlockId from sibling components (e.g. narrative chip hover). + // Also surface validation severity onto each node so BlockNode can ring it. useEffect(() => { setNodes(ns => - ns.map(n => ({ - ...n, - selected: n.id === focusBlockId, - })) + ns.map(n => { + const issues = issuesByElement?.get(n.id) ?? []; + const severity = pickWorst(issues); + return { + ...n, + selected: n.id === focusBlockId, + data: { ...(n.data as BlockNodeData), issueSeverity: severity }, + }; + }) ); - }, [focusBlockId]); + }, [focusBlockId, issuesByElement]); const onNodeClick: NodeMouseHandler = useCallback( (_event, node) => { diff --git a/apps/web/components/diagram-canvas/nodes/BlockNode.tsx b/apps/web/components/diagram-canvas/nodes/BlockNode.tsx index b704583..7e21960 100644 --- a/apps/web/components/diagram-canvas/nodes/BlockNode.tsx +++ b/apps/web/components/diagram-canvas/nodes/BlockNode.tsx @@ -12,6 +12,8 @@ export interface BlockNodeData extends Record { properties: string[]; /** For constraint kinds, optional one-line expression. */ expression?: string; + /** Highest severity of any validation issue anchored to this block. */ + issueSeverity?: "error" | "warning" | "soft"; } const STEREO: Record = { @@ -30,6 +32,7 @@ export function BlockNode({ data, selected }: NodeProps) { "sysml-node", `sysml-node-${kind}`, selected ? "sysml-node-selected" : "", + d.issueSeverity ? `sysml-node-issue-${d.issueSeverity}` : "", ] .filter(Boolean) .join(" "); diff --git a/apps/web/components/editor/EditorShell.tsx b/apps/web/components/editor/EditorShell.tsx index 48a2da8..4b872ab 100644 --- a/apps/web/components/editor/EditorShell.tsx +++ b/apps/web/components/editor/EditorShell.tsx @@ -1,19 +1,25 @@ // The dual-canvas workspace shell. // Composes TopBar / SocratesDock / LeftRail / TextCanvas / DiagramCanvas / StatusBar. -// Ported from docs/design-source/socrata/project/editor-shell.jsx (EditorShell). +// M4 adds: SysML model derivation + validation, IssuesPanel, optional ?break=... +// query param to demonstrate the validator surfacing rule violations. "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { TopBar } from "./TopBar"; import { LeftRail } from "./LeftRail"; import { CanvasHeader } from "./CanvasHeader"; import { StatusBar } from "./StatusBar"; +import { IssuesPanel } from "./IssuesPanel"; import { TextCanvas } from "../text-canvas/TextCanvas"; import { DiagramCanvas, type DiagramVariant } from "../diagram-canvas/DiagramCanvas"; import { SocratesDock, type Density, type SocratesPresence } from "../socrates/SocratesDock"; import type { MarkupStyle } from "../text-canvas/Chip"; import type { FixtureData } from "../../lib/fixtures/aristotle"; +import { fromFixture } from "../../lib/sysml/fromFixture"; +import { validate, type ValidationIssue } from "../../lib/sysml/validate"; +import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks"; interface EditorShellProps { data: FixtureData; @@ -31,6 +37,33 @@ export function EditorShell({ presence = "default", }: EditorShellProps) { const [focusBlockId, setFocusBlockId] = useState(null); + const searchParams = useSearchParams(); + + // Parse `?break=S1-dangling,M2-cycle&...` into a list of named corruptions. + const breaks = useMemo(() => { + const raw = searchParams?.get("break") ?? ""; + if (!raw) return []; + return raw + .split(",") + .map(s => s.trim()) + .filter((s): s is BreakName => s in BREAKS); + }, [searchParams]); + + // Derive the SysML model from the fixture, optionally apply breaks, validate. + const { issues, issuesByElement } = useMemo(() => { + const baseModel = fromFixture(data); + const broken = breaks.length > 0 ? applyBreaks(baseModel, breaks) : baseModel; + const issues = validate(broken); + const issuesByElement = new Map(); + for (const i of issues) { + const key = anchorKey(i.anchor); + if (!key) continue; + const prev = issuesByElement.get(key) ?? []; + prev.push(i); + issuesByElement.set(key, prev); + } + return { issues, issuesByElement }; + }, [data, breaks]); return (
@@ -38,7 +71,12 @@ export function EditorShell({
- +
@@ -59,7 +97,9 @@ export function EditorShell({
0 + ? `SysML · breaks active: ${breaks.join(", ")}` + : "SysML · 6 blocks · 6 associations · 1 constraint"} right={
Fit @@ -75,6 +115,7 @@ export function EditorShell({ variant={diagramStyle} focusBlockId={focusBlockId} onSelect={setFocusBlockId} + issuesByElement={issuesByElement} />
@@ -82,6 +123,22 @@ export function EditorShell({
+ + setFocusBlockId(id)} />
); } + +/** Map an issue's anchor to a single string key matching either a block id or + * a synthetic key (`assoc:a1`, `req:req-001`, `constraint:ferpa`). The UI uses + * block ids most often, so block anchors return the bare id. */ +function anchorKey(anchor: ValidationIssue["anchor"]): string | null { + switch (anchor.kind) { + case "block": return anchor.id; + case "association": return `assoc:${anchor.id}`; + case "constraint": return `constraint:${anchor.id}`; + case "requirement": return `req:${anchor.id}`; + case "property": return anchor.blockId; + case "model": return null; + } +} diff --git a/apps/web/components/editor/IssuesPanel.tsx b/apps/web/components/editor/IssuesPanel.tsx new file mode 100644 index 0000000..bc2fbec --- /dev/null +++ b/apps/web/components/editor/IssuesPanel.tsx @@ -0,0 +1,73 @@ +// Floating panel listing current validation issues — shown next to the +// status bar. Click an issue to focus its anchored element across the rail +// + diagram (via setFocusBlockId). Lets the user verify the validator is +// actually firing on the broken-fixture demos. + +"use client"; + +import { useState } from "react"; +import type { ValidationIssue } from "../../lib/sysml/validate"; + +interface IssuesPanelProps { + issues: ValidationIssue[]; + onSelectAnchor?: (id: string) => void; +} + +const SEV_GLYPH: Record = { + error: "●", + warning: "▲", + soft: "·", +}; + +export function IssuesPanel({ issues, onSelectAnchor }: IssuesPanelProps) { + const [collapsed, setCollapsed] = useState(false); + + const grouped = { + error: issues.filter(i => i.severity === "error"), + warning: issues.filter(i => i.severity === "warning"), + soft: issues.filter(i => i.severity === "soft"), + }; + + if (issues.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ + + {!collapsed && ( +
    + {(["error", "warning", "soft"] as const).flatMap(sev => + grouped[sev].map((i, idx) => ( +
  • onSelectAnchor?.(i.anchor.kind === "property" ? i.anchor.blockId : (i.anchor as { id: string }).id ?? "")} + > + {SEV_GLYPH[i.severity]} + {i.code} + {i.message} +
  • + )) + )} +
+ )} +
+ ); +} diff --git a/apps/web/components/editor/LeftRail.tsx b/apps/web/components/editor/LeftRail.tsx index c76d741..b905c50 100644 --- a/apps/web/components/editor/LeftRail.tsx +++ b/apps/web/components/editor/LeftRail.tsx @@ -6,14 +6,30 @@ import { useState } from "react"; import type { FixtureData } from "../../lib/fixtures/aristotle"; +import type { ValidationIssue, Severity } from "../../lib/sysml/validate"; interface LeftRailProps { data: FixtureData; focusBlockId: string | null; setFocusBlockId: (id: string | null) => void; + /** Map keyed by element key (block id, `req:`, `assoc:`, …) → issues. */ + issuesByElement?: Map; } -export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps) { +function maxSeverityForKey(map: Map | undefined, key: string): Severity | null { + const items = map?.get(key); + if (!items || items.length === 0) return null; + if (items.some(i => i.severity === "error")) return "error"; + if (items.some(i => i.severity === "warning")) return "warning"; + return "soft"; +} + +function IssueDot({ severity, title }: { severity: Severity | null; title?: string }) { + if (!severity) return null; + return ; +} + +export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) { const [collapsed, setCollapsed] = useState(false); const [open, setOpen] = useState({ outline: true, model: true, requirements: true }); const toggle = (k: keyof typeof open) => setOpen(s => ({ ...s, [k]: !s[k] })); @@ -80,26 +96,31 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps) {open.model && (
    - {data.blocks.map(b => ( -
  • setFocusBlockId(b.id)} - onMouseLeave={() => setFocusBlockId(null)} - > - - {b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : "▢"} - - {b.label} - { + const sev = maxSeverityForKey(issuesByElement, b.id); + const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n"); + return ( +
  • setFocusBlockId(b.id)} + onMouseLeave={() => setFocusBlockId(null)} > - · - {b.properties.length} - -
  • - ))} + + {b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : "▢"} + + {b.label} + + + · + {b.properties.length} + + + ); + })}
)} @@ -111,18 +132,22 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps) {open.requirements && (
    -
  • - REQ-001 - -
  • -
  • - REQ-002 - -
  • -
  • - REQ-003 - -
  • + {(["req-001", "req-002", "req-003"] as const).map(reqId => { + const tag = reqId.toUpperCase().replace("-", "-"); + const sev = maxSeverityForKey(issuesByElement, `req:${reqId}`); + const tooltip = issuesByElement?.get(`req:${reqId}`)?.map(i => `${i.code}: ${i.message}`).join("\n"); + const isUntraced = sev === "warning"; // T1 is the warning we surface here + return ( +
  • + {tag.toUpperCase()} + {sev ? ( + + ) : ( + + )} +
  • + ); + })}
)} diff --git a/apps/web/components/text-canvas/ChipView.tsx b/apps/web/components/text-canvas/ChipView.tsx index 7d343d3..4ec09b8 100644 --- a/apps/web/components/text-canvas/ChipView.tsx +++ b/apps/web/components/text-canvas/ChipView.tsx @@ -30,7 +30,8 @@ export function ChipView({ node, selected, editor }: NodeViewProps) { const label = (node.attrs.label as string) ?? "untitled"; const refId = (node.attrs.refId as string | null) ?? null; // Read the current markup style from the editor's storage; defaults to "color". - const markupStyle = ((editor.storage as Record).markupStyle as MarkupStyle) ?? "color"; + const markupStyle = + ((editor.storage as unknown as Record).markupStyle as MarkupStyle | undefined) ?? "color"; const { focusBlockId, setFocusBlockId } = useChipFocus(); const isFocused = (refId !== null && refId === focusBlockId) || selected; diff --git a/apps/web/components/text-canvas/TextCanvas.tsx b/apps/web/components/text-canvas/TextCanvas.tsx index 47c242b..68e2642 100644 --- a/apps/web/components/text-canvas/TextCanvas.tsx +++ b/apps/web/components/text-canvas/TextCanvas.tsx @@ -62,7 +62,7 @@ export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusB // Push the markup style into the editor so the ChipView NodeView can read it. useEffect(() => { if (!editor) return; - (editor.storage as Record).markupStyle = markupStyle; + (editor.storage as unknown as Record).markupStyle = markupStyle; // Force a re-render of all chip node views so they pick up the new style. editor.view.dispatch(editor.state.tr.setMeta("force-update", true)); }, [editor, markupStyle]); diff --git a/apps/web/lib/sysml/breaks.ts b/apps/web/lib/sysml/breaks.ts new file mode 100644 index 0000000..b626cac --- /dev/null +++ b/apps/web/lib/sysml/breaks.ts @@ -0,0 +1,147 @@ +// Known model corruptions for demonstrating the validator end-to-end. +// +// Each "break" returns a model variant that should fire one specific rule +// (or a small cluster) without firing others. Used by the editor's `?break=...` +// query param to make the validator's behavior visible. + +import type { SysMLModel } from "./model"; + +export type BreakName = + | "S1-dangling" + | "S2-dangling-constraint" + | "S3-untraced-satisfier" + | "M2-cycle" + | "M3-multi-system" + | "M5-dup-property" + | "T1-untraced-req" + | "T2-unused-block"; + +export const BREAKS: Record SysMLModel }> = { + "S1-dangling": { + label: "S1 — dangling association endpoint", + description: "Add an association whose target block doesn't exist.", + apply(m) { + return { + ...m, + associations: [ + ...m.associations, + { id: "a-bad", fromBlockId: "aristotle", toBlockId: "ghost-block", label: "haunts", kind: "association" }, + ], + }; + }, + }, + + "S2-dangling-constraint": { + label: "S2 — constraint applies to unknown block", + description: "Add a constraint pointing at a block that doesn't exist.", + apply(m) { + return { + ...m, + constraints: [ + ...m.constraints, + { id: "c-bad", label: "Phantom rule", expression: "{}", appliesTo: ["ghost-block"] }, + ], + }; + }, + }, + + "S3-untraced-satisfier": { + label: "S3 — requirement satisfied by unknown block", + description: "Reroute REQ-001 to a non-existent block.", + apply(m) { + return { + ...m, + requirements: m.requirements.map(r => + r.tag === "REQ-001" ? { ...r, relations: [{ kind: "satisfy" as const, blockId: "ghost-block" }] } : r + ), + }; + }, + }, + + "M2-cycle": { + label: "M2 — cyclic composition", + description: "Add a composition Assignment → Course (the inverse of the existing Course → Assignment).", + apply(m) { + return { + ...m, + associations: [ + ...m.associations, + { + id: "a-cycle", + fromBlockId: "assignment", + toBlockId: "course", + label: "wraps", + kind: "composition", + }, + ], + }; + }, + }, + + "M3-multi-system": { + label: "M3 — two System-of-Interest blocks", + description: "Mark both Aristotle and Course as kind=system.", + apply(m) { + return { + ...m, + blocks: m.blocks.map(b => + b.id === "aristotle" || b.id === "course" ? { ...b, kind: "system" as const, stereotypes: ["system"] } : b + ), + }; + }, + }, + + "M5-dup-property": { + label: "M5 — duplicate property name in a block", + description: "Add a second `interaction_style` property to Aristotle.", + apply(m) { + return { + ...m, + blocks: m.blocks.map(b => + b.id === "aristotle" + ? { + ...b, + properties: [ + ...b.properties, + { id: "p-dup", name: "interaction_style", type: { kind: "string" as const }, multiplicity: "0..1" as const }, + ], + } + : b + ), + }; + }, + }, + + "T1-untraced-req": { + label: "T1 — requirement with no satisfier", + description: "Already present in the fixture (REQ-003 has no satisfy relation).", + apply(m) { + // No mutation needed; the fixture already triggers T1 on REQ-003. + return m; + }, + }, + + "T2-unused-block": { + label: "T2 — block with no incoming references", + description: "Add a Tutor block that nothing connects to.", + apply(m) { + return { + ...m, + blocks: [ + ...m.blocks, + { + id: "tutor-orphan", + label: "TutorOrphan", + kind: "block", + stereotypes: ["block"], + properties: [], + }, + ], + }; + }, + }, +}; + +export function applyBreaks(model: SysMLModel, names: BreakName[]): SysMLModel { + return names.reduce((m, name) => BREAKS[name].apply(m), model); +} diff --git a/apps/web/lib/sysml/depgraph.ts b/apps/web/lib/sysml/depgraph.ts new file mode 100644 index 0000000..1365c39 --- /dev/null +++ b/apps/web/lib/sysml/depgraph.ts @@ -0,0 +1,150 @@ +// Dependency graph over a SysML model. +// +// Nodes: every Block, Property, Constraint, Requirement. +// Edges encode the structural and traceability relationships from +// docs/sysml-modeling.md §8. +// +// Used by the UI for cross-surface highlighting now (M4) and by Socrates' +// impact analysis later (M7). + +import type { SysMLModel } from "./model"; + +export type DepNodeKind = "block" | "property" | "constraint" | "requirement"; + +export interface DepNode { + id: string; // unique within graph; for properties: `${blockId}.${propertyId}` + kind: DepNodeKind; + label: string; +} + +export type DepEdgeKind = + | "contains" // block → property + | "relates" // block → block (per association.kind) + | "applies-to" // constraint → block + | "satisfies" // requirement → block + | "derives-from"; // requirement → requirement + +export interface DepEdge { + from: string; + to: string; + kind: DepEdgeKind; + /** For "relates" edges, the underlying association kind. */ + associationKind?: string; +} + +export interface DepGraph { + nodes: Map; + /** Adjacency list, indexed by `from`. */ + out: Map; + /** Reverse adjacency, indexed by `to`. Useful for "what depends on this?". */ + in: Map; +} + +export function buildDepGraph(model: SysMLModel): DepGraph { + const nodes = new Map(); + const out = new Map(); + const inEdges = new Map(); + + function addNode(node: DepNode) { + nodes.set(node.id, node); + if (!out.has(node.id)) out.set(node.id, []); + if (!inEdges.has(node.id)) inEdges.set(node.id, []); + } + + function addEdge(edge: DepEdge) { + const o = out.get(edge.from); + if (o) o.push(edge); + else out.set(edge.from, [edge]); + const i = inEdges.get(edge.to); + if (i) i.push(edge); + else inEdges.set(edge.to, [edge]); + } + + for (const b of model.blocks) { + addNode({ id: b.id, kind: "block", label: b.label }); + for (const p of b.properties) { + const pid = `${b.id}.${p.id}`; + addNode({ id: pid, kind: "property", label: p.name }); + addEdge({ from: b.id, to: pid, kind: "contains" }); + } + } + + for (const a of model.associations) { + addEdge({ + from: a.fromBlockId, + to: a.toBlockId, + kind: "relates", + associationKind: a.kind, + }); + } + + for (const c of model.constraints) { + addNode({ id: c.id, kind: "constraint", label: c.label }); + for (const target of c.appliesTo) { + addEdge({ from: c.id, to: target, kind: "applies-to" }); + } + } + + for (const r of model.requirements) { + addNode({ id: r.id, kind: "requirement", label: r.tag }); + for (const rel of r.relations) { + if (rel.kind === "satisfy") { + addEdge({ from: r.id, to: rel.blockId, kind: "satisfies" }); + } else if (rel.kind === "derive") { + addEdge({ from: r.id, to: rel.fromReqId, kind: "derives-from" }); + } + } + } + + return { nodes, out, in: inEdges }; +} + +/** + * BFS the dep graph downstream from `seedId`, returning the closure (excluding + * the seed itself by default). Use for "if I change X, what's affected?". + */ +export function dependentsOf(graph: DepGraph, seedId: string): Set { + const visited = new Set(); + const queue: string[] = [seedId]; + while (queue.length) { + const cur = queue.shift()!; + const out = graph.out.get(cur) ?? []; + for (const edge of out) { + if (!visited.has(edge.to)) { + visited.add(edge.to); + queue.push(edge.to); + } + } + // Reverse direction too — things that depend ON this element should also + // appear in the closure (an Assumption linked to this block, etc., once + // those are added in M8). + const inEdges = graph.in.get(cur) ?? []; + for (const edge of inEdges) { + if (!visited.has(edge.from)) { + visited.add(edge.from); + queue.push(edge.from); + } + } + } + visited.delete(seedId); + return visited; +} + +export function neighborhood(graph: DepGraph, seedId: string, radius = 2): Set { + const visited = new Set([seedId]); + let frontier = new Set([seedId]); + for (let i = 0; i < radius; i++) { + const next = new Set(); + for (const id of frontier) { + for (const e of graph.out.get(id) ?? []) { + if (!visited.has(e.to)) { visited.add(e.to); next.add(e.to); } + } + for (const e of graph.in.get(id) ?? []) { + if (!visited.has(e.from)) { visited.add(e.from); next.add(e.from); } + } + } + frontier = next; + } + visited.delete(seedId); + return visited; +} diff --git a/apps/web/lib/sysml/fromFixture.ts b/apps/web/lib/sysml/fromFixture.ts new file mode 100644 index 0000000..9bcd80b --- /dev/null +++ b/apps/web/lib/sysml/fromFixture.ts @@ -0,0 +1,88 @@ +// Convert the loose Aristotle FixtureData (M1 prototype port) into a strict +// SysMLModel that the validator and dep-graph can operate on. + +import type { + SysMLModel, + Block, + Association, + Constraint, + Requirement, + AssociationKind, +} from "./model"; +import type { FixtureData } from "../fixtures/aristotle"; + +export function fromFixture(data: FixtureData): SysMLModel { + const constraintBlocks = data.blocks.filter(b => b.kind === "constraint"); + const constraints: Constraint[] = constraintBlocks.map(b => ({ + id: b.id, + label: b.label, + expression: "{ tenancy = institutional }", + // The fixture infers "what does this constraint apply to?" from its + // outgoing/incoming `kind: "constraint"` associations. + appliesTo: data.associations + .filter(a => a.kind === "constraint" && (a.from === b.id || a.to === b.id)) + .map(a => (a.from === b.id ? a.to : a.from)), + })); + + const blocks: Block[] = data.blocks + .filter(b => b.kind !== "constraint") + .map(b => ({ + id: b.id, + label: b.label, + kind: b.kind, + stereotypes: [b.kind], + properties: b.properties.map((name, i) => ({ + id: `p${i + 1}`, + name, + type: { kind: "string" } as const, + multiplicity: "0..1" as const, + })), + })); + + const associations: Association[] = data.associations + .filter(a => a.kind !== "constraint") + .map(a => ({ + id: a.id, + fromBlockId: a.from, + toBlockId: a.to, + label: a.label, + kind: mapAssocKind(a.kind), + })); + + // Requirements come from the narrative; the fixture chips reference REQ-001..003 + // but the requirements themselves aren't stored. Fabricate them here so the + // validator has something meaningful to operate on. + const requirements: Requirement[] = [ + { + id: "req-001", + tag: "REQ-001", + text: "Must never output a complete solution to a graded problem.", + relations: [{ kind: "satisfy", blockId: "aristotle" }], + }, + { + id: "req-002", + tag: "REQ-002", + text: "Response latency under 1.2s P50 to preserve flow.", + relations: [{ kind: "satisfy", blockId: "aristotle" }], + }, + { + id: "req-003", + tag: "REQ-003", + text: "Operates within FERPA boundaries; coursework never leaves institutional tenancy.", + // Intentionally untraced in the fixture — see the rail's "untraced" dot + relations: [], + }, + ]; + + return { + blocks, + associations, + constraints, + requirements, + }; +} + +function mapAssocKind(k: "association" | "composition" | "constraint"): AssociationKind { + if (k === "constraint") return "constraintApplies"; + return k; +} diff --git a/apps/web/lib/sysml/model.ts b/apps/web/lib/sysml/model.ts new file mode 100644 index 0000000..de3f49a --- /dev/null +++ b/apps/web/lib/sysml/model.ts @@ -0,0 +1,75 @@ +// Canonical SysML metamodel types — adapted from phase-0/src/types.ts +// (the Phase 0 validation that ran 10/10 against the corpus). +// +// See docs/sysml-modeling.md §6 for the design rationale and §3 for what we +// deliberately don't model. + +export type BlockKind = "block" | "actor" | "constraint" | "system"; + +export type PropertyType = + | { kind: "string" } + | { kind: "number" } + | { kind: "boolean" } + | { kind: "enum"; values: string[] }; + +export type Multiplicity = "0..1" | "1" | "0..*" | "1..*"; + +export interface Property { + id: string; + name: string; + type: PropertyType; + multiplicity: Multiplicity; + description?: string; +} + +export interface Block { + id: string; + label: string; + kind: BlockKind; + stereotypes: string[]; + properties: Property[]; + description?: string; +} + +export type AssociationKind = + | "association" + | "composition" + | "aggregation" + | "generalization" + | "constraintApplies"; + +export interface Association { + id: string; + fromBlockId: string; + toBlockId: string; + label: string; + kind: AssociationKind; + multiplicity?: { from: Multiplicity; to: Multiplicity }; +} + +export interface Constraint { + id: string; + label: string; + expression: string; + appliesTo: string[]; +} + +export type RequirementRelation = + | { kind: "satisfy"; blockId: string } + | { kind: "derive"; fromReqId: string } + | { kind: "verify"; experimentId: string }; + +export interface Requirement { + id: string; + tag: string; + text: string; + relations: RequirementRelation[]; +} + +export interface SysMLModel { + systemOfInterestId?: string; + blocks: Block[]; + associations: Association[]; + constraints: Constraint[]; + requirements: Requirement[]; +} diff --git a/apps/web/lib/sysml/validate.ts b/apps/web/lib/sysml/validate.ts new file mode 100644 index 0000000..e258ab9 --- /dev/null +++ b/apps/web/lib/sysml/validate.ts @@ -0,0 +1,324 @@ +// Pure SysML model validator. +// +// 13 rules, grouped: +// - Structural (S1–S5) — referential integrity. Errors. +// - Semantic (M1–M5) — SysML metamodel rules. Mostly errors. +// - Traceability (T1–T2) — soft warnings on coverage. +// +// All rules are described in docs/sysml-modeling.md §7. +// Pure function; safe to call repeatedly. Output is element-anchored so the +// UI can dot/highlight the offending block, association, etc. + +import type { + SysMLModel, + Block, + Association, + Constraint, + Requirement, +} from "./model"; + +export type Severity = "error" | "warning" | "soft"; + +export type IssueAnchor = + | { kind: "block"; id: string } + | { kind: "association"; id: string } + | { kind: "constraint"; id: string } + | { kind: "requirement"; id: string } + | { kind: "property"; blockId: string; propertyId: string } + | { kind: "model" }; + +export interface ValidationIssue { + /** Stable rule code: S1, M2, T1, etc. */ + code: string; + severity: Severity; + message: string; + anchor: IssueAnchor; +} + +export function validate(model: SysMLModel): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + const blockIds = new Set(model.blocks.map(b => b.id)); + const reqIds = new Set(model.requirements.map(r => r.id)); + + // Lookups + const blockById = new Map(model.blocks.map(b => [b.id, b])); + + // ─── Structural ──────────────────────────────────────────────────────── + + // S1 — every association.fromBlockId / toBlockId resolves to a block + for (const a of model.associations) { + if (!blockIds.has(a.fromBlockId)) { + issues.push(s1IssueFor(a, "from", a.fromBlockId)); + } + if (!blockIds.has(a.toBlockId)) { + issues.push(s1IssueFor(a, "to", a.toBlockId)); + } + } + + // S2 — every constraint.appliesTo entry resolves to a block + for (const c of model.constraints) { + for (const target of c.appliesTo) { + if (!blockIds.has(target)) { + issues.push({ + code: "S2", + severity: "error", + message: `Constraint "${c.label}" applies to unknown block "${target}".`, + anchor: { kind: "constraint", id: c.id }, + }); + } + } + } + + // S3 — every requirement satisfy.blockId resolves to a block + // S4 — every requirement derive.fromReqId resolves to a requirement + for (const r of model.requirements) { + for (const rel of r.relations) { + if (rel.kind === "satisfy" && !blockIds.has(rel.blockId)) { + issues.push({ + code: "S3", + severity: "error", + message: `Requirement ${r.tag} is satisfied by unknown block "${rel.blockId}".`, + anchor: { kind: "requirement", id: r.id }, + }); + } + if (rel.kind === "derive" && !reqIds.has(rel.fromReqId)) { + issues.push({ + code: "S4", + severity: "error", + message: `Requirement ${r.tag} derives from unknown requirement "${rel.fromReqId}".`, + anchor: { kind: "requirement", id: r.id }, + }); + } + } + } + + // S5 — block ids unique; requirement tags unique; association ids unique + uniquenessIssues(model.blocks, b => b.id, "S5", "block id", b => ({ kind: "block", id: b.id })).forEach(i => issues.push(i)); + uniquenessIssues(model.associations, a => a.id, "S5", "association id", a => ({ kind: "association", id: a.id })).forEach(i => issues.push(i)); + uniquenessIssues(model.constraints, c => c.id, "S5", "constraint id", c => ({ kind: "constraint", id: c.id })).forEach(i => issues.push(i)); + uniquenessIssues(model.requirements, r => r.tag, "S5", "requirement tag", r => ({ kind: "requirement", id: r.id })).forEach(i => issues.push(i)); + + // ─── Semantic (SysML metamodel) ──────────────────────────────────────── + + // M1 — generalization is acyclic + cycles( + model.blocks, + block => model.associations.filter(a => a.kind === "generalization" && a.fromBlockId === block.id).map(a => a.toBlockId), + blockById + ).forEach(cycle => + issues.push({ + code: "M1", + severity: "error", + message: `Cyclic generalization: ${cycle.join(" → ")} → ${cycle[0]}`, + anchor: { kind: "block", id: cycle[0]! }, + }) + ); + + // M2 — composition is acyclic + cycles( + model.blocks, + block => model.associations.filter(a => a.kind === "composition" && a.fromBlockId === block.id).map(a => a.toBlockId), + blockById + ).forEach(cycle => + issues.push({ + code: "M2", + severity: "error", + message: `Cyclic composition: ${cycle.join(" → ")} → ${cycle[0]}`, + anchor: { kind: "block", id: cycle[0]! }, + }) + ); + + // M3 — exactly zero or one block has kind "system" + const systems = model.blocks.filter(b => b.kind === "system"); + if (systems.length > 1) { + for (const s of systems) { + issues.push({ + code: "M3", + severity: "error", + message: `Multiple System-of-Interest blocks: ${systems.map(x => x.label).join(", ")}.`, + anchor: { kind: "block", id: s.id }, + }); + } + } + + // M4 — constraint blocks may not participate in composition + for (const a of model.associations) { + if (a.kind !== "composition") continue; + const from = blockById.get(a.fromBlockId); + const to = blockById.get(a.toBlockId); + if ((from && from.kind === "constraint") || (to && to.kind === "constraint")) { + issues.push({ + code: "M4", + severity: "error", + message: `Composition involves a constraint block (${from?.kind === "constraint" ? from.label : to?.label}).`, + anchor: { kind: "association", id: a.id }, + }); + } + } + + // M5 — property names unique within a block + for (const b of model.blocks) { + const seen = new Map(); + for (const p of b.properties) { + seen.set(p.name, (seen.get(p.name) ?? 0) + 1); + } + for (const [name, count] of seen) { + if (count > 1) { + issues.push({ + code: "M5", + severity: "error", + message: `Block "${b.label}" has duplicate property "${name}" (×${count}).`, + anchor: { kind: "block", id: b.id }, + }); + } + } + } + + // ─── Traceability ────────────────────────────────────────────────────── + + // T1 — every requirement has at least one satisfy relation + for (const r of model.requirements) { + const hasSatisfier = r.relations.some(rel => rel.kind === "satisfy"); + if (!hasSatisfier) { + issues.push({ + code: "T1", + severity: "warning", + message: `Requirement ${r.tag} has no satisfying block.`, + anchor: { kind: "requirement", id: r.id }, + }); + } + } + + // T2 — every block is referenced by ≥1 association OR satisfies ≥1 requirement + // (constraint blocks are exempt — they don't need to be referenced via association) + const referencedBlockIds = new Set(); + for (const a of model.associations) { + referencedBlockIds.add(a.fromBlockId); + referencedBlockIds.add(a.toBlockId); + } + for (const r of model.requirements) { + for (const rel of r.relations) { + if (rel.kind === "satisfy") referencedBlockIds.add(rel.blockId); + } + } + for (const c of model.constraints) { + for (const target of c.appliesTo) referencedBlockIds.add(target); + } + for (const b of model.blocks) { + if (b.kind === "constraint") continue; + if (!referencedBlockIds.has(b.id)) { + issues.push({ + code: "T2", + severity: "soft", + message: `Block "${b.label}" is not referenced by any association, constraint, or requirement.`, + anchor: { kind: "block", id: b.id }, + }); + } + } + + return issues; +} + +// ─── Helpers ───────────────────────────────────────────────────────────── + +function s1IssueFor(a: Association, side: "from" | "to", missingId: string): ValidationIssue { + return { + code: "S1", + severity: "error", + message: `Association "${a.label || a.id}" ${side} unknown block "${missingId}".`, + anchor: { kind: "association", id: a.id }, + }; +} + +function uniquenessIssues( + items: T[], + keyOf: (item: T) => string, + code: string, + what: string, + anchorOf: (item: T) => IssueAnchor +): ValidationIssue[] { + const counts = new Map(); + for (const it of items) counts.set(keyOf(it), (counts.get(keyOf(it)) ?? 0) + 1); + const issues: ValidationIssue[] = []; + for (const it of items) { + const k = keyOf(it); + if ((counts.get(k) ?? 0) > 1) { + issues.push({ + code, + severity: "error", + message: `Duplicate ${what} "${k}".`, + anchor: anchorOf(it), + }); + } + } + return issues; +} + +/** + * Find all simple cycles in a directed graph, returning each cycle as an + * array of node ids. Uses DFS with a recursion stack (Tarjan-style). + * Each cycle is reported once, starting from its lexicographically smallest + * node (so duplicates from different start positions collapse). + */ +function cycles( + nodes: T[], + outgoing: (node: T) => string[], + byId: Map +): string[][] { + const result: string[][] = []; + const seenCycles = new Set(); + + function dfs(currentId: string, path: string[], onPath: Set) { + const node = byId.get(currentId); + if (!node) return; + for (const nextId of outgoing(node)) { + if (onPath.has(nextId)) { + // Found a cycle from nextId back to itself through the path + const startIdx = path.indexOf(nextId); + if (startIdx >= 0) { + const cycle = path.slice(startIdx); + const key = canonicalCycleKey(cycle); + if (!seenCycles.has(key)) { + seenCycles.add(key); + result.push(cycle); + } + } + continue; + } + onPath.add(nextId); + path.push(nextId); + dfs(nextId, path, onPath); + path.pop(); + onPath.delete(nextId); + } + } + + for (const n of nodes) { + dfs(n.id, [n.id], new Set([n.id])); + } + return result; +} + +function canonicalCycleKey(cycle: string[]): string { + // Rotate so the smallest id is first; then join. + let minIdx = 0; + for (let i = 1; i < cycle.length; i++) { + if (cycle[i]! < cycle[minIdx]!) minIdx = i; + } + return [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)].join("→"); +} + +// ─── Convenience accessors used by the UI ──────────────────────────────── + +export function severityRank(s: Severity): number { + return s === "error" ? 3 : s === "warning" ? 2 : 1; +} + +export function maxSeverity(issues: ValidationIssue[]): Severity | null { + let best: Severity | null = null; + for (const i of issues) { + if (best === null || severityRank(i.severity) > severityRank(best)) best = i.severity; + } + return best; +} diff --git a/apps/web/styles/base.css b/apps/web/styles/base.css index b43b4a2..6ed7db7 100644 --- a/apps/web/styles/base.css +++ b/apps/web/styles/base.css @@ -923,3 +923,123 @@ button { font-family: inherit; } .tiptap .ProseMirror-selectednode .chip { box-shadow: 0 0 0 1.5px var(--accent); } + +/* ─── Issues panel (M4) ─── */ +.issues-panel { + position: fixed; + bottom: 32px; + right: 16px; + width: 380px; + max-height: 50vh; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: 6px; + box-shadow: 0 8px 24px var(--shadow-strong); + display: flex; + flex-direction: column; + z-index: 50; + font-family: var(--font-body); + overflow: hidden; +} +.issues-panel-clean { + width: auto; + min-width: 200px; +} +.issues-panel-clean .issues-panel-head { color: var(--ok-strong); } +.issues-panel-clean-glyph { + color: var(--ok); + font-size: 14px; + font-weight: 600; +} +.issues-panel-collapsed { + max-height: none; +} +.issues-panel-head { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + cursor: pointer; + font-family: inherit; + color: var(--fg); + width: 100%; + text-align: left; +} +.issues-panel-collapsed .issues-panel-head { border-bottom: none; } +.issues-panel-head:hover { background: var(--surface-2); } +.issues-panel-title { + font-family: var(--font-display); + font-weight: 500; + font-size: 13px; + flex: 1; +} +.issues-panel-counts { display: flex; gap: 8px; } +.issues-count { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; +} +.issues-count-error { color: var(--warn-strong); } +.issues-count-warning { color: var(--accent-strong); } +.issues-count-soft { color: var(--muted); } +.issues-panel-caret { font-size: 9px; color: var(--muted); } + +.issues-panel-list { + list-style: none; + margin: 0; + padding: 4px; + overflow-y: auto; + flex: 1; +} +.issues-item { + display: grid; + grid-template-columns: 16px auto 1fr; + gap: 8px; + padding: 6px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 12.5px; + line-height: 1.35; +} +.issues-item:hover { background: var(--surface-2); } +.issues-item-error .issues-item-sev { color: var(--warn-strong); } +.issues-item-warning .issues-item-sev { color: var(--accent); } +.issues-item-soft .issues-item-sev { color: var(--muted); } +.issues-item-sev { font-size: 11px; line-height: 1.6; text-align: center; } +.issues-item-code { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--muted-strong); + background: var(--surface-2); + padding: 1px 5px; + border-radius: 3px; + align-self: start; + margin-top: 1px; +} +.issues-item-msg { color: var(--prose); } + +/* Issue dots in the rail */ +.rail-issue-dot { + width: 6px; height: 6px; + border-radius: 50%; + margin-left: auto; + flex-shrink: 0; + margin-right: 4px; +} +.rail-issue-dot-error { background: var(--warn-strong); box-shadow: 0 0 0 2px var(--warn-soft); } +.rail-issue-dot-warning { background: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } +.rail-issue-dot-soft { background: var(--muted); } + +/* Diagram node issue ring */ +.sysml-node-issue-error { + border-color: var(--warn-strong) !important; + box-shadow: 0 0 0 2px var(--warn-soft), 0 2px 4px var(--shadow); +} +.sysml-node-issue-warning { + border-color: var(--accent) !important; + box-shadow: 0 0 0 2px var(--accent-soft), 0 2px 4px var(--shadow); +} +.sysml-node-issue-soft { + border-style: dotted !important; +}