Files
Socrates/apps/web/lib/sysml/validate.ts
dtoro 2052a280b1 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).
2026-04-28 23:22:24 +02:00

325 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure SysML model validator.
//
// 13 rules, grouped:
// - Structural (S1S5) — referential integrity. Errors.
// - Semantic (M1M5) — SysML metamodel rules. Mostly errors.
// - Traceability (T1T2) — 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;
}