Workspace - Pivot from "set of open panes" to a Finder-style miller column stack: TopBar / LeftSidebar / [section → entity → entity ...] / pinned text editor. openPanesStore is now an ordered Column[] with pushFrom / closeFrom / setStack; only one top-level section is rooted at a time. - New entity column panes: Term, Block, Association, Constraint, Requirement, Finding. Click-through navigation truncates deeper columns automatically. - LeftSidebar surfaces a pending-count chip per section (single-glance navigation cue) and spins its analyze ↻ via SVG Spinner whenever the LLM is working — including server-initiated runs caught by the runs poll, not just user-triggered ones. Analyze pipeline + persistence - Unified `concepts` pass (taxonomy + glossary in one LLM call) replaces the two-pass setup. Server still accepts ?section=taxonomy|glossary and normalizes them for back-compat. - model / requirements / detection (assumptions, risks, inconsistencies) + cross-layer validation rules (X1–X4: stale term link, unlinked formalism, undefined linked term, prose-only term). - Persistence: NarrativeDocument, ModelSnapshot, ChangelogEntry, TaxonomyTerm, RequirementEntry, Finding, AnalysisRun. Re-runs MERGE instead of replace: gentle update on existing items, suggested on new, deprecated on missing — same idiom for every artifact kind. User pins preserve "kept" decisions across re-analyses. - Migrations: pivot_text_first, add_requirement_linked_term, term_review_state, review_state_for_reqs_and_findings, add_term_definition_pinned. Concept ↔ ontology integration - linkedTermId on Block / Association / Constraint / Requirement. PromoteToolbar lets the user formalize a concept inline: + Block / + Association / + Constraint / + Requirement, all routed through applyOps so undo/redo and SSE work for free. - decideElement op for in-canvas keep/discard on review-pending model elements. User-authored definitions - TermColumn definition is click-to-edit. Save (Cmd-Enter / blur), Cancel (Esc), Reset to AI suggestion when pinned. - definitionPinned flag on TaxonomyTerm: future Analyze runs leave the user's text alone. setTermDefinition repo function + POST /api/projects/[id]/terms/[termId]/definition endpoint. - mergeTaxonomySuggestion + applyGlossaryDefinitions both pin-aware. UX/UI - StatusChip: single component for all state idioms (suggested, deprecated, accepted, dismissed, resolved, severity, validation code, confidence, warn). Replaces 5+ ad-hoc badge classes. - PaneControls (PaneViewTabs + PaneFilterChip): separates view-mode toggles from filter chips so toggling Pending no longer flips you off the current view. - PaneEmpty: unified empty-state with title + hint + action. - PaneDrawer: collapsible groups for Pending / Discarded review; cards group as Kept (top) → Pending (bottom drawer) → Discarded (Findings only, hidden when empty). Restore action recovers dismissed/resolved findings. - ConceptCard unifies Tree and A–Z views in Concepts; only Tree parents carry the chevron (no empty placeholder offset). - Type + spacing tokens (--text-xs..xl, --space-1..6, --lh-tight/ui/ prose, --radius-*) replace every ad-hoc value. - Buttons standardized to body sans 500 (was a mishmash of mono / display). - Card shells unified across Concepts / Requirements / Findings. Cleanup - Removed: LeftRail, FindingsPanel, IssuesPanel, SocratesDock, ProposalCard, SlashMenu, SlashExtension, slashSuggestion, CanvasHeader, TaxonomyPane, GlossaryPane, TermDetail (popover; now TermColumn). - Section ids in openPanesStore: dropped taxonomy/glossary, added concepts. localStorage migration runs on hydrate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
// 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: "term"; id: 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;
|
||
}
|