Files
Socrates/apps/web/lib/sysml/validate.ts
dtoro b55425cc68 Pivot to text-first column-stack workspace + merge-with-review across AI artifacts
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>
2026-05-01 00:12:06 +02:00

326 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: "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;
}