MVP M4: SysML metamodel + validator + dependency graph + UI surfacing
apps/web/lib/sysml — pure engine (no UI deps) - model.ts: canonical types ported from phase-0 (Block, Property, Association, Constraint, Requirement, SysMLModel) - validate.ts: 12 rules grouped Structural (S1–S5) / Semantic (M1–M5) / Traceability (T1–T2). Pure function returning element-anchored issues. Cycle detection via DFS with canonical-key dedup. T3 deferred to Phase 1.5 (depends on narrative chip references). - depgraph.ts: directed graph over Blocks / Properties / Constraints / Requirements with `dependentsOf()` and `neighborhood()` for impact analysis (substrate for M7). - fromFixture.ts: loose FixtureData → strict SysMLModel converter (interim until M5 unifies state). - breaks.ts: 8 named model corruptions (S1/S2/S3/M2/M3/M5/T1/T2) for ?break=... demonstrations. UI integration in EditorShell + LeftRail + DiagramCanvas + IssuesPanel - Floating IssuesPanel (bottom-right) with severity-grouped counts and collapse. Click an issue to focus its anchor across the rail and diagram. - LeftRail: severity dots on each block in the Model section and on each requirement entry. Tooltips show full issue text. - DiagramCanvas: red/blue/dotted ring around offending blocks via issuesByElement → BlockNode.data.issueSeverity. - ?break=S1-dangling,M2-cycle,M5-dup-property,... query param injects corruptions for verification of the "done when" criterion. - Suspense boundary added at the editor route for useSearchParams. Two pre-existing TipTap typing fixes (editor.storage cast through unknown).
This commit is contained in:
@@ -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 <EditorShell data={aristotleFixture} />;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<EditorShell data={aristotleFixture} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, ValidationIssue[]>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<ReactFlowProvider>
|
||||
@@ -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<Node<BlockNodeData>[]>(initial.nodes);
|
||||
const [edges, setEdges] = useState<Edge<SysmlEdgeData>[]>(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 => ({
|
||||
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) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface BlockNodeData extends Record<string, unknown> {
|
||||
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<string, string> = {
|
||||
@@ -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(" ");
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Parse `?break=S1-dangling,M2-cycle&...` into a list of named corruptions.
|
||||
const breaks = useMemo<BreakName[]>(() => {
|
||||
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<string, ValidationIssue[]>();
|
||||
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 (
|
||||
<div className={`shell shell-density-${density} shell-presence-${presence}`}>
|
||||
@@ -38,7 +71,12 @@ export function EditorShell({
|
||||
|
||||
<div className="shell-body">
|
||||
<SocratesDock thread={data.socratesThread} presence={presence} density={density} />
|
||||
<LeftRail data={data} focusBlockId={focusBlockId} setFocusBlockId={setFocusBlockId} />
|
||||
<LeftRail
|
||||
data={data}
|
||||
focusBlockId={focusBlockId}
|
||||
setFocusBlockId={setFocusBlockId}
|
||||
issuesByElement={issuesByElement}
|
||||
/>
|
||||
|
||||
<main className="canvases">
|
||||
<section className="canvas canvas-text">
|
||||
@@ -59,7 +97,9 @@ export function EditorShell({
|
||||
<section className="canvas canvas-diagram">
|
||||
<CanvasHeader
|
||||
title="Model"
|
||||
subtitle="SysML · 6 blocks · 6 associations · 1 constraint"
|
||||
subtitle={breaks.length > 0
|
||||
? `SysML · breaks active: ${breaks.join(", ")}`
|
||||
: "SysML · 6 blocks · 6 associations · 1 constraint"}
|
||||
right={
|
||||
<div className="canvas-actions">
|
||||
<span className="canvas-mode-pill">Fit</span>
|
||||
@@ -75,6 +115,7 @@ export function EditorShell({
|
||||
variant={diagramStyle}
|
||||
focusBlockId={focusBlockId}
|
||||
onSelect={setFocusBlockId}
|
||||
issuesByElement={issuesByElement}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -82,6 +123,22 @@ export function EditorShell({
|
||||
</div>
|
||||
|
||||
<StatusBar data={data} />
|
||||
|
||||
<IssuesPanel issues={issues} onSelectAnchor={id => setFocusBlockId(id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
73
apps/web/components/editor/IssuesPanel.tsx
Normal file
73
apps/web/components/editor/IssuesPanel.tsx
Normal file
@@ -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<ValidationIssue["severity"], string> = {
|
||||
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 (
|
||||
<div className={`issues-panel issues-panel-clean ${collapsed ? "issues-panel-collapsed" : ""}`}>
|
||||
<button className="issues-panel-head" onClick={() => setCollapsed(c => !c)} type="button">
|
||||
<span className="issues-panel-clean-glyph">✓</span>
|
||||
<span className="issues-panel-title">Model is clean</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`issues-panel ${collapsed ? "issues-panel-collapsed" : ""}`}>
|
||||
<button className="issues-panel-head" onClick={() => setCollapsed(c => !c)} type="button">
|
||||
<span className="issues-panel-counts">
|
||||
{grouped.error.length > 0 && <span className="issues-count issues-count-error">● {grouped.error.length}</span>}
|
||||
{grouped.warning.length > 0 && <span className="issues-count issues-count-warning">▲ {grouped.warning.length}</span>}
|
||||
{grouped.soft.length > 0 && <span className="issues-count issues-count-soft">· {grouped.soft.length}</span>}
|
||||
</span>
|
||||
<span className="issues-panel-title">{issues.length} validation issue{issues.length === 1 ? "" : "s"}</span>
|
||||
<span className="issues-panel-caret">{collapsed ? "▴" : "▾"}</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<ul className="issues-panel-list">
|
||||
{(["error", "warning", "soft"] as const).flatMap(sev =>
|
||||
grouped[sev].map((i, idx) => (
|
||||
<li
|
||||
key={`${sev}-${idx}`}
|
||||
className={`issues-item issues-item-${i.severity}`}
|
||||
onClick={() => onSelectAnchor?.(i.anchor.kind === "property" ? i.anchor.blockId : (i.anchor as { id: string }).id ?? "")}
|
||||
>
|
||||
<span className="issues-item-sev">{SEV_GLYPH[i.severity]}</span>
|
||||
<span className="issues-item-code">{i.code}</span>
|
||||
<span className="issues-item-msg">{i.message}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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:<id>`, `assoc:<id>`, …) → issues. */
|
||||
issuesByElement?: Map<string, ValidationIssue[]>;
|
||||
}
|
||||
|
||||
export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps) {
|
||||
function maxSeverityForKey(map: Map<string, ValidationIssue[]> | 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 <span className={`rail-issue-dot rail-issue-dot-${severity}`} title={title} />;
|
||||
}
|
||||
|
||||
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,7 +96,10 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps)
|
||||
</button>
|
||||
{open.model && (
|
||||
<ul className="rail-list rail-blocks">
|
||||
{data.blocks.map(b => (
|
||||
{data.blocks.map(b => {
|
||||
const sev = maxSeverityForKey(issuesByElement, b.id);
|
||||
const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n");
|
||||
return (
|
||||
<li
|
||||
key={b.id}
|
||||
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
|
||||
@@ -91,6 +110,7 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps)
|
||||
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : "▢"}
|
||||
</span>
|
||||
<span className="rail-block-label">{b.label}</span>
|
||||
<IssueDot severity={sev} title={tooltip} />
|
||||
<span
|
||||
className="rail-block-count"
|
||||
title={`${b.properties.length} ${b.properties.length === 1 ? "property" : "properties"}`}
|
||||
@@ -99,7 +119,8 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps)
|
||||
{b.properties.length}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
@@ -111,18 +132,22 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps)
|
||||
</button>
|
||||
{open.requirements && (
|
||||
<ul className="rail-list">
|
||||
<li className="rail-req">
|
||||
<span className="req-tag">REQ-001</span>
|
||||
<span className="req-status req-traced" />
|
||||
</li>
|
||||
<li className="rail-req">
|
||||
<span className="req-tag">REQ-002</span>
|
||||
<span className="req-status req-traced" />
|
||||
</li>
|
||||
<li className="rail-req">
|
||||
<span className="req-tag">REQ-003</span>
|
||||
<span className="req-status req-untraced" />
|
||||
{(["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 (
|
||||
<li key={reqId} className="rail-req">
|
||||
<span className="req-tag">{tag.toUpperCase()}</span>
|
||||
{sev ? (
|
||||
<IssueDot severity={sev} title={tooltip} />
|
||||
) : (
|
||||
<span className={`req-status ${isUntraced ? "req-untraced" : "req-traced"}`} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string, unknown>).markupStyle as MarkupStyle) ?? "color";
|
||||
const markupStyle =
|
||||
((editor.storage as unknown as Record<string, unknown>).markupStyle as MarkupStyle | undefined) ?? "color";
|
||||
|
||||
const { focusBlockId, setFocusBlockId } = useChipFocus();
|
||||
const isFocused = (refId !== null && refId === focusBlockId) || selected;
|
||||
|
||||
@@ -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<string, unknown>).markupStyle = markupStyle;
|
||||
(editor.storage as unknown as Record<string, unknown>).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]);
|
||||
|
||||
147
apps/web/lib/sysml/breaks.ts
Normal file
147
apps/web/lib/sysml/breaks.ts
Normal file
@@ -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<BreakName, { label: string; description: string; apply: (m: SysMLModel) => 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);
|
||||
}
|
||||
150
apps/web/lib/sysml/depgraph.ts
Normal file
150
apps/web/lib/sysml/depgraph.ts
Normal file
@@ -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<string, DepNode>;
|
||||
/** Adjacency list, indexed by `from`. */
|
||||
out: Map<string, DepEdge[]>;
|
||||
/** Reverse adjacency, indexed by `to`. Useful for "what depends on this?". */
|
||||
in: Map<string, DepEdge[]>;
|
||||
}
|
||||
|
||||
export function buildDepGraph(model: SysMLModel): DepGraph {
|
||||
const nodes = new Map<string, DepNode>();
|
||||
const out = new Map<string, DepEdge[]>();
|
||||
const inEdges = new Map<string, DepEdge[]>();
|
||||
|
||||
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<string> {
|
||||
const visited = new Set<string>();
|
||||
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<string> {
|
||||
const visited = new Set<string>([seedId]);
|
||||
let frontier = new Set<string>([seedId]);
|
||||
for (let i = 0; i < radius; i++) {
|
||||
const next = new Set<string>();
|
||||
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;
|
||||
}
|
||||
88
apps/web/lib/sysml/fromFixture.ts
Normal file
88
apps/web/lib/sysml/fromFixture.ts
Normal file
@@ -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;
|
||||
}
|
||||
75
apps/web/lib/sysml/model.ts
Normal file
75
apps/web/lib/sysml/model.ts
Normal file
@@ -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[];
|
||||
}
|
||||
324
apps/web/lib/sysml/validate.ts
Normal file
324
apps/web/lib/sysml/validate.ts
Normal file
@@ -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<string, Block>(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<string, number>();
|
||||
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<string>();
|
||||
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<T>(
|
||||
items: T[],
|
||||
keyOf: (item: T) => string,
|
||||
code: string,
|
||||
what: string,
|
||||
anchorOf: (item: T) => IssueAnchor
|
||||
): ValidationIssue[] {
|
||||
const counts = new Map<string, number>();
|
||||
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<T extends { id: string }>(
|
||||
nodes: T[],
|
||||
outgoing: (node: T) => string[],
|
||||
byId: Map<string, T>
|
||||
): string[][] {
|
||||
const result: string[][] = [];
|
||||
const seenCycles = new Set<string>();
|
||||
|
||||
function dfs(currentId: string, path: string[], onPath: Set<string>) {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user