sad-mcp 2.2.0 → 2.2.2
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 (DESKTOP-GQHOSST's conflicted copy 2026-04-19).d.ts +15 -0
- package/dist/usecase/index (DESKTOP-GQHOSST's conflicted copy 2026-04-19).js +218 -0
- package/dist/usecase/index.d (DESKTOP-GQHOSST's conflicted copy 2026-04-19).ts +15 -0
- package/dist/usecase/index.d.ts +2 -15
- package/dist/usecase/index.js +1 -218
- package/dist/usecase/validate.d.ts +17 -0
- package/dist/usecase/validate.js +393 -0
- package/package.json +1 -1
- package/skills/uml-use-case-diagram/SKILL.md +22 -7
|
@@ -0,0 +1,15 @@
|
|
|
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;
|
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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.d.ts
CHANGED
|
@@ -1,15 +1,2 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
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;
|
|
1
|
+
export { validateModel, parseAndValidate, formatIssues } from "./validate.js";
|
|
2
|
+
export type { ValidateResult, ValidationIssue } from "./validate.js";
|
package/dist/usecase/index.js
CHANGED
|
@@ -1,218 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
export { validateModel, parseAndValidate, formatIssues } from "./validate.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
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;
|
|
@@ -0,0 +1,393 @@
|
|
|
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
|
+
// §WF — a UC initiated by a human actor must have a wireframe.
|
|
322
|
+
if (primaryActor && actorIds.has(primaryActor)) {
|
|
323
|
+
const pa = actors.find((a) => String(a.id) === primaryActor);
|
|
324
|
+
const isHuman = !pa || pa.actorType !== "system";
|
|
325
|
+
const wfMissing = typeof wf !== "string" || wf.trim() === "";
|
|
326
|
+
if (isHuman && wfMissing) {
|
|
327
|
+
err("wireframe-missing", `${path}.wireframe`, `UC "${id}" has a human primary actor ("${primaryActor}") but no wireframe. Every human-initiated UC requires a wireframe per §WF. Add one, or explicitly use a placeholder describing what info is missing.`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (typeof wf === "string" && wf.length > 0) {
|
|
331
|
+
const forbidden = [
|
|
332
|
+
/<script\b/i,
|
|
333
|
+
/\son\w+\s*=/i,
|
|
334
|
+
/<iframe\b/i,
|
|
335
|
+
/src\s*=\s*["']https?:/i,
|
|
336
|
+
/href\s*=\s*["']https?:/i,
|
|
337
|
+
/@import\b/i,
|
|
338
|
+
/<style\b/i,
|
|
339
|
+
];
|
|
340
|
+
const hit = forbidden.find((rx) => rx.test(wf));
|
|
341
|
+
if (hit) {
|
|
342
|
+
err("wireframe-unsafe", `${path}.wireframe`, `Wireframe HTML contains a forbidden pattern (${hit}). Remove scripts / on*= / iframe / external URLs / @import / <style>.`);
|
|
343
|
+
}
|
|
344
|
+
// CR #11 — the visible text inside the wireframe (labels, buttons, headings)
|
|
345
|
+
// must be in Hebrew since the product is Hebrew. A wireframe with zero
|
|
346
|
+
// Hebrew characters is an English UI mock.
|
|
347
|
+
if (!hasHebrew(wf)) {
|
|
348
|
+
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.`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
// CR #11 — assumptions / openQuestions must be Hebrew (they live at the model root).
|
|
354
|
+
asArray(root.assumptions).forEach((s, i) => {
|
|
355
|
+
if (isMeaningful(s) && !hasHebrew(s))
|
|
356
|
+
err("non-hebrew-text", `assumptions[${i}]`, `Assumption must be written in Hebrew (CR #11).`);
|
|
357
|
+
});
|
|
358
|
+
asArray(root.openQuestions).forEach((s, i) => {
|
|
359
|
+
if (isMeaningful(s) && !hasHebrew(s))
|
|
360
|
+
err("non-hebrew-text", `openQuestions[${i}]`, `Open question must be written in Hebrew (CR #11).`);
|
|
361
|
+
});
|
|
362
|
+
// --- Warn on too many includes (diagrams with 3+ usually have a design smell) ---
|
|
363
|
+
const totalIncludes = allRelationships.filter((r) => r.rel.kind === "include").length;
|
|
364
|
+
const totalExtends = allRelationships.filter((r) => r.rel.kind === "extend").length;
|
|
365
|
+
if (totalIncludes + totalExtends > 2) {
|
|
366
|
+
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.`);
|
|
367
|
+
}
|
|
368
|
+
if (errors.length > 0)
|
|
369
|
+
return { ok: false, issues: errors, warnings };
|
|
370
|
+
return { ok: true, warnings };
|
|
371
|
+
}
|
|
372
|
+
export function parseAndValidate(jsonText) {
|
|
373
|
+
let parsed;
|
|
374
|
+
try {
|
|
375
|
+
parsed = JSON.parse(jsonText);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
issues: [{ severity: "error", code: "invalid-json", path: "(root)", message: `JSON parse failed: ${msg}` }],
|
|
382
|
+
warnings: [],
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
return validateModel(parsed);
|
|
386
|
+
}
|
|
387
|
+
export function formatIssues(issues) {
|
|
388
|
+
if (issues.length === 0)
|
|
389
|
+
return "(no issues)";
|
|
390
|
+
return issues
|
|
391
|
+
.map((i) => `- [${i.severity === "error" ? "ERROR" : "warn"}] [${i.code}] ${i.path}: ${i.message}`)
|
|
392
|
+
.join("\n");
|
|
393
|
+
}
|
package/package.json
CHANGED
|
@@ -716,6 +716,16 @@ Ellipse: fill #d4e9f7, stroke #2980b9 (1.8px), solid
|
|
|
716
716
|
Ellipse text: #1a1a2e, 11px, weight 500, text-anchor middle
|
|
717
717
|
Actor stick figure: stroke #334155 (2px), human head fill #e0f0ff, system head fill #fef3c7
|
|
718
718
|
Actor label: #1e293b, 11px, weight 600, text-anchor middle
|
|
719
|
+
|
|
720
|
+
Actor SVG template — EXACT coordinates, do not deviate (fixLayout centers on the group origin):
|
|
721
|
+
<circle cx="0" cy="-60" r="14" fill="#e0f0ff" stroke="#334155" stroke-width="2"/>
|
|
722
|
+
<line x1="0" y1="-46" x2="0" y2="-10" stroke="#334155" stroke-width="2"/>
|
|
723
|
+
<line x1="-18" y1="-36" x2="18" y2="-36" stroke="#334155" stroke-width="2"/>
|
|
724
|
+
<line x1="0" y1="-10" x2="-14" y2="14" stroke="#334155" stroke-width="2"/>
|
|
725
|
+
<line x1="0" y1="-10" x2="14" y2="14" stroke="#334155" stroke-width="2"/>
|
|
726
|
+
<text x="0" y="30" text-anchor="middle" font-size="11" font-weight="600" fill="#1e293b"
|
|
727
|
+
font-family="'Noto Sans Hebrew',sans-serif">[Actor label]</text>
|
|
728
|
+
(For system actors: same but head fill #fef3c7, add «system» label at y="44" in IBM Plex Mono 9px #64748b)
|
|
719
729
|
Association line: #334155, 1.8px, no arrowhead
|
|
720
730
|
Include/extend: #7c3aed, 1.6px, dasharray 6 4, open arrowhead, label 9.5px
|
|
721
731
|
Generalization: #334155, 1.8px, hollow triangle (fill white)
|
|
@@ -788,20 +798,25 @@ Available wireframe classes (for §WF): `wf-screen`, `wf-heading`, `wf-row`, `wf
|
|
|
788
798
|
<!-- VP export button -->
|
|
789
799
|
<button class="vp-export-btn" onclick="exportXMI()">Download .xmi (Visual Paradigm)</button>
|
|
790
800
|
|
|
791
|
-
<!-- DIAGRAM DATA ONLY — all functions are in uc-diagram-common.js
|
|
801
|
+
<!-- DIAGRAM DATA ONLY — all functions are in uc-diagram-common.js.
|
|
802
|
+
Use var (NOT const/let) so these become window properties accessible from the external script.
|
|
803
|
+
Do NOT add activeTab, currentLayout, showDiagram, exportXMI, xmlEscape, downloadFile,
|
|
804
|
+
or any DOMContentLoaded handler here — they all live in uc-diagram-common.js.
|
|
805
|
+
Adding let/const variables with the same names as any variable in the external script
|
|
806
|
+
causes a SyntaxError that silently kills the entire external script. -->
|
|
792
807
|
<script>
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
808
|
+
var diagramLayouts = [ /* one layout object per tab, per §6 */ ];
|
|
809
|
+
var actorDescriptions = { /* §8b — one entry per actor */ };
|
|
810
|
+
var assocDescriptions = { /* §8b — one entry per association line */ };
|
|
811
|
+
var useCaseDocs = { /* §8a — full per-UC documentation */ };
|
|
812
|
+
var diagramModel = { /* model-shape.md, kind: 'use-case' */ };
|
|
798
813
|
</script>
|
|
799
814
|
<script src="https://themathbible.com/sad-mcp/uc-diagram-common.js"></script>
|
|
800
815
|
</body>
|
|
801
816
|
</html>
|
|
802
817
|
```
|
|
803
818
|
|
|
804
|
-
**Single-tab diagrams still define the full script.** Always emit
|
|
819
|
+
**Single-tab diagrams still define the full script.** Always emit all five `var` data globals; the tab bar HTML and second `.diagram-part` are the only things conditional on splitting. `showDiagram`, `currentLayout`, `activeTab`, `fixLayout`, `exportXMI` and all other functions live exclusively in `uc-diagram-common.js` — never re-declare them inline.
|
|
805
820
|
|
|
806
821
|
---
|
|
807
822
|
|