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).
151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
// 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;
|
|
}
|