sad-mcp 2.2.0 → 2.2.1

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.
@@ -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;
@@ -1,15 +1,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;
1
+ export { validateModel, parseAndValidate, formatIssues } from "./validate.js";
2
+ export type { ValidateResult, ValidationIssue } from "./validate.js";
@@ -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
- }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sad-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "MCP server for Software Analysis and Design course materials at BGU",
5
5
  "type": "module",
6
6
  "bin": {
@@ -788,20 +788,25 @@ Available wireframe classes (for §WF): `wf-screen`, `wf-heading`, `wf-row`, `wf
788
788
  <!-- VP export button -->
789
789
  <button class="vp-export-btn" onclick="exportXMI()">Download .xmi (Visual Paradigm)</button>
790
790
 
791
- <!-- DIAGRAM DATA ONLY — all functions are in uc-diagram-common.js -->
791
+ <!-- DIAGRAM DATA ONLY — all functions are in uc-diagram-common.js.
792
+ Use var (NOT const/let) so these become window properties accessible from the external script.
793
+ Do NOT add activeTab, currentLayout, showDiagram, exportXMI, xmlEscape, downloadFile,
794
+ or any DOMContentLoaded handler here — they all live in uc-diagram-common.js.
795
+ Adding let/const variables with the same names as any variable in the external script
796
+ causes a SyntaxError that silently kills the entire external script. -->
792
797
  <script>
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 */ };
797
- const diagramModel = { /* model-shape.md, kind: 'use-case' */ };
798
+ var diagramLayouts = [ /* one layout object per tab, per §6 */ ];
799
+ var actorDescriptions = { /* §8b — one entry per actor */ };
800
+ var assocDescriptions = { /* §8b — one entry per association line */ };
801
+ var useCaseDocs = { /* §8a — full per-UC documentation */ };
802
+ var diagramModel = { /* model-shape.md, kind: 'use-case' */ };
798
803
  </script>
799
804
  <script src="https://themathbible.com/sad-mcp/uc-diagram-common.js"></script>
800
805
  </body>
801
806
  </html>
802
807
  ```
803
808
 
804
- **Single-tab diagrams still define the full script.** Always emit `diagramLayouts` (one-element array), `activeTab`, `currentLayout()`, **and `showDiagram(index)`**. Only the HTML-level tab bar and the second `.diagram-part` are conditional on splitting. Omitting `showDiagram` is a silent bug: a future edit that adds tabs will have dead onclick handlers with no console error pointing at the cause.
809
+ **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
810
 
806
811
  ---
807
812