sad-mcp 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/usecase/index.d.ts +15 -2
- package/dist/usecase/index.js +218 -1
- package/package.json +1 -1
- package/skills/uml-use-case-diagram/SKILL.md +53 -397
- package/dist/usecase/validate.d.ts +0 -17
- package/dist/usecase/validate.js +0 -384
package/dist/usecase/index.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
1
|
+
export interface ValidationIssue {
|
|
2
|
+
path: string;
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export type ValidateResult = {
|
|
7
|
+
ok: true;
|
|
8
|
+
warnings: ValidationIssue[];
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
issues: ValidationIssue[];
|
|
12
|
+
warnings: ValidationIssue[];
|
|
13
|
+
};
|
|
14
|
+
export declare function formatIssues(issues: ValidationIssue[]): string;
|
|
15
|
+
export declare function parseAndValidate(jsonText: string): ValidateResult;
|
package/dist/usecase/index.js
CHANGED
|
@@ -1 +1,218 @@
|
|
|
1
|
-
|
|
1
|
+
// Validator for use-case diagramModel JSON (kind: 'use-case').
|
|
2
|
+
// Called by uml_usecase_validate_model MCP tool in tools.ts.
|
|
3
|
+
// Returns all errors and warnings at once so an LLM can self-correct in one pass.
|
|
4
|
+
export function formatIssues(issues) {
|
|
5
|
+
if (issues.length === 0)
|
|
6
|
+
return "(none)";
|
|
7
|
+
return issues.map(i => `- [${i.code}] ${i.path}: ${i.message}`).join("\n");
|
|
8
|
+
}
|
|
9
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
10
|
+
function hasHebrew(s) {
|
|
11
|
+
return /[\u0590-\u05FF\uFB1D-\uFB4F]/.test(s);
|
|
12
|
+
}
|
|
13
|
+
const SEND_ONLY_PREFIXES = [
|
|
14
|
+
/^שליחת /i, /^הודעה ל/i, /^הודעת /i, /^משלוח /i,
|
|
15
|
+
/^Send /i, /^Print /i, /^Notify /i,
|
|
16
|
+
];
|
|
17
|
+
const PHYSICAL_VERB_PREFIXES = [
|
|
18
|
+
/^ביצוע /i, /^הכנת /i, /^ספירת /i, /^מסירת /i,
|
|
19
|
+
/^תשלום ב/i, /^Perform /i, /^Execute /i,
|
|
20
|
+
];
|
|
21
|
+
const UNSAFE_WF = [
|
|
22
|
+
/<script\b/i, /\son\w+\s*=/i, /<iframe\b/i,
|
|
23
|
+
/src\s*=\s*["']https?:/i, /href\s*=\s*["']https?:/i,
|
|
24
|
+
/@import\b/i, /<style\b/i,
|
|
25
|
+
];
|
|
26
|
+
function wireframeSafe(html) {
|
|
27
|
+
return !UNSAFE_WF.some(rx => rx.test(html));
|
|
28
|
+
}
|
|
29
|
+
// ── Main validator ────────────────────────────────────────────────────────
|
|
30
|
+
export function parseAndValidate(jsonText) {
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = JSON.parse(jsonText);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37
|
+
return { ok: false, warnings: [], issues: [{ path: "(root)", code: "invalid-json", message: `JSON parse failed: ${msg}` }] };
|
|
38
|
+
}
|
|
39
|
+
const errors = [];
|
|
40
|
+
const warnings = [];
|
|
41
|
+
const err = (path, code, message) => errors.push({ path, code, message });
|
|
42
|
+
const warn = (path, code, message) => warnings.push({ path, code, message });
|
|
43
|
+
const m = raw;
|
|
44
|
+
if (!m || typeof m !== "object") {
|
|
45
|
+
err("(root)", "not-object", "model must be an object");
|
|
46
|
+
return { ok: false, issues: errors, warnings };
|
|
47
|
+
}
|
|
48
|
+
if (m["kind"] !== "use-case")
|
|
49
|
+
err("kind", "wrong-kind", `expected "use-case", got "${m["kind"]}"`);
|
|
50
|
+
const parts = Array.isArray(m["parts"]) ? m["parts"] : [];
|
|
51
|
+
if (!Array.isArray(m["parts"]) || parts.length === 0)
|
|
52
|
+
err("parts", "missing-parts", "parts array is required and must be non-empty");
|
|
53
|
+
// Narrative field checks at model level
|
|
54
|
+
for (const [key, val] of [["assumptions", m["assumptions"]], ["openQuestions", m["openQuestions"]]]) {
|
|
55
|
+
if (Array.isArray(val)) {
|
|
56
|
+
val.forEach((item, i) => {
|
|
57
|
+
if (typeof item === "string" && item.length > 0 && !hasHebrew(item)) {
|
|
58
|
+
err(`${key}[${i}]`, "narrative-non-hebrew", `${key}[${i}] contains no Hebrew characters`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const [pi, part] of parts.entries()) {
|
|
64
|
+
const pp = `parts[${pi}]`;
|
|
65
|
+
const elements = Array.isArray(part["elements"]) ? part["elements"] : [];
|
|
66
|
+
const relationships = Array.isArray(part["relationships"]) ? part["relationships"] : [];
|
|
67
|
+
const actorIds = new Set();
|
|
68
|
+
const ucIds = new Set();
|
|
69
|
+
// Index actors and UCs
|
|
70
|
+
for (const el of elements) {
|
|
71
|
+
if (el["kind"] === "actor" && typeof el["id"] === "string")
|
|
72
|
+
actorIds.add(el["id"]);
|
|
73
|
+
if (el["kind"] === "useCase" && typeof el["id"] === "string")
|
|
74
|
+
ucIds.add(el["id"]);
|
|
75
|
+
}
|
|
76
|
+
// Build relationship indexes
|
|
77
|
+
const includesByTarget = new Map(); // targetUcId → [baseUcId...]
|
|
78
|
+
const assocsByUc = new Map(); // ucId → [actorId...]
|
|
79
|
+
const includeSet = new Set(); // "from:to"
|
|
80
|
+
let totalIncludeExtend = 0;
|
|
81
|
+
for (const rel of relationships) {
|
|
82
|
+
if (rel["kind"] === "include") {
|
|
83
|
+
const from = String(rel["from"] ?? ""), to = String(rel["to"] ?? "");
|
|
84
|
+
const list = includesByTarget.get(to) ?? [];
|
|
85
|
+
list.push(from);
|
|
86
|
+
includesByTarget.set(to, list);
|
|
87
|
+
includeSet.add(`${from}:${to}`);
|
|
88
|
+
totalIncludeExtend++;
|
|
89
|
+
}
|
|
90
|
+
if (rel["kind"] === "extend")
|
|
91
|
+
totalIncludeExtend++;
|
|
92
|
+
if (rel["kind"] === "actorAssoc") {
|
|
93
|
+
const ucId = String(rel["ucId"] ?? ""), actorId = String(rel["actorId"] ?? "");
|
|
94
|
+
const list = assocsByUc.get(ucId) ?? [];
|
|
95
|
+
list.push(actorId);
|
|
96
|
+
assocsByUc.set(ucId, list);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (totalIncludeExtend > 2)
|
|
100
|
+
warn(`${pp}.relationships`, "too-many-include-extend", `${totalIncludeExtend} include+extend relationships found — more than 2 is almost always wrong`);
|
|
101
|
+
// Check «include» targets
|
|
102
|
+
for (const [targetId, bases] of includesByTarget) {
|
|
103
|
+
if (bases.length < 2) {
|
|
104
|
+
err(`${pp}.relationships[include→${targetId}]`, "single-incoming-include", `UC "${targetId}" has exactly one incoming «include» (from "${bases[0]}") — fold it into the parent instead`);
|
|
105
|
+
}
|
|
106
|
+
if (assocsByUc.has(targetId)) {
|
|
107
|
+
err(`${pp}.relationships[include→${targetId}]`, "include-target-has-assoc", `UC "${targetId}" is an «include» target but also has direct actor associations — it cannot be both`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Check UC elements
|
|
111
|
+
for (const el of elements) {
|
|
112
|
+
if (el["kind"] !== "useCase")
|
|
113
|
+
continue;
|
|
114
|
+
const id = String(el["id"] ?? "");
|
|
115
|
+
const name = String(el["name"] ?? "");
|
|
116
|
+
const ePath = `${pp}.elements[id=${id}]`;
|
|
117
|
+
// Send-only check
|
|
118
|
+
if (SEND_ONLY_PREFIXES.some(rx => rx.test(name))) {
|
|
119
|
+
err(ePath, "send-only-uc", `UC "${id}" name "${name}" appears to be send-only — fold the send step into the UC that decided to send`);
|
|
120
|
+
}
|
|
121
|
+
// Physical verb check
|
|
122
|
+
if (PHYSICAL_VERB_PREFIXES.some(rx => rx.test(name))) {
|
|
123
|
+
warn(ePath, "physical-verb", `UC "${id}" name "${name}" uses a physical verb — wrap with a system verb (רישום, הזנה, עדכון, סימון…)`);
|
|
124
|
+
}
|
|
125
|
+
// Primary actor
|
|
126
|
+
const primaryActor = el["primaryActor"];
|
|
127
|
+
if (!primaryActor) {
|
|
128
|
+
err(ePath, "missing-primary-actor", `UC "${id}" has no primaryActor`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
if (!actorIds.has(primaryActor)) {
|
|
132
|
+
err(ePath, "unknown-primary-actor", `UC "${id}" primaryActor "${primaryActor}" is not in actors list`);
|
|
133
|
+
}
|
|
134
|
+
// Check that an actorAssoc with role=initiator exists for this actor↔UC pair
|
|
135
|
+
const initiatorAssoc = relationships.find(r => r["kind"] === "actorAssoc" && r["ucId"] === id && r["actorId"] === primaryActor && r["role"] === "initiator");
|
|
136
|
+
if (!initiatorAssoc) {
|
|
137
|
+
err(ePath, "primary-not-initiator", `UC "${id}" primaryActor "${primaryActor}" has no actorAssoc with role="initiator"`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Narrative fields — must contain Hebrew
|
|
141
|
+
for (const [key, val] of [["description", el["description"]]]) {
|
|
142
|
+
if (typeof val === "string" && val.length > 0 && !hasHebrew(val)) {
|
|
143
|
+
err(`${ePath}.${key}`, "narrative-non-hebrew", `${key} contains no Hebrew characters`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const [key, arr] of [["preconditions", el["preconditions"]], ["postconditions", el["postconditions"]]]) {
|
|
147
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
148
|
+
warn(`${ePath}.${key}`, `missing-${key}`, `UC "${id}" has no ${key}`);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
arr.forEach((item, i) => {
|
|
152
|
+
if (typeof item === "string" && item.length > 0 && !hasHebrew(item)) {
|
|
153
|
+
err(`${ePath}.${key}[${i}]`, "narrative-non-hebrew", `${key}[${i}] contains no Hebrew characters`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// User stories
|
|
159
|
+
const userStories = Array.isArray(el["userStories"]) ? el["userStories"] : [];
|
|
160
|
+
if (userStories.length < 2) {
|
|
161
|
+
warn(`${ePath}.userStories`, "few-user-stories", `UC "${id}" has ${userStories.length} user stor${userStories.length === 1 ? "y" : "ies"} — cover every distinct stakeholder goal`);
|
|
162
|
+
}
|
|
163
|
+
// Scenarios / flow steps
|
|
164
|
+
const scenarios = Array.isArray(el["scenarios"]) ? el["scenarios"] : [];
|
|
165
|
+
if (scenarios.length === 0) {
|
|
166
|
+
err(`${ePath}.scenarios`, "missing-scenarios", `UC "${id}" has no scenarios`);
|
|
167
|
+
}
|
|
168
|
+
for (const [si, sce] of scenarios.entries()) {
|
|
169
|
+
const sPath = `${ePath}.scenarios[${si}]`;
|
|
170
|
+
const flow = Array.isArray(sce["flow"]) ? sce["flow"] : [];
|
|
171
|
+
if (flow.length === 0) {
|
|
172
|
+
err(`${sPath}.flow`, "empty-flow", `scenario "${sce["id"]}" in UC "${id}" has no flow steps`);
|
|
173
|
+
}
|
|
174
|
+
const flowLen = flow.length;
|
|
175
|
+
for (const [fi, step] of flow.entries()) {
|
|
176
|
+
const stepPath = `${sPath}.flow[${fi}]`;
|
|
177
|
+
if (step["kind"] === "actor" && typeof step["actor"] === "string" && !actorIds.has(step["actor"])) {
|
|
178
|
+
err(stepPath, "unknown-actor-ref", `flow step references unknown actor id "${step["actor"]}"`);
|
|
179
|
+
}
|
|
180
|
+
if (step["kind"] === "include") {
|
|
181
|
+
const targetUc = String(step["uc"] ?? "");
|
|
182
|
+
if (!ucIds.has(targetUc)) {
|
|
183
|
+
err(stepPath, "unknown-uc-ref", `«include» step references unknown UC id "${targetUc}"`);
|
|
184
|
+
}
|
|
185
|
+
else if (!includeSet.has(`${id}:${targetUc}`)) {
|
|
186
|
+
err(stepPath, "include-step-no-relationship", `«include» step references "${targetUc}" but no include relationship exists from "${id}" to "${targetUc}"`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (typeof step["text"] === "string" && step["text"].length > 0 && !hasHebrew(step["text"])) {
|
|
190
|
+
err(stepPath, "narrative-non-hebrew", "flow step text contains no Hebrew characters");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const extensions = Array.isArray(sce["extensions"]) ? sce["extensions"] : [];
|
|
194
|
+
for (const [ei, ext] of extensions.entries()) {
|
|
195
|
+
const extPath = `${sPath}.extensions[${ei}]`;
|
|
196
|
+
if (typeof ext["at"] === "number" && (ext["at"] < 1 || ext["at"] > flowLen)) {
|
|
197
|
+
err(extPath, "invalid-extension-at", `extension "at" value ${ext["at"]} is out of range (flow has ${flowLen} steps)`);
|
|
198
|
+
}
|
|
199
|
+
if (typeof ext["text"] === "string" && ext["text"].length > 0 && !hasHebrew(ext["text"])) {
|
|
200
|
+
err(extPath, "narrative-non-hebrew", "extension text contains no Hebrew characters");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Wireframe safety (only when present and non-empty)
|
|
205
|
+
if (typeof el["wireframe"] === "string" && el["wireframe"].length > 0) {
|
|
206
|
+
if (!wireframeSafe(el["wireframe"])) {
|
|
207
|
+
err(`${ePath}.wireframe`, "unsafe-wireframe", `wireframe for UC "${id}" contains forbidden patterns (script/on*/iframe/external URL/@import/style)`);
|
|
208
|
+
}
|
|
209
|
+
if (!hasHebrew(el["wireframe"])) {
|
|
210
|
+
err(`${ePath}.wireframe`, "wireframe-non-hebrew", `wireframe for UC "${id}" contains no Hebrew characters`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (errors.length > 0)
|
|
216
|
+
return { ok: false, issues: errors, warnings };
|
|
217
|
+
return { ok: true, warnings };
|
|
218
|
+
}
|
package/package.json
CHANGED
|
@@ -272,8 +272,14 @@ const diagramLayout = {
|
|
|
272
272
|
|
|
273
273
|
### fixLayout implementation
|
|
274
274
|
|
|
275
|
+
`fixLayout`, its constants, and all other runtime functions are provided by
|
|
276
|
+
`https://themathbible.com/sad-mcp/uc-diagram-common.js` — **do not inline them**.
|
|
277
|
+
The HTML `<script>` block must contain only the diagram-specific data objects
|
|
278
|
+
(`diagramLayouts`, `actorDescriptions`, `assocDescriptions`, `useCaseDocs`, `diagramModel`).
|
|
279
|
+
|
|
280
|
+
The function signature and constants for reference (implementation is external):
|
|
281
|
+
|
|
275
282
|
```javascript
|
|
276
|
-
// Named constants — tune these, do not hardcode magic numbers elsewhere.
|
|
277
283
|
const HEADER_H = 44; // boundary header height
|
|
278
284
|
const UC_PAD = 30; // min gap from boundary edge to nearest UC edge
|
|
279
285
|
const UC_GAP = 14; // min gap between UC ellipses in the same column
|
|
@@ -439,13 +445,6 @@ function fixLayout(layout) {
|
|
|
439
445
|
</div>
|
|
440
446
|
```
|
|
441
447
|
|
|
442
|
-
```css
|
|
443
|
-
.fix-btn { padding: 8px 20px; border: 1px solid #3b82f6; background: #eff6ff;
|
|
444
|
-
color: #1e40af; font-size: 12px; font-weight: 600; cursor: pointer;
|
|
445
|
-
border-radius: 6px; font-family: 'Noto Sans Hebrew', sans-serif; }
|
|
446
|
-
.fix-btn:hover { background: #dbeafe; }
|
|
447
|
-
```
|
|
448
|
-
|
|
449
448
|
---
|
|
450
449
|
|
|
451
450
|
## 7. Splitting Into Multiple Diagrams
|
|
@@ -495,7 +494,11 @@ function showDesc(id, type, event) {
|
|
|
495
494
|
}
|
|
496
495
|
```
|
|
497
496
|
|
|
498
|
-
|
|
497
|
+
All popup and modal functions (`showDesc`, `showPopup`, `hideDesc`, `openUcModal`,
|
|
498
|
+
`closeUcModal`, `pulseActor`, `wireframeSafe`) are provided by
|
|
499
|
+
`uc-diagram-common.js` — **do not inline them**.
|
|
500
|
+
|
|
501
|
+
### 8b. Data objects for popups (inline in the HTML `<script>` block)
|
|
499
502
|
|
|
500
503
|
```javascript
|
|
501
504
|
const actorDescriptions = {
|
|
@@ -503,48 +506,16 @@ const actorDescriptions = {
|
|
|
503
506
|
// ... one entry per actor
|
|
504
507
|
};
|
|
505
508
|
const assocDescriptions = {
|
|
506
|
-
// keyed by connection id; name = actor-name ↔ UC-name
|
|
509
|
+
// keyed by connection id; name = actor-name ↔ UC-name
|
|
507
510
|
'a1': { name: 'לקוח ↔ פתיחת הזמנה', desc: 'הלקוח יוזם את פתיחת ההזמנה. תפקיד: Initiator.' },
|
|
508
511
|
// ... one entry per association line you want described
|
|
509
512
|
};
|
|
510
|
-
|
|
511
|
-
function showPopup(id, type, event) {
|
|
512
|
-
hideDesc();
|
|
513
|
-
const data = type === 'actor' ? actorDescriptions[id]
|
|
514
|
-
: type === 'assoc' ? assocDescriptions[id]
|
|
515
|
-
: null;
|
|
516
|
-
if (!data) return;
|
|
517
|
-
const panel = document.createElement('div');
|
|
518
|
-
panel.id = 'desc-panel';
|
|
519
|
-
panel.innerHTML = `
|
|
520
|
-
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
|
521
|
-
<strong style="font-size:12px;color:#1e40af;direction:rtl;">${data.name}</strong>
|
|
522
|
-
<span onclick="hideDesc()" style="cursor:pointer;color:#94a3b8;font-size:18px;line-height:1;margin-right:6px;">×</span>
|
|
523
|
-
</div>
|
|
524
|
-
<div style="font-size:11px;color:#334155;line-height:1.6;direction:rtl;">${data.desc}</div>`;
|
|
525
|
-
Object.assign(panel.style, {
|
|
526
|
-
position:'fixed', background:'#fff', border:'1.5px solid #2980b9',
|
|
527
|
-
borderRadius:'8px', padding:'12px 16px', maxWidth:'300px',
|
|
528
|
-
boxShadow:'0 4px 14px rgba(0,0,0,.15)', zIndex:'1000',
|
|
529
|
-
left: event.clientX + 'px', top: event.clientY + 'px'
|
|
530
|
-
});
|
|
531
|
-
document.body.appendChild(panel);
|
|
532
|
-
const r = panel.getBoundingClientRect();
|
|
533
|
-
if (r.right > window.innerWidth) panel.style.left = (window.innerWidth - r.width - 10) + 'px';
|
|
534
|
-
if (r.bottom > window.innerHeight) panel.style.top = (event.clientY - r.height - 10) + 'px';
|
|
535
|
-
event.stopPropagation();
|
|
536
|
-
}
|
|
537
|
-
function hideDesc() { document.getElementById('desc-panel')?.remove(); }
|
|
538
|
-
document.addEventListener('click', e => {
|
|
539
|
-
if (!e.target.closest('[data-uc-id]') &&
|
|
540
|
-
!e.target.closest('[data-actor-id]') &&
|
|
541
|
-
!e.target.closest('[data-conn-id]')) hideDesc();
|
|
542
|
-
});
|
|
543
513
|
```
|
|
544
514
|
|
|
545
|
-
### 8a.
|
|
515
|
+
### 8a. useCaseDocs data object (inline in the HTML `<script>` block)
|
|
546
516
|
|
|
547
|
-
|
|
517
|
+
`openUcModal(id)` reads `useCaseDocs[id]` and renders a full-screen modal with
|
|
518
|
+
the complete UC spec. Populate one entry per UC:
|
|
548
519
|
|
|
549
520
|
```javascript
|
|
550
521
|
const useCaseDocs = {
|
|
@@ -559,10 +530,10 @@ const useCaseDocs = {
|
|
|
559
530
|
scenarios: [
|
|
560
531
|
{ id: 'sc-happy', name: 'מסלול מרכזי',
|
|
561
532
|
flow: [
|
|
562
|
-
{ kind: 'actor', actor: 'customer',
|
|
563
|
-
{ kind: 'system',
|
|
564
|
-
{ kind: 'include', uc:
|
|
565
|
-
{ kind: 'actor', actor: 'customer',
|
|
533
|
+
{ kind: 'actor', actor: 'customer', text: 'בוחר פריט מהקטלוג' },
|
|
534
|
+
{ kind: 'system', text: 'מוסיף את הפריט לעגלה' },
|
|
535
|
+
{ kind: 'include', uc: 'uc-authenticate', text: '' },
|
|
536
|
+
{ kind: 'actor', actor: 'customer', text: 'מאשר את ההזמנה' }
|
|
566
537
|
],
|
|
567
538
|
extensions: [
|
|
568
539
|
{ at: 1, text: 'אם הפריט אזל במלאי — מציג הודעה וחוזר לשלב 1.' }
|
|
@@ -576,130 +547,6 @@ const useCaseDocs = {
|
|
|
576
547
|
}
|
|
577
548
|
// ... one entry per UC
|
|
578
549
|
};
|
|
579
|
-
|
|
580
|
-
function openUcModal(id) {
|
|
581
|
-
const doc = useCaseDocs[id];
|
|
582
|
-
if (!doc) return;
|
|
583
|
-
const actorName = aid => (actorDescriptions[aid]?.name) || aid;
|
|
584
|
-
const ucName = uid => (useCaseDocs[uid]?.name) || uid;
|
|
585
|
-
const modal = document.getElementById('uc-modal');
|
|
586
|
-
modal.querySelector('.uc-id').textContent = doc.id;
|
|
587
|
-
modal.querySelector('.uc-name').textContent = doc.name;
|
|
588
|
-
const paLink = modal.querySelector('.uc-primary-link');
|
|
589
|
-
paLink.textContent = actorName(doc.primaryActor);
|
|
590
|
-
paLink.dataset.actorId = doc.primaryActor;
|
|
591
|
-
paLink.onclick = () => { closeUcModal(); pulseActor(doc.primaryActor); };
|
|
592
|
-
|
|
593
|
-
modal.querySelector('.uc-description').textContent = doc.description || '';
|
|
594
|
-
|
|
595
|
-
// Scenarios
|
|
596
|
-
const sc = modal.querySelector('.uc-scenarios');
|
|
597
|
-
sc.innerHTML = '';
|
|
598
|
-
(doc.scenarios || []).forEach(sce => {
|
|
599
|
-
const h = document.createElement('h3'); h.textContent = sce.name || sce.id; sc.appendChild(h);
|
|
600
|
-
const ol = document.createElement('ol');
|
|
601
|
-
(sce.flow || []).forEach((step, i) => {
|
|
602
|
-
const li = document.createElement('li');
|
|
603
|
-
if (step.kind === 'actor') {
|
|
604
|
-
const strong = document.createElement('strong');
|
|
605
|
-
strong.className = 'step-actor';
|
|
606
|
-
strong.dataset.actorId = step.actor;
|
|
607
|
-
strong.textContent = actorName(step.actor);
|
|
608
|
-
strong.onclick = () => { closeUcModal(); pulseActor(step.actor); };
|
|
609
|
-
li.appendChild(strong);
|
|
610
|
-
li.appendChild(document.createTextNode(': ' + (step.text || '')));
|
|
611
|
-
} else if (step.kind === 'system') {
|
|
612
|
-
const em = document.createElement('em');
|
|
613
|
-
em.className = 'step-system'; em.textContent = 'המערכת';
|
|
614
|
-
li.appendChild(em);
|
|
615
|
-
li.appendChild(document.createTextNode(': ' + (step.text || '')));
|
|
616
|
-
} else if (step.kind === 'include') {
|
|
617
|
-
const span = document.createElement('span');
|
|
618
|
-
span.className = 'step-include'; span.textContent = '«include» ';
|
|
619
|
-
const a = document.createElement('a');
|
|
620
|
-
a.className = 'step-uc-ref'; a.textContent = ucName(step.uc);
|
|
621
|
-
a.onclick = () => openUcModal(step.uc);
|
|
622
|
-
li.appendChild(span); li.appendChild(a);
|
|
623
|
-
if (step.text) li.appendChild(document.createTextNode(' — ' + step.text));
|
|
624
|
-
} else if (step.kind === 'extension') {
|
|
625
|
-
li.className = 'ext-inline';
|
|
626
|
-
li.textContent = (step.at ? step.at + 'a. ' : '') + (step.text || '');
|
|
627
|
-
}
|
|
628
|
-
ol.appendChild(li);
|
|
629
|
-
// Inline extensions defined in sce.extensions anchored to this step number:
|
|
630
|
-
(sce.extensions || []).filter(e => e.at === i + 1).forEach((ext, j) => {
|
|
631
|
-
const sub = document.createElement('li');
|
|
632
|
-
sub.className = 'ext-sub';
|
|
633
|
-
const letter = String.fromCharCode(97 + j); // a, b, c, ...
|
|
634
|
-
sub.textContent = (i + 1) + letter + '. ' + ext.text;
|
|
635
|
-
ol.appendChild(sub);
|
|
636
|
-
});
|
|
637
|
-
});
|
|
638
|
-
sc.appendChild(ol);
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
const fillList = (selector, items) => {
|
|
642
|
-
const el = modal.querySelector(selector);
|
|
643
|
-
el.innerHTML = '';
|
|
644
|
-
(items || []).forEach(s => { const li = document.createElement('li'); li.textContent = s; el.appendChild(li); });
|
|
645
|
-
};
|
|
646
|
-
fillList('.uc-pre ul', doc.preconditions);
|
|
647
|
-
fillList('.uc-post ul', doc.postconditions);
|
|
648
|
-
const usList = modal.querySelector('.uc-stories ul');
|
|
649
|
-
usList.innerHTML = '';
|
|
650
|
-
(doc.userStories || []).forEach(us => {
|
|
651
|
-
const li = document.createElement('li');
|
|
652
|
-
li.textContent = `As a ${us.role}, I want ${us.want}, so that ${us.so}`;
|
|
653
|
-
usList.appendChild(li);
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
// Wireframe — safety-checked before insertion
|
|
657
|
-
const wf = modal.querySelector('.uc-wireframe');
|
|
658
|
-
wf.innerHTML = '';
|
|
659
|
-
if (doc.wireframe && wireframeSafe(doc.wireframe)) {
|
|
660
|
-
wf.innerHTML = doc.wireframe;
|
|
661
|
-
wf.hidden = false;
|
|
662
|
-
} else {
|
|
663
|
-
wf.hidden = true;
|
|
664
|
-
if (doc.wireframe) console.warn(`Wireframe for ${id} failed safety check.`);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
modal.hidden = false;
|
|
668
|
-
document.body.classList.add('uc-modal-open');
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
function closeUcModal() {
|
|
672
|
-
const modal = document.getElementById('uc-modal');
|
|
673
|
-
modal.hidden = true;
|
|
674
|
-
document.body.classList.remove('uc-modal-open');
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// Esc closes the modal.
|
|
678
|
-
document.addEventListener('keydown', e => {
|
|
679
|
-
if (e.key === 'Escape') closeUcModal();
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
// Highlight an actor on the diagram for ~2s.
|
|
683
|
-
function pulseActor(actorId) {
|
|
684
|
-
const g = document.getElementById('actor-group-' + actorId);
|
|
685
|
-
if (!g) return;
|
|
686
|
-
g.classList.add('actor-pulse');
|
|
687
|
-
setTimeout(() => g.classList.remove('actor-pulse'), 2000);
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Wireframe sanity check — see §WF. Rejects scripts, event handlers, external URLs.
|
|
691
|
-
function wireframeSafe(html) {
|
|
692
|
-
const forbidden = [
|
|
693
|
-
/<script\b/i,
|
|
694
|
-
/\son\w+\s*=/i,
|
|
695
|
-
/<iframe\b/i,
|
|
696
|
-
/src\s*=\s*["']https?:/i,
|
|
697
|
-
/href\s*=\s*["']https?:/i,
|
|
698
|
-
/@import\b/i,
|
|
699
|
-
/<style\b/i
|
|
700
|
-
];
|
|
701
|
-
return !forbidden.some(rx => rx.test(html));
|
|
702
|
-
}
|
|
703
550
|
```
|
|
704
551
|
|
|
705
552
|
UC group: `<g id="uc-uc1" data-uc-id="uc1" onclick="showDesc('uc1','uc',event)" style="cursor:pointer">`
|
|
@@ -742,46 +589,10 @@ Associations without a meaningful description (e.g., auto-generated from trivial
|
|
|
742
589
|
|
|
743
590
|
### Timing labels on cron/time-actor associations (required)
|
|
744
591
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
```javascript
|
|
748
|
-
function renderTimingLabels(layout) {
|
|
749
|
-
const svg = document.getElementById(layout.svgId);
|
|
750
|
-
if (!svg) return;
|
|
751
|
-
svg.querySelectorAll('.timing-label').forEach(el => el.remove());
|
|
752
|
-
const NS = 'http://www.w3.org/2000/svg';
|
|
753
|
-
(layout.connections || []).forEach(conn => {
|
|
754
|
-
if (!conn.timing) return;
|
|
755
|
-
const line = svg.querySelector(
|
|
756
|
-
`[data-conn-from="${conn.from}"][data-conn-to="${conn.to}"]`);
|
|
757
|
-
if (!line) return;
|
|
758
|
-
const x1 = +line.getAttribute('x1'), y1 = +line.getAttribute('y1');
|
|
759
|
-
const x2 = +line.getAttribute('x2'), y2 = +line.getAttribute('y2');
|
|
760
|
-
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
|
|
761
|
-
const w = 10 + conn.timing.length * 7, h = 16;
|
|
762
|
-
const g = document.createElementNS(NS, 'g');
|
|
763
|
-
g.setAttribute('class', 'timing-label');
|
|
764
|
-
const rect = document.createElementNS(NS, 'rect');
|
|
765
|
-
rect.setAttribute('x', mx - w / 2); rect.setAttribute('y', my - h / 2);
|
|
766
|
-
rect.setAttribute('width', w); rect.setAttribute('height', h);
|
|
767
|
-
rect.setAttribute('fill', 'white'); rect.setAttribute('stroke', '#334155');
|
|
768
|
-
rect.setAttribute('stroke-width', '0.8'); rect.setAttribute('rx', '3');
|
|
769
|
-
const text = document.createElementNS(NS, 'text');
|
|
770
|
-
text.setAttribute('x', mx); text.setAttribute('y', my + 4);
|
|
771
|
-
text.setAttribute('font-size', '10'); text.setAttribute('text-anchor', 'middle');
|
|
772
|
-
text.setAttribute('fill', '#334155');
|
|
773
|
-
text.setAttribute('font-family', "'Noto Sans Hebrew', sans-serif");
|
|
774
|
-
text.setAttribute('direction', 'rtl');
|
|
775
|
-
text.textContent = conn.timing;
|
|
776
|
-
g.appendChild(rect); g.appendChild(text);
|
|
777
|
-
svg.appendChild(g);
|
|
778
|
-
});
|
|
779
|
-
}
|
|
780
|
-
```
|
|
781
|
-
|
|
782
|
-
Call `renderTimingLabels(layout)` at the end of every `fixLayout` invocation, **and** on `DOMContentLoaded` (after the initial `fixLayout`). Add it inside `showDiagram(index)` too so the label tracks the active tab.
|
|
592
|
+
`renderTimingLabels(layout)` is provided by `uc-diagram-common.js` — do not inline it.
|
|
593
|
+
It is called automatically at the end of every `fixLayout` invocation.
|
|
783
594
|
|
|
784
|
-
|
|
595
|
+
Every connection from a system-time actor MUST carry a `timing` field:
|
|
785
596
|
|
|
786
597
|
```javascript
|
|
787
598
|
{ from: 'cron', to: 'uc-daily-reconcile', type: 'association', timing: 'פעם ביום' }
|
|
@@ -789,9 +600,15 @@ The `connections` entry for a timed link looks like:
|
|
|
789
600
|
|
|
790
601
|
---
|
|
791
602
|
|
|
792
|
-
## §WF. Wireframe Authoring (
|
|
603
|
+
## §WF. Wireframe Authoring (Opt-In)
|
|
793
604
|
|
|
794
|
-
|
|
605
|
+
**Wireframes are generated only when the user explicitly requests them** (e.g., "add wireframes", "include UI mocks"). Do not generate wireframes unless asked — they significantly increase generation time.
|
|
606
|
+
|
|
607
|
+
When wireframes are requested, generate them only for UCs whose `primaryActor.actorType === 'human'`. Skip wireframes for:
|
|
608
|
+
- UCs whose primary actor is a system or cron (`actorType: 'system'`) — no human sees a screen.
|
|
609
|
+
- Pure include-target UCs (sub-behaviors with no actor association).
|
|
610
|
+
|
|
611
|
+
When generated, emit a wireframe as an inline HTML fragment and attach it to `useCaseDocs[ucId].wireframe`. The modal renderer (§8a) injects the fragment after passing a safety check (see `wireframeSafe()` in §8a).
|
|
795
612
|
|
|
796
613
|
Claude authors the wireframe using its own UI-generation judgment — there is no DSL. But the skill enforces a consistent look and safe sandbox via a scoped CSS theme and a set of forbidden patterns.
|
|
797
614
|
|
|
@@ -860,9 +677,10 @@ The `wireframeSafe()` regex rejects any of the following. A failing wireframe re
|
|
|
860
677
|
|
|
861
678
|
### When NOT to include a wireframe
|
|
862
679
|
|
|
863
|
-
-
|
|
864
|
-
- The
|
|
865
|
-
|
|
680
|
+
- Primary actor is a system / cron (`actorType: 'system'`) — e.g., a back-end reconciliation triggered by the clock.
|
|
681
|
+
- The UC is purely an `«include»` target (a sub-behavior, not a user-visible goal).
|
|
682
|
+
|
|
683
|
+
**"The UI is trivial" is not a valid reason to skip.** A single approval prompt still deserves a mock — it shows the reader what the button says, what data is visible on the screen, and what confirmation text appears. If you genuinely cannot author the wireframe because the spec is silent, record the gap in `diagramModel.openQuestions` AND still emit a placeholder wireframe with the questions visible inside it.
|
|
866
684
|
|
|
867
685
|
---
|
|
868
686
|
|
|
@@ -908,107 +726,9 @@ Assumptions li: #334155, 12px, line-height 1.6, list-style disc inside
|
|
|
908
726
|
Fonts: 'Noto Sans Hebrew' + 'IBM Plex Mono' from Google Fonts
|
|
909
727
|
```
|
|
910
728
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
```css
|
|
914
|
-
.assumptions-panel { display: flex; gap: 24px; max-width: 1000px;
|
|
915
|
-
margin: 20px auto; padding: 16px 20px; background: #fff;
|
|
916
|
-
border: 1px solid #cbd5e1; border-radius: 8px; direction: rtl; }
|
|
917
|
-
.assumptions-col { flex: 1; min-width: 0; }
|
|
918
|
-
.assumptions-col h3 { margin: 0 0 6px; font-size: 13px; font-weight: 600;
|
|
919
|
-
color: #1e40af; }
|
|
920
|
-
.assumptions-col ul { margin: 0; padding-right: 18px; }
|
|
921
|
-
.assumptions-col li { color: #334155; font-size: 12px; line-height: 1.6; }
|
|
922
|
-
```
|
|
923
|
-
|
|
924
|
-
### v2 — UC modal styling
|
|
925
|
-
|
|
926
|
-
```css
|
|
927
|
-
/* Lock body scroll while the modal is open */
|
|
928
|
-
body.uc-modal-open { overflow: hidden; }
|
|
929
|
-
|
|
930
|
-
.uc-modal { position: fixed; inset: 0; z-index: 2000; display: flex;
|
|
931
|
-
align-items: flex-start; justify-content: center; padding: 40px 20px;
|
|
932
|
-
overflow-y: auto; direction: rtl;
|
|
933
|
-
font-family: 'Noto Sans Hebrew', sans-serif; }
|
|
934
|
-
.uc-modal-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, 0.55); }
|
|
935
|
-
.uc-modal-body { position: relative; z-index: 1; width: 100%; max-width: 960px;
|
|
936
|
-
background: #fff; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,.25);
|
|
937
|
-
padding: 28px 32px 32px; color: #1e293b; }
|
|
938
|
-
.uc-modal-close { position: absolute; top: 10px; left: 14px; background: none;
|
|
939
|
-
border: none; font-size: 24px; color: #64748b; cursor: pointer; }
|
|
940
|
-
.uc-modal-body header { border-bottom: 1px solid #e2e8f0; padding-bottom: 12px;
|
|
941
|
-
margin-bottom: 18px; }
|
|
942
|
-
.uc-modal-body .uc-id { display: inline-block; font-family: 'IBM Plex Mono',monospace;
|
|
943
|
-
font-size: 11px; color: #64748b; background: #f1f5f9; padding: 2px 8px;
|
|
944
|
-
border-radius: 4px; margin-bottom: 6px; }
|
|
945
|
-
.uc-modal-body .uc-name { margin: 0 0 4px; font-size: 20px; color: #1e40af; font-weight: 700; }
|
|
946
|
-
.uc-modal-body .uc-primary { font-size: 13px; color: #334155; }
|
|
947
|
-
.uc-modal-body .uc-primary-label { color: #64748b; margin-left: 6px; }
|
|
948
|
-
.uc-modal-body .uc-primary-link { color: #1e40af; cursor: pointer; font-weight: 600;
|
|
949
|
-
text-decoration: underline dotted; }
|
|
950
|
-
.uc-description { margin-bottom: 20px; font-size: 14px; line-height: 1.7; color: #334155; }
|
|
951
|
-
|
|
952
|
-
.uc-scenarios h3 { font-size: 14px; color: #1e40af; margin: 18px 0 6px;
|
|
953
|
-
border-right: 3px solid #3b82f6; padding-right: 10px; }
|
|
954
|
-
.uc-scenarios ol { margin: 0 0 14px; padding-right: 22px; }
|
|
955
|
-
.uc-scenarios ol li { color: #1e293b; font-size: 13px; line-height: 1.9; }
|
|
956
|
-
.step-actor { color: #1e40af; font-weight: 600; cursor: pointer; text-decoration: underline dotted; }
|
|
957
|
-
.step-system { color: #475569; font-style: italic; }
|
|
958
|
-
.step-include { color: #7c3aed; font-family: 'IBM Plex Mono', monospace; font-size: 11px; }
|
|
959
|
-
.step-uc-ref { color: #7c3aed; cursor: pointer; text-decoration: underline; }
|
|
960
|
-
.ext-inline, .ext-sub { list-style: none; color: #64748b; font-size: 12px;
|
|
961
|
-
padding-right: 24px; line-height: 1.7; }
|
|
962
|
-
|
|
963
|
-
.uc-prepostuser { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 18px;
|
|
964
|
-
margin: 18px 0 20px; }
|
|
965
|
-
.uc-prepostuser > div { background: #f8fafc; border: 1px solid #e2e8f0;
|
|
966
|
-
border-radius: 8px; padding: 12px 14px; }
|
|
967
|
-
.uc-prepostuser h4 { margin: 0 0 6px; font-size: 12px; color: #1e40af; font-weight: 600; }
|
|
968
|
-
.uc-prepostuser ul { margin: 0; padding-right: 18px; font-size: 12px;
|
|
969
|
-
color: #334155; line-height: 1.7; }
|
|
970
|
-
|
|
971
|
-
.uc-wireframe { border-top: 1px dashed #cbd5e1; padding-top: 16px; margin-top: 6px; }
|
|
972
|
-
.uc-wireframe:empty, .uc-wireframe[hidden] { display: none; }
|
|
973
|
-
|
|
974
|
-
/* Actor pulse animation used by pulseActor() */
|
|
975
|
-
.actor-pulse { animation: actor-pulse 0.6s ease-in-out 3; }
|
|
976
|
-
@keyframes actor-pulse {
|
|
977
|
-
0%, 100% { filter: none; }
|
|
978
|
-
50% { filter: drop-shadow(0 0 8px #3b82f6); }
|
|
979
|
-
}
|
|
980
|
-
```
|
|
729
|
+
All styles are in `https://themathbible.com/sad-mcp/uc-diagram-common.css` — **do not add a `<style>` block**. Do not inline any CSS.
|
|
981
730
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
```css
|
|
985
|
-
.wf-screen { background: #f8fafc; border: 1px solid #cbd5e1; border-radius: 8px;
|
|
986
|
-
padding: 18px 20px; font-family: 'Noto Sans Hebrew', sans-serif;
|
|
987
|
-
color: #1e293b; direction: rtl; max-width: 760px; margin: 0 auto; }
|
|
988
|
-
.wf-heading { margin: 0 0 14px; font-size: 15px; color: #1e40af; font-weight: 700;
|
|
989
|
-
border-bottom: 1px solid #e2e8f0; padding-bottom: 6px; }
|
|
990
|
-
.wf-section { margin: 14px 0; }
|
|
991
|
-
.wf-row { display: flex; gap: 10px; align-items: center; margin: 6px 0; }
|
|
992
|
-
.wf-label { font-size: 12px; color: #475569; min-width: 120px; }
|
|
993
|
-
.wf-input, .wf-textarea, .wf-select { flex: 1; background: #fff;
|
|
994
|
-
border: 1px solid #cbd5e1; border-radius: 4px; padding: 6px 8px;
|
|
995
|
-
font-size: 12px; color: #334155; }
|
|
996
|
-
.wf-textarea { min-height: 60px; }
|
|
997
|
-
.wf-table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 12px; }
|
|
998
|
-
.wf-table th, .wf-table td { border: 1px solid #cbd5e1; padding: 6px 8px;
|
|
999
|
-
text-align: right; }
|
|
1000
|
-
.wf-table th { background: #e2e8f0; color: #1e40af; font-weight: 600; }
|
|
1001
|
-
.wf-actions { display: flex; gap: 8px; margin-top: 12px; justify-content: flex-start; }
|
|
1002
|
-
.wf-button { background: #fff; border: 1px solid #64748b; border-radius: 4px;
|
|
1003
|
-
padding: 6px 14px; font-size: 12px; color: #334155; cursor: default; }
|
|
1004
|
-
.wf-primary { background: #1e40af; color: #fff; border-color: #1e40af; }
|
|
1005
|
-
.wf-image-box { border: 1px dashed #94a3b8; background: #f1f5f9; color: #64748b;
|
|
1006
|
-
text-align: center; padding: 24px; border-radius: 4px; font-size: 12px;
|
|
1007
|
-
margin: 10px 0; }
|
|
1008
|
-
.wf-hint { font-size: 11px; color: #64748b; font-style: italic; margin: 6px 0; }
|
|
1009
|
-
.wf-badge { display: inline-block; background: #e0f2fe; color: #0369a1;
|
|
1010
|
-
padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 600; }
|
|
1011
|
-
```
|
|
731
|
+
Available wireframe classes (for §WF): `wf-screen`, `wf-heading`, `wf-row`, `wf-label`, `wf-input`, `wf-textarea`, `wf-select`, `wf-button`, `wf-primary`, `wf-actions`, `wf-table`, `wf-image-box`, `wf-hint`, `wf-section`, `wf-badge`.
|
|
1012
732
|
|
|
1013
733
|
---
|
|
1014
734
|
|
|
@@ -1021,7 +741,7 @@ body.uc-modal-open { overflow: hidden; }
|
|
|
1021
741
|
<meta charset="UTF-8">
|
|
1022
742
|
<title>[System] — Use Case Diagram</title>
|
|
1023
743
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Hebrew:wght@400;600&family=IBM+Plex+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
1024
|
-
<
|
|
744
|
+
<link href="https://themathbible.com/sad-mcp/uc-diagram-common.css" rel="stylesheet">
|
|
1025
745
|
</head>
|
|
1026
746
|
<body>
|
|
1027
747
|
<!-- Tab bar (only when split) -->
|
|
@@ -1033,7 +753,7 @@ body.uc-modal-open { overflow: hidden; }
|
|
|
1033
753
|
<!-- Split rationale (only when split) -->
|
|
1034
754
|
<div class="split-rationale">Split by UC count: N UCs > 8 threshold</div>
|
|
1035
755
|
|
|
1036
|
-
<!-- Fix Layout button
|
|
756
|
+
<!-- Fix Layout button -->
|
|
1037
757
|
<div style="text-align:center;margin:8px 0;">
|
|
1038
758
|
<button onclick="fixLayout(currentLayout())" class="fix-btn">Fix Layout</button>
|
|
1039
759
|
</div>
|
|
@@ -1049,99 +769,34 @@ body.uc-modal-open { overflow: hidden; }
|
|
|
1049
769
|
<svg id="diagram-svg-1" viewBox="0 0 1100 900" xmlns="http://www.w3.org/2000/svg">...</svg>
|
|
1050
770
|
</div>
|
|
1051
771
|
|
|
1052
|
-
<!-- Assumptions / open-questions panel
|
|
772
|
+
<!-- Assumptions / open-questions panel -->
|
|
1053
773
|
<div id="assumptions-panel" class="assumptions-panel" hidden>
|
|
1054
774
|
<div class="assumptions-col" id="assumptions-col">
|
|
1055
|
-
<h3>הנחות</h3>
|
|
1056
|
-
<ul id="assumptions-list"></ul>
|
|
775
|
+
<h3>הנחות</h3><ul id="assumptions-list"></ul>
|
|
1057
776
|
</div>
|
|
1058
777
|
<div class="assumptions-col" id="open-questions-col">
|
|
1059
|
-
<h3>שאלות פתוחות</h3>
|
|
1060
|
-
<ul id="open-questions-list"></ul>
|
|
778
|
+
<h3>שאלות פתוחות</h3><ul id="open-questions-list"></ul>
|
|
1061
779
|
</div>
|
|
1062
780
|
</div>
|
|
1063
781
|
|
|
1064
|
-
<!--
|
|
782
|
+
<!-- UC modal (body is populated by openUcModal in common.js; starts empty) -->
|
|
1065
783
|
<div id="uc-modal" class="uc-modal" hidden role="dialog" aria-modal="true">
|
|
1066
784
|
<div class="uc-modal-backdrop" onclick="closeUcModal()"></div>
|
|
1067
|
-
<div class="uc-modal-body">
|
|
1068
|
-
<button class="uc-modal-close" onclick="closeUcModal()" aria-label="Close">×</button>
|
|
1069
|
-
<header>
|
|
1070
|
-
<span class="uc-id"></span>
|
|
1071
|
-
<h2 class="uc-name"></h2>
|
|
1072
|
-
<div class="uc-primary">
|
|
1073
|
-
<span class="uc-primary-label">שחקן ראשי:</span>
|
|
1074
|
-
<a class="uc-primary-link"></a>
|
|
1075
|
-
</div>
|
|
1076
|
-
</header>
|
|
1077
|
-
<section class="uc-description"></section>
|
|
1078
|
-
<section class="uc-scenarios"></section>
|
|
1079
|
-
<section class="uc-prepostuser">
|
|
1080
|
-
<div class="uc-pre"><h4>תנאים מוקדמים</h4><ul></ul></div>
|
|
1081
|
-
<div class="uc-post"><h4>תנאים סופיים</h4><ul></ul></div>
|
|
1082
|
-
<div class="uc-stories"><h4>סיפורי משתמש</h4><ul></ul></div>
|
|
1083
|
-
</section>
|
|
1084
|
-
<section class="uc-wireframe" hidden></section>
|
|
1085
|
-
</div>
|
|
785
|
+
<div class="uc-modal-body"></div>
|
|
1086
786
|
</div>
|
|
1087
787
|
|
|
1088
788
|
<!-- VP export button -->
|
|
1089
789
|
<button class="vp-export-btn" onclick="exportXMI()">Download .xmi (Visual Paradigm)</button>
|
|
1090
790
|
|
|
791
|
+
<!-- DIAGRAM DATA ONLY — all functions are in uc-diagram-common.js -->
|
|
1091
792
|
<script>
|
|
1092
|
-
const diagramLayouts
|
|
1093
|
-
const actorDescriptions = { /* §8b */ };
|
|
1094
|
-
const assocDescriptions = { /* §8b */ };
|
|
1095
|
-
const useCaseDocs = { /* §8a — full
|
|
793
|
+
const diagramLayouts = [ /* one layout object per tab, per §6 */ ];
|
|
794
|
+
const actorDescriptions = { /* §8b — one entry per actor */ };
|
|
795
|
+
const assocDescriptions = { /* §8b — one entry per association line */ };
|
|
796
|
+
const useCaseDocs = { /* §8a — full per-UC documentation */ };
|
|
1096
797
|
const diagramModel = { /* model-shape.md, kind: 'use-case' */ };
|
|
1097
|
-
|
|
1098
|
-
let activeTab = 0;
|
|
1099
|
-
function currentLayout() { return diagramLayouts[activeTab]; }
|
|
1100
|
-
function showDiagram(index) {
|
|
1101
|
-
activeTab = index;
|
|
1102
|
-
document.querySelectorAll('.diagram-part').forEach((p, i) => p.hidden = i !== index);
|
|
1103
|
-
document.querySelectorAll('.tab').forEach((t, i) => t.classList.toggle('active', i === index));
|
|
1104
|
-
fixLayout(diagramLayouts[index]); // idempotent
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
function fixLayout(layout) { /* §6 */ }
|
|
1108
|
-
function showDesc(id, type, event){ /* §8: routes to openUcModal for UCs, showPopup otherwise */ }
|
|
1109
|
-
function showPopup(id, type, event){ /* §8b */ }
|
|
1110
|
-
function hideDesc() { /* §8b */ }
|
|
1111
|
-
function openUcModal(id) { /* §8a */ }
|
|
1112
|
-
function closeUcModal() { /* §8a */ }
|
|
1113
|
-
function pulseActor(actorId) { /* §8a */ }
|
|
1114
|
-
function wireframeSafe(html) { /* §8a / §WF */ }
|
|
1115
|
-
function xmiBody(elements, rels) { /* export-buttons.md — v2 emits preconditions/postconditions/ownedBehavior */ }
|
|
1116
|
-
function exportXMI() { /* export-buttons.md */ }
|
|
1117
|
-
|
|
1118
|
-
function renderAssumptions() {
|
|
1119
|
-
const a = (diagramModel && diagramModel.assumptions) || [];
|
|
1120
|
-
const q = (diagramModel && diagramModel.openQuestions) || [];
|
|
1121
|
-
const panel = document.getElementById('assumptions-panel');
|
|
1122
|
-
if (!panel) return;
|
|
1123
|
-
if (!a.length && !q.length) { panel.hidden = true; return; }
|
|
1124
|
-
panel.hidden = false;
|
|
1125
|
-
const ul = id => document.getElementById(id);
|
|
1126
|
-
const fill = (listEl, items) => {
|
|
1127
|
-
listEl.innerHTML = '';
|
|
1128
|
-
items.forEach(s => {
|
|
1129
|
-
const li = document.createElement('li');
|
|
1130
|
-
li.textContent = s;
|
|
1131
|
-
listEl.appendChild(li);
|
|
1132
|
-
});
|
|
1133
|
-
};
|
|
1134
|
-
fill(ul('assumptions-list'), a);
|
|
1135
|
-
fill(ul('open-questions-list'), q);
|
|
1136
|
-
document.getElementById('assumptions-col').hidden = !a.length;
|
|
1137
|
-
document.getElementById('open-questions-col').hidden = !q.length;
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
1141
|
-
diagramLayouts.forEach(l => fixLayout(l));
|
|
1142
|
-
renderAssumptions();
|
|
1143
|
-
});
|
|
1144
798
|
</script>
|
|
799
|
+
<script src="https://themathbible.com/sad-mcp/uc-diagram-common.js"></script>
|
|
1145
800
|
</body>
|
|
1146
801
|
</html>
|
|
1147
802
|
```
|
|
@@ -1170,7 +825,7 @@ The validator runs locally (no API cost) and catches what the §4 and CR gates c
|
|
|
1170
825
|
- Extensions referencing invalid step numbers → ERROR
|
|
1171
826
|
- Narrative text missing Hebrew characters (description, preconditions, postconditions, flow/extension text, user stories, assumptions, openQuestions) → ERROR
|
|
1172
827
|
- Unsafe wireframe HTML (script / on*= / iframe / external URL / @import / inline style) → ERROR
|
|
1173
|
-
- Warnings: fewer than 2 user stories on a UC, no preconditions/postconditions, overall >2 include+extend relationships
|
|
828
|
+
- Warnings: fewer than 2 user stories on a UC, no preconditions/postconditions, overall >2 include+extend relationships, wireframe present on a UC when none were requested
|
|
1174
829
|
|
|
1175
830
|
If the response is `✗ Model invalid`, fix every listed error and call the validator again. **Do not write the HTML file until the validator returns `✓ Model valid`.**
|
|
1176
831
|
|
|
@@ -1188,7 +843,8 @@ Briefly report: actor count, use case count, include/extend count, whether split
|
|
|
1188
843
|
- [ ] Every `primaryActor` id exists in `actors[]` AND has an association with `role: 'initiator'` on that UC (aligning §4.1 with §4.4).
|
|
1189
844
|
- [ ] Every flow step of `kind: 'actor'` references a real actor id; every `kind: 'include'` references a real UC id; every `extension.at` references a valid step number in its scenario's flow.
|
|
1190
845
|
- [ ] Clicking a UC on the diagram opens the full-screen modal (§8a); Esc / backdrop click closes it. Step actor/UC references are clickable. Actor popup + association popup (§8b) still work for non-UC clicks.
|
|
1191
|
-
- [ ]
|
|
846
|
+
- [ ] Wireframes generated only if the user explicitly requested them (§WF). If generated, only for human-initiated UCs; system/cron and include-targets omitted.
|
|
847
|
+
- [ ] Any `wireframe` HTML passes `wireframeSafe()` (no `<script>`, no `on*=`, no `<iframe>`, no external URLs, no `@import`, no inline `<style>`); all classes are `wf-*` only; visible text is in Hebrew.
|
|
1192
848
|
- [ ] Every actor-UC association has a written §4.1 justification block (role, initiates? one-line "why"); every UC has ≥1 association with `initiates = yes`.
|
|
1193
849
|
- [ ] Every association line carries a transparent click-hitbox (stroke-width ≥12, `data-conn-id`) wired to `showDesc(id,'assoc',event)`; `assocDescriptions` entry exists for each id.
|
|
1194
850
|
- [ ] `assumptions` and `openQuestions` arrays are present in `diagramModel`; the HTML renders the bottom panel whenever either is non-empty.
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
export interface ValidationIssue {
|
|
2
|
-
severity: "error" | "warning";
|
|
3
|
-
code: string;
|
|
4
|
-
path: string;
|
|
5
|
-
message: string;
|
|
6
|
-
}
|
|
7
|
-
export type ValidateResult = {
|
|
8
|
-
ok: true;
|
|
9
|
-
warnings: ValidationIssue[];
|
|
10
|
-
} | {
|
|
11
|
-
ok: false;
|
|
12
|
-
issues: ValidationIssue[];
|
|
13
|
-
warnings: ValidationIssue[];
|
|
14
|
-
};
|
|
15
|
-
export declare function validateModel(model: unknown): ValidateResult;
|
|
16
|
-
export declare function parseAndValidate(jsonText: string): ValidateResult;
|
|
17
|
-
export declare function formatIssues(issues: ValidationIssue[]): string;
|
package/dist/usecase/validate.js
DELETED
|
@@ -1,384 +0,0 @@
|
|
|
1
|
-
// Structural validator for UML use-case diagramModel JSON.
|
|
2
|
-
//
|
|
3
|
-
// Purpose: catch the business-rule violations the SKILL prompts for but cannot
|
|
4
|
-
// enforce on its own — overeager «include», send-only UCs, physical-verb
|
|
5
|
-
// labels, missing primary actor, broken references in scenario flows, etc.
|
|
6
|
-
//
|
|
7
|
-
// Returns ALL issues at once so the calling LLM can self-correct in a single
|
|
8
|
-
// pass rather than iterating error-by-error.
|
|
9
|
-
// ── Name heuristics (CR #8 and CR #10) ──────────────────────────────────
|
|
10
|
-
// Send-only / output-only verbs that on their own do not constitute a UC.
|
|
11
|
-
// Pattern allows "Record sending" etc. — only bare send-verbs flag.
|
|
12
|
-
const SEND_ONLY_PATTERNS = [
|
|
13
|
-
/^(send|notify|email|sms|alert|push|broadcast)\b/i,
|
|
14
|
-
/^(print)\s+\w+\s+(label|receipt|ticket|slip)\b/i,
|
|
15
|
-
/^שליחת\s/,
|
|
16
|
-
/^הודעה\s/,
|
|
17
|
-
/^התראה\s/,
|
|
18
|
-
/^הפצת\s/,
|
|
19
|
-
];
|
|
20
|
-
// Physical verbs that describe actions outside the IS. Allowed only when
|
|
21
|
-
// wrapped by a system verb (רישום / הזנה / עדכון / סימון / Record / Enter / ...).
|
|
22
|
-
const PHYSICAL_VERBS = [
|
|
23
|
-
/^(perform|do|execute|conduct)\b/i,
|
|
24
|
-
/^(pick|pack|ship|deliver|receive|count|pay|collect)\b/i,
|
|
25
|
-
/^(ביצוע|הכנת|קבלת|מסירת|ספירת|תשלום|איסוף)\s/,
|
|
26
|
-
];
|
|
27
|
-
const SYSTEM_VERB_WRAPPERS = [
|
|
28
|
-
/^(record|register|enter|log|capture|mark|update|create|save)\b/i,
|
|
29
|
-
/^(רישום|הזנת|עדכון|סימון|יצירת|שמירת|קליטת|תיעוד)\s/,
|
|
30
|
-
];
|
|
31
|
-
function looksSendOnly(name) {
|
|
32
|
-
return SEND_ONLY_PATTERNS.some((rx) => rx.test(name.trim()));
|
|
33
|
-
}
|
|
34
|
-
function looksPhysical(name) {
|
|
35
|
-
const n = name.trim();
|
|
36
|
-
if (SYSTEM_VERB_WRAPPERS.some((rx) => rx.test(n)))
|
|
37
|
-
return false;
|
|
38
|
-
return PHYSICAL_VERBS.some((rx) => rx.test(n));
|
|
39
|
-
}
|
|
40
|
-
// CR #11 — narrative UC docs must be in Hebrew. We accept "contains at least
|
|
41
|
-
// one Hebrew character" as a cheap heuristic; trivial strings (empty, whitespace,
|
|
42
|
-
// pure punctuation) are treated as missing-content rather than English.
|
|
43
|
-
const HEBREW_RANGE = /[\u0590-\u05FF]/;
|
|
44
|
-
function hasHebrew(text) {
|
|
45
|
-
return typeof text === "string" && HEBREW_RANGE.test(text);
|
|
46
|
-
}
|
|
47
|
-
function isMeaningful(text) {
|
|
48
|
-
return typeof text === "string" && text.trim().length >= 2;
|
|
49
|
-
}
|
|
50
|
-
function asArray(v) {
|
|
51
|
-
return Array.isArray(v) ? v : [];
|
|
52
|
-
}
|
|
53
|
-
function getParts(model) {
|
|
54
|
-
if (!model || typeof model !== "object")
|
|
55
|
-
return [];
|
|
56
|
-
const parts = asArray(model.parts);
|
|
57
|
-
return parts.map((p) => {
|
|
58
|
-
const obj = (p && typeof p === "object") ? p : {};
|
|
59
|
-
return {
|
|
60
|
-
elements: asArray(obj.elements),
|
|
61
|
-
relationships: asArray(obj.relationships),
|
|
62
|
-
};
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
// ── Main validator ──────────────────────────────────────────────────────
|
|
66
|
-
export function validateModel(model) {
|
|
67
|
-
const errors = [];
|
|
68
|
-
const warnings = [];
|
|
69
|
-
const err = (code, path, message) => {
|
|
70
|
-
errors.push({ severity: "error", code, path, message });
|
|
71
|
-
};
|
|
72
|
-
const warn = (code, path, message) => {
|
|
73
|
-
warnings.push({ severity: "warning", code, path, message });
|
|
74
|
-
};
|
|
75
|
-
// --- Root shape ---
|
|
76
|
-
if (!model || typeof model !== "object") {
|
|
77
|
-
err("not-object", "(root)", "diagramModel must be a JSON object.");
|
|
78
|
-
return { ok: false, issues: errors, warnings };
|
|
79
|
-
}
|
|
80
|
-
const root = model;
|
|
81
|
-
if (root.kind !== "use-case") {
|
|
82
|
-
err("wrong-kind", "kind", `Expected kind: "use-case", got ${JSON.stringify(root.kind)}.`);
|
|
83
|
-
}
|
|
84
|
-
const parts = getParts(root);
|
|
85
|
-
if (parts.length === 0) {
|
|
86
|
-
err("no-parts", "parts", "parts[] is empty — at least one part (tab) is required.");
|
|
87
|
-
return { ok: false, issues: errors, warnings };
|
|
88
|
-
}
|
|
89
|
-
// Collect everything across all parts for cross-part id lookup.
|
|
90
|
-
const allElements = [];
|
|
91
|
-
const allRelationships = [];
|
|
92
|
-
parts.forEach((p, i) => {
|
|
93
|
-
p.elements.forEach((el) => allElements.push({ part: i, el }));
|
|
94
|
-
p.relationships.forEach((rel) => allRelationships.push({ part: i, rel }));
|
|
95
|
-
});
|
|
96
|
-
const ucs = allElements.filter((x) => x.el.kind === "useCase").map((x) => x.el);
|
|
97
|
-
const actors = allElements.filter((x) => x.el.kind === "actor").map((x) => x.el);
|
|
98
|
-
const ucIds = new Set(ucs.map((u) => String(u.id)));
|
|
99
|
-
const actorIds = new Set(actors.map((a) => String(a.id)));
|
|
100
|
-
// --- Element shape checks ---
|
|
101
|
-
ucs.forEach((uc) => {
|
|
102
|
-
const id = String(uc.id ?? "");
|
|
103
|
-
const path = `useCase[${id || "?"}]`;
|
|
104
|
-
if (!id)
|
|
105
|
-
err("uc-missing-id", path, "useCase is missing an id.");
|
|
106
|
-
if (!uc.name)
|
|
107
|
-
err("uc-missing-name", path, "useCase is missing a name.");
|
|
108
|
-
if (uc.name && typeof uc.name === "string") {
|
|
109
|
-
if (looksSendOnly(uc.name)) {
|
|
110
|
-
err("send-only-uc", `${path}.name`, `UC name "${uc.name}" looks send-only (CR #10). Fold into the UC that decided to send.`);
|
|
111
|
-
}
|
|
112
|
-
if (looksPhysical(uc.name)) {
|
|
113
|
-
warn("physical-verb", `${path}.name`, `UC name "${uc.name}" starts with a physical verb (CR #8). Prefer "Record X" / "Enter X" / "Register X" to describe the IS action.`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
actors.forEach((a) => {
|
|
118
|
-
const id = String(a.id ?? "");
|
|
119
|
-
const path = `actor[${id || "?"}]`;
|
|
120
|
-
if (!id)
|
|
121
|
-
err("actor-missing-id", path, "actor is missing an id.");
|
|
122
|
-
if (!a.name)
|
|
123
|
-
err("actor-missing-name", path, "actor is missing a name.");
|
|
124
|
-
});
|
|
125
|
-
// --- Relationship cross-references + CR #4 + single-include + orphaned UC ---
|
|
126
|
-
const actorAssocsByUc = new Map();
|
|
127
|
-
const includeIntoByUc = new Map();
|
|
128
|
-
const initiatorByUc = new Map();
|
|
129
|
-
allRelationships.forEach(({ rel }, i) => {
|
|
130
|
-
const path = `relationships[${i}]`;
|
|
131
|
-
const kind = rel.kind;
|
|
132
|
-
if (kind === "actorAssoc") {
|
|
133
|
-
const actorId = String(rel.actorId ?? "");
|
|
134
|
-
const ucId = String(rel.ucId ?? "");
|
|
135
|
-
if (!actorIds.has(actorId))
|
|
136
|
-
err("unknown-actor", path, `actorAssoc.actorId "${actorId}" does not exist in actors[].`);
|
|
137
|
-
if (!ucIds.has(ucId))
|
|
138
|
-
err("unknown-uc", path, `actorAssoc.ucId "${ucId}" does not exist in use cases.`);
|
|
139
|
-
if (actorIds.has(actorId) && ucIds.has(ucId)) {
|
|
140
|
-
if (!actorAssocsByUc.has(ucId))
|
|
141
|
-
actorAssocsByUc.set(ucId, []);
|
|
142
|
-
actorAssocsByUc.get(ucId).push(rel);
|
|
143
|
-
if (rel.role === "initiator") {
|
|
144
|
-
if (!initiatorByUc.has(ucId))
|
|
145
|
-
initiatorByUc.set(ucId, new Set());
|
|
146
|
-
initiatorByUc.get(ucId).add(actorId);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
else if (kind === "include") {
|
|
151
|
-
const from = String(rel.from ?? "");
|
|
152
|
-
const to = String(rel.to ?? "");
|
|
153
|
-
if (!ucIds.has(from))
|
|
154
|
-
err("unknown-uc", path, `include.from "${from}" does not exist.`);
|
|
155
|
-
if (!ucIds.has(to))
|
|
156
|
-
err("unknown-uc", path, `include.to "${to}" does not exist.`);
|
|
157
|
-
if (ucIds.has(to)) {
|
|
158
|
-
if (!includeIntoByUc.has(to))
|
|
159
|
-
includeIntoByUc.set(to, []);
|
|
160
|
-
includeIntoByUc.get(to).push(rel);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
else if (kind === "extend") {
|
|
164
|
-
const from = String(rel.from ?? "");
|
|
165
|
-
const to = String(rel.to ?? "");
|
|
166
|
-
if (!ucIds.has(from))
|
|
167
|
-
err("unknown-uc", path, `extend.from "${from}" does not exist.`);
|
|
168
|
-
if (!ucIds.has(to))
|
|
169
|
-
err("unknown-uc", path, `extend.to "${to}" does not exist.`);
|
|
170
|
-
}
|
|
171
|
-
else if (kind === "generalization") {
|
|
172
|
-
const from = String(rel.from ?? "");
|
|
173
|
-
const to = String(rel.to ?? "");
|
|
174
|
-
if (!ucIds.has(from) && !actorIds.has(from))
|
|
175
|
-
err("unknown-endpoint", path, `generalization.from "${from}" is neither a UC nor an actor.`);
|
|
176
|
-
if (!ucIds.has(to) && !actorIds.has(to))
|
|
177
|
-
err("unknown-endpoint", path, `generalization.to "${to}" is neither a UC nor an actor.`);
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
// CR #4 — include target checks
|
|
181
|
-
for (const [ucId, includes] of includeIntoByUc) {
|
|
182
|
-
const path = `useCase[${ucId}]`;
|
|
183
|
-
if (includes.length < 2) {
|
|
184
|
-
err("include-single-base", path, `UC "${ucId}" is the target of only ${includes.length} «include» arrow — an include target requires ≥2 bases (CR #4). Fold into the parent UC.`);
|
|
185
|
-
}
|
|
186
|
-
if ((actorAssocsByUc.get(ucId) || []).length > 0) {
|
|
187
|
-
err("include-target-has-actor", path, `UC "${ucId}" is an «include» target AND has a direct actor association — forbidden (CR #4). An include target must have ZERO actor associations.`);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
// §4.1 — every UC must have ≥1 initiator (unless it's purely an include target)
|
|
191
|
-
ucs.forEach((uc) => {
|
|
192
|
-
const id = String(uc.id ?? "");
|
|
193
|
-
if (!id)
|
|
194
|
-
return;
|
|
195
|
-
const path = `useCase[${id}]`;
|
|
196
|
-
const isIncludeTarget = (includeIntoByUc.get(id) || []).length >= 2;
|
|
197
|
-
const hasInitiator = (initiatorByUc.get(id)?.size ?? 0) > 0;
|
|
198
|
-
const hasAnyActor = (actorAssocsByUc.get(id) || []).length > 0;
|
|
199
|
-
if (!isIncludeTarget && !hasAnyActor) {
|
|
200
|
-
err("uc-no-actor", path, `UC "${id}" has no actor association and is not an include target. Either connect an actor or remove the UC.`);
|
|
201
|
-
}
|
|
202
|
-
if (!isIncludeTarget && hasAnyActor && !hasInitiator) {
|
|
203
|
-
err("uc-no-initiator", path, `UC "${id}" has actor associations but none with role: "initiator" (§4.1). Every UC needs at least one initiator.`);
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
// §4.4 — per-UC documentation checks (only for non-include-target UCs; include
|
|
207
|
-
// targets are sub-behaviors and can use lighter docs in practice, but we still
|
|
208
|
-
// require description + at least one scenario if any doc is present).
|
|
209
|
-
ucs.forEach((uc) => {
|
|
210
|
-
const id = String(uc.id ?? "");
|
|
211
|
-
if (!id)
|
|
212
|
-
return;
|
|
213
|
-
const path = `useCase[${id}]`;
|
|
214
|
-
const isIncludeTarget = (includeIntoByUc.get(id) || []).length >= 2;
|
|
215
|
-
const primaryActor = uc.primaryActor;
|
|
216
|
-
if (!isIncludeTarget) {
|
|
217
|
-
if (!primaryActor) {
|
|
218
|
-
err("uc-missing-primary-actor", path, `UC "${id}" is missing primaryActor (§4.4).`);
|
|
219
|
-
}
|
|
220
|
-
else if (!actorIds.has(primaryActor)) {
|
|
221
|
-
err("uc-primary-actor-unknown", path, `UC "${id}" primaryActor "${primaryActor}" is not an actor.`);
|
|
222
|
-
}
|
|
223
|
-
else if (!(initiatorByUc.get(id)?.has(primaryActor))) {
|
|
224
|
-
err("uc-primary-actor-not-initiator", path, `UC "${id}" primaryActor "${primaryActor}" is not connected with role: "initiator". The primary actor must be an initiator.`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
const scenarios = asArray(uc.scenarios);
|
|
228
|
-
if (!isIncludeTarget && scenarios.length === 0) {
|
|
229
|
-
err("uc-no-scenarios", path, `UC "${id}" has no scenarios. At least one scenario with a flow is required (§4.4).`);
|
|
230
|
-
}
|
|
231
|
-
scenarios.forEach((sc, si) => {
|
|
232
|
-
const sco = (sc && typeof sc === "object") ? sc : {};
|
|
233
|
-
const scPath = `${path}.scenarios[${si}]`;
|
|
234
|
-
const flow = asArray(sco.flow);
|
|
235
|
-
if (flow.length === 0) {
|
|
236
|
-
err("scenario-no-flow", scPath, `Scenario has no flow steps. At least one step is required.`);
|
|
237
|
-
}
|
|
238
|
-
flow.forEach((step, stepIdx) => {
|
|
239
|
-
const s = (step && typeof step === "object") ? step : {};
|
|
240
|
-
const sPath = `${scPath}.flow[${stepIdx}]`;
|
|
241
|
-
const k = s.kind;
|
|
242
|
-
if (k === "actor") {
|
|
243
|
-
const aId = String(s.actor ?? "");
|
|
244
|
-
if (!actorIds.has(aId))
|
|
245
|
-
err("flow-unknown-actor", sPath, `Flow step references unknown actor id "${aId}".`);
|
|
246
|
-
}
|
|
247
|
-
else if (k === "include") {
|
|
248
|
-
const uId = String(s.uc ?? "");
|
|
249
|
-
if (!ucIds.has(uId))
|
|
250
|
-
err("flow-unknown-uc", sPath, `Flow step «include» references unknown UC id "${uId}".`);
|
|
251
|
-
// Also require a matching include relationship to exist.
|
|
252
|
-
const targets = includeIntoByUc.get(uId) || [];
|
|
253
|
-
const hasMatching = targets.some((rel) => String(rel.from) === id);
|
|
254
|
-
if (ucIds.has(uId) && !hasMatching) {
|
|
255
|
-
err("flow-include-mismatch", sPath, `Flow step «include» ${uId} from UC "${id}" does not have a matching include relationship in relationships[]. Add { kind: "include", from: "${id}", to: "${uId}" } or remove this step.`);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
else if (k === "system" || k === "extension") {
|
|
259
|
-
// no refs to check
|
|
260
|
-
}
|
|
261
|
-
else if (k) {
|
|
262
|
-
err("flow-unknown-kind", sPath, `Flow step kind "${String(k)}" is not one of: actor, system, include, extension.`);
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
const extensions = asArray(sco.extensions);
|
|
266
|
-
extensions.forEach((ext, ei) => {
|
|
267
|
-
const e = (ext && typeof ext === "object") ? ext : {};
|
|
268
|
-
const at = Number(e.at);
|
|
269
|
-
if (!Number.isFinite(at) || at < 1 || at > flow.length) {
|
|
270
|
-
err("extension-bad-at", `${scPath}.extensions[${ei}]`, `Extension.at=${String(e.at)} does not reference a valid flow step (1..${flow.length}).`);
|
|
271
|
-
}
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
if (!isIncludeTarget) {
|
|
275
|
-
const pre = asArray(uc.preconditions);
|
|
276
|
-
const post = asArray(uc.postconditions);
|
|
277
|
-
const stories = asArray(uc.userStories);
|
|
278
|
-
if (pre.length === 0)
|
|
279
|
-
warn("uc-no-preconditions", path, `UC "${id}" has no preconditions — recommended.`);
|
|
280
|
-
if (post.length === 0)
|
|
281
|
-
warn("uc-no-postconditions", path, `UC "${id}" has no postconditions — recommended.`);
|
|
282
|
-
if (stories.length === 0)
|
|
283
|
-
err("uc-no-user-stories", path, `UC "${id}" has no userStories (§4.4). Add as many as the UC warrants — one per distinct user goal.`);
|
|
284
|
-
else if (stories.length === 1)
|
|
285
|
-
warn("uc-single-user-story", path, `UC "${id}" has only 1 user story. Add one per distinct stakeholder goal behind the UC; a single token story is usually incomplete.`);
|
|
286
|
-
// CR #11 — narrative content must be in Hebrew.
|
|
287
|
-
if (isMeaningful(uc.description) && !hasHebrew(uc.description)) {
|
|
288
|
-
err("non-hebrew-text", `${path}.description`, `UC description must be written in Hebrew (CR #11).`);
|
|
289
|
-
}
|
|
290
|
-
pre.forEach((p, i) => {
|
|
291
|
-
if (isMeaningful(p) && !hasHebrew(p))
|
|
292
|
-
err("non-hebrew-text", `${path}.preconditions[${i}]`, `Precondition must be written in Hebrew (CR #11).`);
|
|
293
|
-
});
|
|
294
|
-
post.forEach((p, i) => {
|
|
295
|
-
if (isMeaningful(p) && !hasHebrew(p))
|
|
296
|
-
err("non-hebrew-text", `${path}.postconditions[${i}]`, `Postcondition must be written in Hebrew (CR #11).`);
|
|
297
|
-
});
|
|
298
|
-
stories.forEach((s, i) => {
|
|
299
|
-
const obj = (s && typeof s === "object") ? s : {};
|
|
300
|
-
const combined = `${obj.role ?? ""} ${obj.want ?? ""} ${obj.so ?? ""}`;
|
|
301
|
-
if (isMeaningful(combined) && !hasHebrew(combined))
|
|
302
|
-
err("non-hebrew-text", `${path}.userStories[${i}]`, `User story must be written in Hebrew (role/want/so — CR #11).`);
|
|
303
|
-
});
|
|
304
|
-
scenarios.forEach((sc, si) => {
|
|
305
|
-
const sco = (sc && typeof sc === "object") ? sc : {};
|
|
306
|
-
const scPath = `${path}.scenarios[${si}]`;
|
|
307
|
-
asArray(sco.flow).forEach((step, stepIdx) => {
|
|
308
|
-
const s = (step && typeof step === "object") ? step : {};
|
|
309
|
-
if (isMeaningful(s.text) && !hasHebrew(s.text))
|
|
310
|
-
err("non-hebrew-text", `${scPath}.flow[${stepIdx}].text`, `Flow step text must be written in Hebrew (CR #11).`);
|
|
311
|
-
});
|
|
312
|
-
asArray(sco.extensions).forEach((ext, ei) => {
|
|
313
|
-
const e = (ext && typeof ext === "object") ? ext : {};
|
|
314
|
-
if (isMeaningful(e.text) && !hasHebrew(e.text))
|
|
315
|
-
err("non-hebrew-text", `${scPath}.extensions[${ei}].text`, `Extension text must be written in Hebrew (CR #11).`);
|
|
316
|
-
});
|
|
317
|
-
});
|
|
318
|
-
// Wireframe safety pre-check (regex-only; defense in depth — the HTML runtime
|
|
319
|
-
// also re-checks via wireframeSafe()).
|
|
320
|
-
const wf = uc.wireframe;
|
|
321
|
-
if (typeof wf === "string" && wf.length > 0) {
|
|
322
|
-
const forbidden = [
|
|
323
|
-
/<script\b/i,
|
|
324
|
-
/\son\w+\s*=/i,
|
|
325
|
-
/<iframe\b/i,
|
|
326
|
-
/src\s*=\s*["']https?:/i,
|
|
327
|
-
/href\s*=\s*["']https?:/i,
|
|
328
|
-
/@import\b/i,
|
|
329
|
-
/<style\b/i,
|
|
330
|
-
];
|
|
331
|
-
const hit = forbidden.find((rx) => rx.test(wf));
|
|
332
|
-
if (hit) {
|
|
333
|
-
err("wireframe-unsafe", `${path}.wireframe`, `Wireframe HTML contains a forbidden pattern (${hit}). Remove scripts / on*= / iframe / external URLs / @import / <style>.`);
|
|
334
|
-
}
|
|
335
|
-
// CR #11 — the visible text inside the wireframe (labels, buttons, headings)
|
|
336
|
-
// must be in Hebrew since the product is Hebrew. A wireframe with zero
|
|
337
|
-
// Hebrew characters is an English UI mock.
|
|
338
|
-
if (!hasHebrew(wf)) {
|
|
339
|
-
err("wireframe-non-hebrew", `${path}.wireframe`, `Wireframe contains no Hebrew characters. Visible labels, headings, and buttons inside the wireframe must be in Hebrew (CR #11). Identifier/class names (wf-*) stay ASCII.`);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
});
|
|
344
|
-
// CR #11 — assumptions / openQuestions must be Hebrew (they live at the model root).
|
|
345
|
-
asArray(root.assumptions).forEach((s, i) => {
|
|
346
|
-
if (isMeaningful(s) && !hasHebrew(s))
|
|
347
|
-
err("non-hebrew-text", `assumptions[${i}]`, `Assumption must be written in Hebrew (CR #11).`);
|
|
348
|
-
});
|
|
349
|
-
asArray(root.openQuestions).forEach((s, i) => {
|
|
350
|
-
if (isMeaningful(s) && !hasHebrew(s))
|
|
351
|
-
err("non-hebrew-text", `openQuestions[${i}]`, `Open question must be written in Hebrew (CR #11).`);
|
|
352
|
-
});
|
|
353
|
-
// --- Warn on too many includes (diagrams with 3+ usually have a design smell) ---
|
|
354
|
-
const totalIncludes = allRelationships.filter((r) => r.rel.kind === "include").length;
|
|
355
|
-
const totalExtends = allRelationships.filter((r) => r.rel.kind === "extend").length;
|
|
356
|
-
if (totalIncludes + totalExtends > 2) {
|
|
357
|
-
warn("many-include-extend", "relationships", `Diagram has ${totalIncludes} «include» and ${totalExtends} «extend» relationships. Most UC diagrams need ≤2 combined — review each against the §4 gate.`);
|
|
358
|
-
}
|
|
359
|
-
if (errors.length > 0)
|
|
360
|
-
return { ok: false, issues: errors, warnings };
|
|
361
|
-
return { ok: true, warnings };
|
|
362
|
-
}
|
|
363
|
-
export function parseAndValidate(jsonText) {
|
|
364
|
-
let parsed;
|
|
365
|
-
try {
|
|
366
|
-
parsed = JSON.parse(jsonText);
|
|
367
|
-
}
|
|
368
|
-
catch (err) {
|
|
369
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
370
|
-
return {
|
|
371
|
-
ok: false,
|
|
372
|
-
issues: [{ severity: "error", code: "invalid-json", path: "(root)", message: `JSON parse failed: ${msg}` }],
|
|
373
|
-
warnings: [],
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
return validateModel(parsed);
|
|
377
|
-
}
|
|
378
|
-
export function formatIssues(issues) {
|
|
379
|
-
if (issues.length === 0)
|
|
380
|
-
return "(no issues)";
|
|
381
|
-
return issues
|
|
382
|
-
.map((i) => `- [${i.severity === "error" ? "ERROR" : "warn"}] [${i.code}] ${i.path}: ${i.message}`)
|
|
383
|
-
.join("\n");
|
|
384
|
-
}
|