specky-sdd 3.4.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.apm/hooks/scripts/ears-validator.sh +56 -17
  2. package/.apm/prompts/specky-api.prompt.md +1 -1
  3. package/.apm/prompts/specky-research.prompt.md +2 -2
  4. package/.apm/skills/research-analyst/SKILL.md +2 -2
  5. package/.apm/skills/specky-onboarding/SKILL.md +6 -4
  6. package/CHANGELOG.md +58 -0
  7. package/README.md +39 -10
  8. package/SECURITY.md +15 -8
  9. package/apm.yml +1 -1
  10. package/config.yml +1 -1
  11. package/dist/cli/commands/serve.d.ts.map +1 -1
  12. package/dist/cli/commands/serve.js +3 -2
  13. package/dist/cli/commands/serve.js.map +1 -1
  14. package/dist/cli/index.js +3 -1
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/config.d.ts +24 -1
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +92 -6
  19. package/dist/config.js.map +1 -1
  20. package/dist/index.js +63 -14
  21. package/dist/index.js.map +1 -1
  22. package/dist/services/audit-logger.d.ts +54 -5
  23. package/dist/services/audit-logger.d.ts.map +1 -1
  24. package/dist/services/audit-logger.js +121 -9
  25. package/dist/services/audit-logger.js.map +1 -1
  26. package/dist/services/diagram-generator.d.ts +53 -4
  27. package/dist/services/diagram-generator.d.ts.map +1 -1
  28. package/dist/services/diagram-generator.js +565 -178
  29. package/dist/services/diagram-generator.js.map +1 -1
  30. package/dist/services/doc-generator.d.ts +44 -0
  31. package/dist/services/doc-generator.d.ts.map +1 -1
  32. package/dist/services/doc-generator.js +352 -50
  33. package/dist/services/doc-generator.js.map +1 -1
  34. package/dist/services/document-converter.d.ts +10 -7
  35. package/dist/services/document-converter.d.ts.map +1 -1
  36. package/dist/services/document-converter.js +113 -38
  37. package/dist/services/document-converter.js.map +1 -1
  38. package/dist/services/file-manager.d.ts.map +1 -1
  39. package/dist/services/file-manager.js +9 -3
  40. package/dist/services/file-manager.js.map +1 -1
  41. package/dist/services/iac-generator.d.ts +31 -3
  42. package/dist/services/iac-generator.d.ts.map +1 -1
  43. package/dist/services/iac-generator.js +509 -41
  44. package/dist/services/iac-generator.js.map +1 -1
  45. package/dist/services/pbt-generator.d.ts +12 -0
  46. package/dist/services/pbt-generator.d.ts.map +1 -1
  47. package/dist/services/pbt-generator.js +296 -145
  48. package/dist/services/pbt-generator.js.map +1 -1
  49. package/dist/services/state-machine.d.ts +15 -1
  50. package/dist/services/state-machine.d.ts.map +1 -1
  51. package/dist/services/state-machine.js +21 -3
  52. package/dist/services/state-machine.js.map +1 -1
  53. package/dist/services/test-generator.d.ts +8 -0
  54. package/dist/services/test-generator.d.ts.map +1 -1
  55. package/dist/services/test-generator.js +134 -33
  56. package/dist/services/test-generator.js.map +1 -1
  57. package/dist/services/test-traceability-mapper.d.ts.map +1 -1
  58. package/dist/services/test-traceability-mapper.js +8 -3
  59. package/dist/services/test-traceability-mapper.js.map +1 -1
  60. package/dist/services/work-item-exporter.d.ts +70 -9
  61. package/dist/services/work-item-exporter.d.ts.map +1 -1
  62. package/dist/services/work-item-exporter.js +115 -53
  63. package/dist/services/work-item-exporter.js.map +1 -1
  64. package/dist/templates/checklist.md +2 -3
  65. package/dist/templates/cross-analysis.md +2 -4
  66. package/dist/tools/audit.js +1 -1
  67. package/dist/tools/audit.js.map +1 -1
  68. package/dist/tools/documentation.d.ts.map +1 -1
  69. package/dist/tools/documentation.js +9 -3
  70. package/dist/tools/documentation.js.map +1 -1
  71. package/dist/tools/environment.d.ts.map +1 -1
  72. package/dist/tools/environment.js +75 -12
  73. package/dist/tools/environment.js.map +1 -1
  74. package/dist/tools/infrastructure.d.ts.map +1 -1
  75. package/dist/tools/infrastructure.js +7 -39
  76. package/dist/tools/infrastructure.js.map +1 -1
  77. package/dist/tools/input.js +4 -4
  78. package/dist/tools/input.js.map +1 -1
  79. package/dist/tools/integration.js +4 -4
  80. package/dist/tools/integration.js.map +1 -1
  81. package/dist/tools/pipeline.d.ts.map +1 -1
  82. package/dist/tools/pipeline.js +42 -4
  83. package/dist/tools/pipeline.js.map +1 -1
  84. package/dist/tools/quality.d.ts.map +1 -1
  85. package/dist/tools/quality.js +36 -9
  86. package/dist/tools/quality.js.map +1 -1
  87. package/dist/tools/rbac.d.ts.map +1 -1
  88. package/dist/tools/rbac.js +18 -11
  89. package/dist/tools/rbac.js.map +1 -1
  90. package/dist/tools/testing.d.ts +10 -0
  91. package/dist/tools/testing.d.ts.map +1 -1
  92. package/dist/tools/testing.js +66 -13
  93. package/dist/tools/testing.js.map +1 -1
  94. package/dist/tools/tool-enforcement.d.ts +15 -1
  95. package/dist/tools/tool-enforcement.d.ts.map +1 -1
  96. package/dist/tools/tool-enforcement.js +70 -9
  97. package/dist/tools/tool-enforcement.js.map +1 -1
  98. package/dist/tools/transcript.d.ts.map +1 -1
  99. package/dist/tools/transcript.js +66 -41
  100. package/dist/tools/transcript.js.map +1 -1
  101. package/dist/tools/utility.d.ts.map +1 -1
  102. package/dist/tools/utility.js +22 -12
  103. package/dist/tools/utility.js.map +1 -1
  104. package/dist/tools/visualization.d.ts +21 -0
  105. package/dist/tools/visualization.d.ts.map +1 -1
  106. package/dist/tools/visualization.js +130 -29
  107. package/dist/tools/visualization.js.map +1 -1
  108. package/dist/utils/token-table.d.ts +41 -0
  109. package/dist/utils/token-table.d.ts.map +1 -0
  110. package/dist/utils/token-table.js +103 -0
  111. package/dist/utils/token-table.js.map +1 -0
  112. package/package.json +1 -1
  113. package/templates/checklist.md +2 -3
  114. package/templates/cross-analysis.md +2 -4
@@ -1,7 +1,19 @@
1
1
  /**
2
2
  * DiagramGenerator — Deterministic Mermaid diagram generation from spec artifacts.
3
3
  * All generation is pure string manipulation — no AI calls.
4
+ *
5
+ * Invariants:
6
+ * - Every identifier position (node ids, entity names, class names, state ids,
7
+ * participants) contains only [A-Za-z0-9_]; human text lives in quoted labels.
8
+ * - No generator emits constant filler: output is derived from the artifact's
9
+ * requirements, sections, bullets, or (as a last resort) its title/words, so
10
+ * different inputs never produce byte-identical diagrams.
11
+ * - Numeric values (pie slices, gantt tasks) are real counts from the source,
12
+ * never fabricated percentages.
4
13
  */
14
+ import { currentDateString } from "../utils/runtime-context.js";
15
+ /** Document-plumbing headings that must never become components/entities/states. */
16
+ const STRUCTURAL_HEADING_REGEX = /^(table of contents|contents|toc|overview|introduction|revision history|document (?:control|history)|references|appendix(?:\s+\w+)?|glossary|summary|executive summary|purpose|scope|background|assumptions|out of scope|non-goals|approvals?|sign[- ]?offs?|change ?log|metadata|version history|status)$/i;
5
17
  export class DiagramGenerator {
6
18
  fileManager;
7
19
  constructor(fileManager) {
@@ -70,7 +82,9 @@ export class DiagramGenerator {
70
82
  return { type, title, source: "spec", mermaid_code };
71
83
  }
72
84
  /**
73
- * Generate ALL diagram types for a feature from its artifacts.
85
+ * Generate ALL diagram types for a feature from its artifacts and write the
86
+ * full set to DIAGRAMS.md in the feature directory (grouped by source),
87
+ * replacing any scaffold left by earlier pipeline steps.
74
88
  */
75
89
  async generateAllDiagrams(specDir, featureDir) {
76
90
  const diagrams = [];
@@ -85,8 +99,10 @@ export class DiagramGenerator {
85
99
  if (specContent) {
86
100
  diagrams.push({ ...this.generateDiagram(specContent, "flowchart", "Requirements Flow"), source: "spec" });
87
101
  diagrams.push({ ...this.generateDiagram(specContent, "pie", "Requirements Coverage"), source: "spec" });
102
+ diagrams.push({ ...this.generateDiagram(specContent, "state", "Requirement Lifecycle"), source: "spec" });
88
103
  }
89
104
  if (designContent) {
105
+ diagrams.push({ ...this.generateDiagram(designContent, "c4_context", "System Context"), source: "design" });
90
106
  diagrams.push({ ...this.generateDiagram(designContent, "c4_container", "Architecture"), source: "design" });
91
107
  diagrams.push({ ...this.generateDiagram(designContent, "sequence", "API Flow"), source: "design" });
92
108
  diagrams.push({ ...this.generateDiagram(designContent, "er", "Data Model"), source: "design" });
@@ -108,7 +124,16 @@ export class DiagramGenerator {
108
124
  diagrams.push({ ...this.generateDiagram(specContent, "activity", "Workflow Activities"), source: "spec" });
109
125
  }
110
126
  const featureNumber = featureDir.match(/(\d{3})/)?.[1] || "000";
111
- return { feature_number: featureNumber, diagrams, total_generated: diagrams.length };
127
+ let diagramsFile = null;
128
+ if (diagrams.length > 0) {
129
+ try {
130
+ diagramsFile = await this.fileManager.writeSpecFile(featureDir, "DIAGRAMS.md", this.buildDiagramsMarkdown(featureNumber, diagrams), true);
131
+ }
132
+ catch {
133
+ diagramsFile = null;
134
+ }
135
+ }
136
+ return { feature_number: featureNumber, diagrams, total_generated: diagrams.length, diagrams_file: diagramsFile };
112
137
  }
113
138
  /**
114
139
  * Generate user story flow diagram.
@@ -129,7 +154,7 @@ export class DiagramGenerator {
129
154
  generateFlowchart(content, title) {
130
155
  const items = this.extractListItems(content);
131
156
  if (items.length === 0)
132
- return `flowchart TD\n A[${this.sanitize(title)}]`;
157
+ return `flowchart TD\n A["${this.sanitize(title)}"]`;
133
158
  const nodes = items.slice(0, 12).map((item, i) => {
134
159
  const id = `N${i}`;
135
160
  const nextId = i < Math.min(items.length, 12) - 1 ? `N${i + 1}` : null;
@@ -139,65 +164,118 @@ export class DiagramGenerator {
139
164
  });
140
165
  return `flowchart TD\n${nodes.join("\n")}`;
141
166
  }
142
- generateSequence(content, _title) {
143
- const actors = this.extractActors(content);
144
- if (actors.length < 2)
145
- return `sequenceDiagram\n participant User\n participant System\n User->>System: Request\n System-->>User: Response`;
146
- const lines = [`sequenceDiagram`];
147
- for (const actor of actors.slice(0, 6)) {
148
- lines.push(` participant ${this.sanitize(actor)}`);
167
+ generateSequence(content, title) {
168
+ const lines = [`sequenceDiagram`, ` participant Client`, ` participant System`];
169
+ // Preferred: derive one request/response exchange per EARS requirement
170
+ // (trigger clause -> Client request, shall clause -> System response).
171
+ const exchanges = this.extractRequirementExchanges(content);
172
+ if (exchanges.length > 0) {
173
+ for (const exchange of exchanges.slice(0, 8)) {
174
+ lines.push(` Client->>System: ${this.messageText(exchange.trigger)}`);
175
+ lines.push(` System-->>Client: ${this.messageText(exchange.response)}`);
176
+ }
177
+ return lines.join("\n");
149
178
  }
150
- // Generate interactions from content patterns
151
- const interactions = this.extractInteractions(content);
152
- for (const interaction of interactions.slice(0, 10)) {
153
- lines.push(` ${this.sanitize(interaction.from)}->>${this.sanitize(interaction.to)}: ${this.sanitize(interaction.action.substring(0, 40))}`);
179
+ // Fallback: chart the artifact's own steps as alternating messages.
180
+ const items = this.extractListItems(content).slice(0, 6);
181
+ if (items.length > 0) {
182
+ items.forEach((item, i) => {
183
+ const arrow = i % 2 === 0 ? "Client->>System" : "System-->>Client";
184
+ lines.push(` ${arrow}: ${this.messageText(item)}`);
185
+ });
186
+ return lines.join("\n");
154
187
  }
188
+ lines.push(` Client->>System: ${this.messageText(`Request ${title}`)}`);
189
+ lines.push(` System-->>Client: Response`);
155
190
  return lines.join("\n");
156
191
  }
157
- generateERD(content, _title) {
192
+ generateERD(content, title) {
158
193
  const entities = this.extractEntities(content);
159
- if (entities.length === 0)
160
- return `erDiagram\n ENTITY {\n string id PK\n string name\n }`;
161
194
  const lines = [`erDiagram`];
195
+ if (entities.length === 0) {
196
+ const name = this.safeId(this.toPascalCase(title), "Entity").toUpperCase();
197
+ lines.push(` ${name} {`, ` string id PK`, ` string name`, ` }`);
198
+ return lines.join("\n");
199
+ }
200
+ const used = new Set();
201
+ const ids = [];
162
202
  for (const entity of entities.slice(0, 8)) {
163
- lines.push(` ${entity.name.toUpperCase().replace(/\s+/g, "_")} {`);
203
+ const id = this.uniqueId(this.safeId(entity.name, "ENTITY").toUpperCase(), used);
204
+ ids.push(id);
205
+ lines.push(` ${id} {`);
164
206
  lines.push(` string id PK`);
207
+ const attrUsed = new Set(["id"]);
165
208
  for (const attr of entity.attributes.slice(0, 6)) {
166
- lines.push(` string ${attr.replace(/\s+/g, "_").toLowerCase()}`);
209
+ const attrId = this.uniqueId(this.safeId(attr, "attr").toLowerCase(), attrUsed);
210
+ lines.push(` string ${attrId}`);
167
211
  }
168
212
  lines.push(` }`);
169
213
  }
170
214
  // Add relationships
171
- for (let i = 0; i < entities.length - 1 && i < 7; i++) {
172
- const from = entities[i].name.toUpperCase().replace(/\s+/g, "_");
173
- const to = entities[i + 1].name.toUpperCase().replace(/\s+/g, "_");
174
- lines.push(` ${from} ||--o{ ${to} : "has"`);
215
+ for (let i = 0; i < ids.length - 1 && i < 7; i++) {
216
+ lines.push(` ${ids[i]} ||--o{ ${ids[i + 1]} : "has"`);
175
217
  }
176
218
  return lines.join("\n");
177
219
  }
178
- generateClassDiagram(content, _title) {
179
- const interfaces = this.extractInterfaces(content);
180
- if (interfaces.length === 0)
181
- return `classDiagram\n class Service {\n +execute()\n }`;
220
+ generateClassDiagram(content, title) {
221
+ const classes = this.extractClassCandidates(content);
182
222
  const lines = [`classDiagram`];
183
- for (const iface of interfaces.slice(0, 8)) {
184
- lines.push(` class ${this.sanitize(iface.name)} {`);
185
- for (const method of iface.methods.slice(0, 5)) {
186
- lines.push(` +${this.sanitize(method)}()`);
223
+ if (classes.length === 0) {
224
+ lines.push(` class ${this.safeId(this.toPascalCase(title), "Feature")}`);
225
+ return lines.join("\n");
226
+ }
227
+ const used = new Set();
228
+ const ids = [];
229
+ for (const cls of classes.slice(0, 8)) {
230
+ const id = this.uniqueId(this.safeId(cls.name, "Class"), used);
231
+ ids.push(id);
232
+ if (cls.methods.length === 0) {
233
+ lines.push(` class ${id}`);
234
+ }
235
+ else {
236
+ lines.push(` class ${id} {`);
237
+ for (const method of cls.methods.slice(0, 5)) {
238
+ lines.push(` +${this.safeId(method, "method")}()`);
239
+ }
240
+ lines.push(` }`);
187
241
  }
188
- lines.push(` }`);
242
+ }
243
+ for (let i = 0; i < ids.length - 1 && i < 7; i++) {
244
+ lines.push(` ${ids[i]} ..> ${ids[i + 1]}`);
189
245
  }
190
246
  return lines.join("\n");
191
247
  }
192
- generateStateDiagram(content, _title) {
248
+ generateStateDiagram(content, title) {
249
+ const lines = [`stateDiagram-v2`];
250
+ // Preferred: real lifecycle words found in the artifact.
193
251
  const states = this.extractStates(content);
194
- if (states.length === 0)
195
- return `stateDiagram-v2\n [*] --> Active\n Active --> Completed\n Completed --> [*]`;
196
- const lines = [`stateDiagram-v2`, ` [*] --> ${this.sanitize(states[0])}`];
197
- for (let i = 0; i < states.length - 1; i++) {
198
- lines.push(` ${this.sanitize(states[i])} --> ${this.sanitize(states[i + 1])}`);
252
+ if (states.length >= 2) {
253
+ const used = new Set();
254
+ const ids = states.slice(0, 8).map((s) => this.uniqueId(this.safeId(s, "state"), used));
255
+ lines.push(` [*] --> ${ids[0]}`);
256
+ for (let i = 0; i < ids.length - 1; i++) {
257
+ lines.push(` ${ids[i]} --> ${ids[i + 1]}`);
258
+ }
259
+ lines.push(` ${ids[ids.length - 1]} --> [*]`);
260
+ return lines.join("\n");
261
+ }
262
+ // Fallback: the artifact's own section names become sequential stages.
263
+ const sections = this.extractSections(content).slice(0, 8);
264
+ if (sections.length > 0) {
265
+ const ids = sections.map((_, i) => `s${i}`);
266
+ sections.forEach((section, i) => {
267
+ lines.push(` state "${this.plainText(section.heading)}" as ${ids[i]}`);
268
+ });
269
+ lines.push(` [*] --> ${ids[0]}`);
270
+ for (let i = 0; i < ids.length - 1; i++) {
271
+ lines.push(` ${ids[i]} --> ${ids[i + 1]}`);
272
+ }
273
+ lines.push(` ${ids[ids.length - 1]} --> [*]`);
274
+ return lines.join("\n");
199
275
  }
200
- lines.push(` ${this.sanitize(states[states.length - 1])} --> [*]`);
276
+ // Last resort: a single state derived from the found word or the title.
277
+ const only = this.safeId(states[0] ?? this.toPascalCase(title), "Active");
278
+ lines.push(` [*] --> ${only}`, ` ${only} --> [*]`);
201
279
  return lines.join("\n");
202
280
  }
203
281
  generateC4Context(content, title) {
@@ -212,11 +290,12 @@ export class DiagramGenerator {
212
290
  lines.push(` Rel(user, system, "Uses")`);
213
291
  }
214
292
  else {
215
- for (const sys of systems.slice(0, 6)) {
216
- const id = sys.replace(/\s+/g, "_").toLowerCase();
217
- lines.push(` System(${id}, "${this.sanitize(sys)}", "${this.sanitize(sys)}")`);
218
- }
219
- lines.push(` Rel(user, ${systems[0].replace(/\s+/g, "_").toLowerCase()}, "Uses")`);
293
+ const used = new Set(["user"]);
294
+ const ids = systems.slice(0, 6).map((sys) => this.uniqueId(this.safeId(sys, "system").toLowerCase(), used));
295
+ systems.slice(0, 6).forEach((sys, i) => {
296
+ lines.push(` System(${ids[i]}, "${this.sanitize(sys)}", "${this.sanitize(sys)}")`);
297
+ });
298
+ lines.push(` Rel(user, ${ids[0]}, "Uses")`);
220
299
  }
221
300
  return lines.join("\n");
222
301
  }
@@ -228,54 +307,102 @@ export class DiagramGenerator {
228
307
  ` Person(user, "User", "End user")`,
229
308
  ` System_Boundary(system, "${this.sanitize(title)}") {`,
230
309
  ];
310
+ const used = new Set(["user", "system"]);
311
+ let ids;
231
312
  if (components.length === 0) {
313
+ ids = ["api"];
232
314
  lines.push(` Container(api, "API", "REST API")`);
233
315
  lines.push(` ContainerDb(db, "Database", "Data storage")`);
234
316
  }
235
317
  else {
236
- for (const comp of components.slice(0, 8)) {
237
- const id = comp.replace(/\s+/g, "_").toLowerCase();
238
- lines.push(` Container(${id}, "${this.sanitize(comp)}", "${this.sanitize(comp)}")`);
239
- }
318
+ ids = components.slice(0, 8).map((comp) => this.uniqueId(this.safeId(comp, "container").toLowerCase(), used));
319
+ components.slice(0, 8).forEach((comp, i) => {
320
+ lines.push(` Container(${ids[i]}, "${this.sanitize(comp)}", "${this.sanitize(comp)}")`);
321
+ });
240
322
  }
241
323
  lines.push(` }`);
242
- lines.push(` Rel(user, ${(components[0] || "api").replace(/\s+/g, "_").toLowerCase()}, "Uses")`);
324
+ lines.push(` Rel(user, ${ids[0]}, "Uses")`);
243
325
  return lines.join("\n");
244
326
  }
245
327
  generateGantt(content, title) {
246
- const tasks = this.extractTasks(content);
247
328
  const lines = [
248
329
  `gantt`,
249
- ` title ${this.sanitize(title)}`,
330
+ ` title ${this.plainText(title)}`,
250
331
  ` dateFormat YYYY-MM-DD`,
251
332
  ];
252
- let currentSection = "Setup";
253
333
  let taskCount = 0;
254
- for (const task of tasks.slice(0, 15)) {
255
- if (task.section && task.section !== currentSection) {
256
- currentSection = task.section;
257
- lines.push(` section ${this.sanitize(currentSection)}`);
334
+ const pushTask = (name, duration) => {
335
+ const id = `t${taskCount}`;
336
+ // Mermaid gantt metadata order is `id, start, duration`: the first task
337
+ // is anchored to the (deterministic) generation date, the rest chain
338
+ // with `after`.
339
+ const start = taskCount === 0 ? currentDateString() : `after t${taskCount - 1}`;
340
+ const label = this.plainText(name).substring(0, 40) || `Task ${taskCount + 1}`;
341
+ lines.push(` ${label} :${id}, ${start}, ${duration}`);
342
+ taskCount++;
343
+ };
344
+ // TASKS.md path: real checkbox tasks with their sections.
345
+ const tasks = this.extractTasks(content);
346
+ if (tasks.length > 0) {
347
+ let currentSection;
348
+ for (const task of tasks.slice(0, 15)) {
349
+ if (task.section && task.section !== currentSection) {
350
+ currentSection = task.section;
351
+ lines.push(` section ${this.plainText(currentSection).substring(0, 40)}`);
352
+ }
353
+ pushTask(task.name, task.parallel ? "1d" : "2d");
258
354
  }
259
- const id = `t${taskCount++}`;
260
- const duration = task.parallel ? "1d" : "2d";
261
- const dep = taskCount > 1 ? `, after t${taskCount - 2}` : "";
262
- lines.push(` ${this.sanitize(task.name.substring(0, 40))} :${id}, ${duration}${dep}`);
355
+ return lines.join("\n");
263
356
  }
357
+ // Spec/design/constitution path: chart the artifact's own sections so the
358
+ // gantt is never header-only.
359
+ const sections = this.extractSections(content).slice(0, 6);
360
+ if (sections.length > 0) {
361
+ for (const section of sections) {
362
+ lines.push(` section ${this.plainText(section.heading).substring(0, 40)}`);
363
+ const bullets = section.bullets.slice(0, 3);
364
+ if (bullets.length === 0) {
365
+ pushTask(section.heading, "2d");
366
+ }
367
+ else {
368
+ for (const bullet of bullets) {
369
+ pushTask(bullet, "1d");
370
+ }
371
+ }
372
+ }
373
+ return lines.join("\n");
374
+ }
375
+ lines.push(` section ${this.plainText(title).substring(0, 40) || "Plan"}`);
376
+ pushTask(`Draft ${title}`, "2d");
377
+ pushTask(`Review ${title}`, "1d");
264
378
  return lines.join("\n");
265
379
  }
266
380
  generatePie(content, title) {
267
- // Extract categories and counts from content
268
381
  const categories = this.extractCategories(content);
269
382
  const lines = [`pie title ${this.sanitize(title)}`];
270
- if (categories.length === 0) {
271
- lines.push(` "Functional" : 60`);
272
- lines.push(` "Non-Functional" : 25`);
273
- lines.push(` "Constraints" : 15`);
274
- }
275
- else {
383
+ if (categories.length > 0) {
276
384
  for (const cat of categories.slice(0, 8)) {
277
385
  lines.push(` "${this.sanitize(cat.name)}" : ${cat.count}`);
278
386
  }
387
+ return lines.join("\n");
388
+ }
389
+ // No categorizable content: chart REAL structural counts of the artifact —
390
+ // never fabricated percentages.
391
+ const sectionCount = (content.match(/^#{1,4}\s+.+$/gm) || []).length;
392
+ const bulletCount = (content.match(/^\s*[-*]\s+.+$/gm) || []).length;
393
+ const paragraphCount = content.split(/\n{2,}/).filter((p) => p.trim().length > 0).length;
394
+ const counts = [
395
+ { name: "Sections", count: sectionCount },
396
+ { name: "List Items", count: bulletCount },
397
+ { name: "Paragraphs", count: paragraphCount },
398
+ ].filter((c) => c.count > 0);
399
+ if (counts.length === 0) {
400
+ lines.push(` "Empty" : 1`);
401
+ }
402
+ else {
403
+ for (const c of counts) {
404
+ lines.push(` "${c.name}" : ${c.count}`);
405
+ }
279
406
  }
280
407
  return lines.join("\n");
281
408
  }
@@ -297,59 +424,66 @@ export class DiagramGenerator {
297
424
  ` title ${this.sanitize(title)}`,
298
425
  ` Container_Boundary(main, "Main System") {`,
299
426
  ];
427
+ const used = new Set(["main"]);
428
+ let ids;
300
429
  if (components.length === 0) {
430
+ ids = ["comp1", "comp2", "comp3"];
301
431
  lines.push(` Component(comp1, "Core Module", "Handles main logic")`);
302
432
  lines.push(` Component(comp2, "Data Access", "Database operations")`);
303
433
  lines.push(` Component(comp3, "API Layer", "External interface")`);
304
434
  }
305
435
  else {
306
- for (const comp of components.slice(0, 10)) {
307
- const id = comp.replace(/\s+/g, "_").toLowerCase().replace(/[^a-z0-9_]/g, "");
308
- lines.push(` Component(${id}, "${this.sanitize(comp)}", "${this.sanitize(comp)}")`);
309
- }
436
+ ids = components.slice(0, 10).map((comp) => this.uniqueId(this.safeId(comp, "comp").toLowerCase(), used));
437
+ components.slice(0, 10).forEach((comp, i) => {
438
+ lines.push(` Component(${ids[i]}, "${this.sanitize(comp)}", "${this.sanitize(comp)}")`);
439
+ });
310
440
  }
311
441
  lines.push(` }`);
312
442
  // Add relationships between adjacent components
313
- const ids = components.length > 0
314
- ? components.slice(0, 10).map(c => c.replace(/\s+/g, "_").toLowerCase().replace(/[^a-z0-9_]/g, ""))
315
- : ["comp1", "comp2", "comp3"];
316
443
  for (let i = 0; i < ids.length - 1; i++) {
317
444
  lines.push(` Rel(${ids[i]}, ${ids[i + 1]}, "Uses")`);
318
445
  }
319
446
  return lines.join("\n");
320
447
  }
321
- generateC4Code(content, _title) {
322
- const interfaces = this.extractInterfaces(content);
323
- if (interfaces.length === 0) {
324
- return `classDiagram\n class IService {\n <<interface>>\n +execute()\n +validate()\n }\n class ServiceImpl {\n +execute()\n +validate()\n }\n IService <|.. ServiceImpl`;
325
- }
448
+ generateC4Code(content, title) {
449
+ const classes = this.extractClassCandidates(content);
326
450
  const lines = [`classDiagram`];
327
- for (const iface of interfaces.slice(0, 10)) {
328
- lines.push(` class ${this.sanitize(iface.name)} {`);
329
- lines.push(` <<interface>>`);
330
- for (const method of iface.methods.slice(0, 6)) {
331
- lines.push(` +${this.sanitize(method)}()`);
451
+ if (classes.length === 0) {
452
+ lines.push(` class ${this.safeId(this.toPascalCase(title), "Feature")} {`, ` <<component>>`, ` }`);
453
+ return lines.join("\n");
454
+ }
455
+ const used = new Set();
456
+ const entries = [];
457
+ for (const cls of classes.slice(0, 10)) {
458
+ const id = this.uniqueId(this.safeId(cls.name, "Class"), used);
459
+ entries.push({ id, kind: cls.kind });
460
+ lines.push(` class ${id} {`);
461
+ lines.push(` <<${cls.kind === "interface" ? "interface" : "component"}>>`);
462
+ for (const method of cls.methods.slice(0, 6)) {
463
+ lines.push(` +${this.safeId(method, "method")}()`);
332
464
  }
333
465
  lines.push(` }`);
334
466
  }
335
- // Add implementation relationships
336
- for (let i = 1; i < interfaces.length && i < 10; i++) {
337
- lines.push(` ${this.sanitize(interfaces[0].name)} <|.. ${this.sanitize(interfaces[i].name)}`);
467
+ for (let i = 1; i < entries.length && i < 10; i++) {
468
+ const edge = entries[0].kind === "interface" ? "<|.." : "-->";
469
+ lines.push(` ${entries[0].id} ${edge} ${entries[i].id}`);
338
470
  }
339
471
  return lines.join("\n");
340
472
  }
341
473
  generateActivity(content, title) {
342
- const items = this.extractListItems(content);
343
- if (items.length === 0)
344
- return `flowchart TD\n Start([Start]) --> Action1[Process]\n Action1 --> End([End])`;
345
- const lines = [`flowchart TD`, ` Start([${this.sanitize(title)} - Start])`];
474
+ const listItems = this.extractListItems(content);
475
+ const items = listItems.length > 0 ? listItems : this.extractSections(content).map((s) => s.heading);
476
+ if (items.length === 0) {
477
+ return `flowchart TD\n Start(["${this.sanitize(title)} - Start"]) --> End([End])`;
478
+ }
479
+ const lines = [`flowchart TD`, ` Start(["${this.sanitize(title)} - Start"])`];
346
480
  let prevId = "Start";
347
481
  for (let i = 0; i < Math.min(items.length, 12); i++) {
348
482
  const item = items[i];
349
483
  const id = `A${i}`;
350
484
  // Detect decision points
351
485
  if (item.toLowerCase().includes("if ") || item.toLowerCase().includes("check") || item.toLowerCase().includes("validate")) {
352
- lines.push(` ${id}{${this.sanitize(item.substring(0, 50))}}`);
486
+ lines.push(` ${id}{"${this.sanitize(item.substring(0, 50))}"}`);
353
487
  lines.push(` ${prevId} --> ${id}`);
354
488
  if (i < items.length - 1) {
355
489
  lines.push(` ${id} -->|Yes| A${i + 1}`);
@@ -357,7 +491,7 @@ export class DiagramGenerator {
357
491
  }
358
492
  }
359
493
  else {
360
- lines.push(` ${id}[${this.sanitize(item.substring(0, 50))}]`);
494
+ lines.push(` ${id}["${this.sanitize(item.substring(0, 50))}"]`);
361
495
  lines.push(` ${prevId} --> ${id}`);
362
496
  }
363
497
  prevId = id;
@@ -367,24 +501,27 @@ export class DiagramGenerator {
367
501
  }
368
502
  generateUseCase(content, title) {
369
503
  const actors = this.extractActors(content);
370
- const items = this.extractListItems(content);
371
504
  const lines = [`flowchart LR`];
372
505
  // Create actor nodes
373
506
  const actorList = actors.length > 0 ? actors.slice(0, 4) : ["User"];
374
- for (const actor of actorList) {
375
- lines.push(` ${actor.replace(/\s+/g, "")}((${this.sanitize(actor)}))`);
376
- }
377
- // Create use case boundary
507
+ const used = new Set();
508
+ const actorIds = actorList.map((actor) => this.uniqueId(this.safeId(actor, "Actor"), used));
509
+ actorList.forEach((actor, i) => {
510
+ lines.push(` ${actorIds[i]}(("${this.sanitize(actor)}"))`);
511
+ });
512
+ // Create use case boundary — cases come from the artifact's list items,
513
+ // then its real section names; never from an invented canned list.
378
514
  lines.push(` subgraph "${this.sanitize(title)}"`);
379
- const useCases = items.length > 0 ? items.slice(0, 8) : ["Login", "View Dashboard", "Export Data"];
515
+ const items = this.extractListItems(content);
516
+ const derived = items.length > 0 ? items : this.extractSections(content).map((s) => s.heading);
517
+ const useCases = derived.length > 0 ? derived.slice(0, 8) : [title];
380
518
  for (let i = 0; i < useCases.length; i++) {
381
- lines.push(` UC${i}([${this.sanitize(useCases[i].substring(0, 40))}])`);
519
+ lines.push(` UC${i}(["${this.sanitize(useCases[i].substring(0, 40))}"])`);
382
520
  }
383
521
  lines.push(` end`);
384
522
  // Connect actors to use cases
385
- const primaryActor = actorList[0].replace(/\s+/g, "");
386
523
  for (let i = 0; i < Math.min(useCases.length, 8); i++) {
387
- lines.push(` ${primaryActor} --> UC${i}`);
524
+ lines.push(` ${actorIds[0]} --> UC${i}`);
388
525
  }
389
526
  return lines.join("\n");
390
527
  }
@@ -392,32 +529,32 @@ export class DiagramGenerator {
392
529
  const entities = this.extractEntities(content);
393
530
  const components = this.extractComponents(content);
394
531
  const lines = [`flowchart LR`];
532
+ const used = new Set(["DS1", "DS2"]);
395
533
  // External entities
396
- const externals = entities.length > 0 ? entities.slice(0, 3) : [{ name: "User", attributes: [] }];
397
- for (const ext of externals) {
398
- const id = ext.name.replace(/\s+/g, "_").toLowerCase();
399
- lines.push(` ${id}[/"${this.sanitize(ext.name)}"/]`);
400
- }
534
+ const externals = entities.length > 0 ? entities.slice(0, 3).map((e) => e.name) : ["User"];
535
+ const extIds = externals.map((name) => this.uniqueId(this.safeId(name, "ext").toLowerCase(), used));
536
+ externals.forEach((name, i) => {
537
+ lines.push(` ${extIds[i]}[/"${this.sanitize(name)}"/]`);
538
+ });
401
539
  // Processes
402
540
  const processes = components.length > 0 ? components.slice(0, 4) : ["Process Data"];
403
541
  for (let i = 0; i < processes.length; i++) {
404
- lines.push(` P${i}((${this.sanitize(processes[i].substring(0, 30))}))`);
542
+ lines.push(` P${i}(("${this.sanitize(processes[i].substring(0, 30))}"))`);
405
543
  }
406
544
  // Data stores
407
- lines.push(` DS1[(Database)]`);
408
- lines.push(` DS2[(Cache)]`);
545
+ lines.push(` DS1[("Database")]`);
546
+ lines.push(` DS2[("Cache")]`);
409
547
  // Connect: external -> process -> store
410
- const extId = externals[0].name.replace(/\s+/g, "_").toLowerCase();
411
548
  if (processes.length > 0) {
412
- lines.push(` ${extId} -->|Input| P0`);
549
+ lines.push(` ${extIds[0]} -->|Input| P0`);
413
550
  lines.push(` P0 -->|Store| DS1`);
414
551
  if (processes.length > 1) {
415
552
  lines.push(` P0 -->|Forward| P1`);
416
553
  lines.push(` P1 -->|Cache| DS2`);
417
- lines.push(` P1 -->|Response| ${extId}`);
554
+ lines.push(` P1 -->|Response| ${extIds[0]}`);
418
555
  }
419
556
  else {
420
- lines.push(` P0 -->|Response| ${extId}`);
557
+ lines.push(` P0 -->|Response| ${extIds[0]}`);
421
558
  }
422
559
  }
423
560
  return lines.join("\n");
@@ -431,13 +568,16 @@ export class DiagramGenerator {
431
568
  lines.push(` Internet --> LB`);
432
569
  // Application layer
433
570
  lines.push(` subgraph "Application Layer"`);
571
+ const used = new Set(["Internet", "LB", "DB", "Cache"]);
572
+ let appIds;
434
573
  if (components.length > 0) {
435
- for (const comp of components.slice(0, 4)) {
436
- const id = comp.replace(/\s+/g, "_").toLowerCase().replace(/[^a-z0-9_]/g, "");
437
- lines.push(` ${id}["${this.sanitize(comp)}"]`);
438
- }
574
+ appIds = components.slice(0, 4).map((comp) => this.uniqueId(this.safeId(comp, "app").toLowerCase(), used));
575
+ components.slice(0, 4).forEach((comp, i) => {
576
+ lines.push(` ${appIds[i]}["${this.sanitize(comp)}"]`);
577
+ });
439
578
  }
440
579
  else {
580
+ appIds = ["API", "Worker"];
441
581
  lines.push(` API["API Server"]`);
442
582
  lines.push(` Worker["Background Worker"]`);
443
583
  }
@@ -448,9 +588,6 @@ export class DiagramGenerator {
448
588
  lines.push(` Cache[(Redis Cache)]`);
449
589
  lines.push(` end`);
450
590
  // Connections
451
- const appIds = components.length > 0
452
- ? components.slice(0, 4).map(c => c.replace(/\s+/g, "_").toLowerCase().replace(/[^a-z0-9_]/g, ""))
453
- : ["API", "Worker"];
454
591
  lines.push(` LB --> ${appIds[0]}`);
455
592
  for (const id of appIds) {
456
593
  lines.push(` ${id} --> DB`);
@@ -473,13 +610,16 @@ export class DiagramGenerator {
473
610
  // Private zone
474
611
  lines.push(` subgraph "Private Zone"`);
475
612
  const components = this.extractComponents(content);
613
+ const used = new Set(["CDN", "WAF", "LB", "Gateway", "DB", "MQ"]);
614
+ let privateIds;
476
615
  if (components.length > 0) {
477
- for (const comp of components.slice(0, 4)) {
478
- const id = comp.replace(/\s+/g, "_").toLowerCase().replace(/[^a-z0-9_]/g, "");
479
- lines.push(` ${id}["${this.sanitize(comp)}"]`);
480
- }
616
+ privateIds = components.slice(0, 4).map((comp) => this.uniqueId(this.safeId(comp, "node").toLowerCase(), used));
617
+ components.slice(0, 4).forEach((comp, i) => {
618
+ lines.push(` ${privateIds[i]}["${this.sanitize(comp)}"]`);
619
+ });
481
620
  }
482
621
  else {
622
+ privateIds = ["AppServer", "WorkerNode"];
483
623
  lines.push(` AppServer["Application Server"]`);
484
624
  lines.push(` WorkerNode["Worker Node"]`);
485
625
  }
@@ -493,9 +633,6 @@ export class DiagramGenerator {
493
633
  lines.push(` CDN --> WAF`);
494
634
  lines.push(` WAF --> LB`);
495
635
  lines.push(` LB --> Gateway`);
496
- const privateIds = components.length > 0
497
- ? components.slice(0, 4).map(c => c.replace(/\s+/g, "_").toLowerCase().replace(/[^a-z0-9_]/g, ""))
498
- : ["AppServer", "WorkerNode"];
499
636
  lines.push(` Gateway --> ${privateIds[0]}`);
500
637
  for (const id of privateIds) {
501
638
  lines.push(` ${id} --> DB`);
@@ -509,67 +646,133 @@ export class DiagramGenerator {
509
646
  const regex = /^[-*]\s+(.+)$/gm;
510
647
  let match;
511
648
  while ((match = regex.exec(content)) !== null) {
512
- items.push(match[1].trim());
649
+ const raw = match[1].trim();
650
+ // Skip pure-link bullets (tables of contents) and unwrap inline links.
651
+ if (/^\[[^\]]*\]\([^)]*\)$/.test(raw))
652
+ continue;
653
+ const item = raw.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").trim();
654
+ if (item)
655
+ items.push(item);
513
656
  }
514
657
  return items;
515
658
  }
516
659
  extractActors(content) {
517
- const actors = new Set();
660
+ const actors = [];
661
+ const seen = new Set();
518
662
  // Look for common actor patterns: "User", "System", "Admin", service names
519
663
  const patterns = /\b(User|System|Admin|Client|Server|API|Database|Service|Gateway|Queue|Cache)\b/gi;
520
664
  let match;
521
665
  while ((match = patterns.exec(content)) !== null) {
522
- actors.add(match[1]);
666
+ const key = match[1].toLowerCase();
667
+ if (seen.has(key))
668
+ continue;
669
+ seen.add(key);
670
+ // Normalize casing so "service"/"Service" never become two actors
671
+ actors.push(match[1].charAt(0).toUpperCase() + match[1].slice(1));
523
672
  }
524
- return Array.from(actors);
673
+ return actors;
525
674
  }
526
- extractInteractions(content) {
527
- const interactions = [];
528
- // Look for "X sends/calls/returns to Y" patterns
529
- const regex = /(\w+)\s+(?:sends?|calls?|requests?|returns?|responds?)\s+(?:to\s+)?(\w+)/gi;
530
- let match;
531
- while ((match = regex.exec(content)) !== null) {
532
- interactions.push({ from: match[1], to: match[2], action: match[0] });
533
- }
534
- if (interactions.length === 0) {
535
- interactions.push({ from: "User", to: "System", action: "Request" });
536
- interactions.push({ from: "System", to: "User", action: "Response" });
675
+ /**
676
+ * Derive request/response exchanges from EARS requirement sentences.
677
+ * "When X, the system shall Y" -> Client request "X", System response "Y".
678
+ */
679
+ extractRequirementExchanges(content) {
680
+ const exchanges = [];
681
+ const sentences = content.split(/(?<=[.!?])\s+|\n+/);
682
+ for (const sentence of sentences) {
683
+ if (!/\bshall\b/i.test(sentence))
684
+ continue;
685
+ const conditional = sentence.match(/\b(?:when|while|where|if)\s+(.{3,80}?),?\s*(?:then\s+)?the\s+[\w -]{1,40}?\s+shall\s+(.{3,120})/i);
686
+ if (conditional) {
687
+ exchanges.push({
688
+ trigger: conditional[1].trim(),
689
+ response: conditional[2].replace(/[.\s]+$/, "").trim(),
690
+ });
691
+ continue;
692
+ }
693
+ const ubiquitous = sentence.match(/\bthe\s+[\w -]{1,40}?\s+shall\s+(.{3,120})/i);
694
+ if (ubiquitous) {
695
+ const action = ubiquitous[1].replace(/[.\s]+$/, "").trim();
696
+ exchanges.push({
697
+ trigger: `Request to ${action.split(/\s+/).slice(0, 5).join(" ")}`,
698
+ response: action,
699
+ });
700
+ }
537
701
  }
538
- return interactions;
702
+ return exchanges;
539
703
  }
540
704
  extractEntities(content) {
541
705
  const entities = [];
542
- // Look for entity-like headings followed by attribute lists
543
- const sections = content.split(/^#{2,3}\s+/m);
544
- for (const section of sections) {
545
- const lines = section.split("\n");
546
- const heading = lines[0]?.trim();
547
- if (!heading)
548
- continue;
549
- const attrs = [];
550
- for (const line of lines.slice(1)) {
551
- const attrMatch = line.match(/^[-*]\s+\*?\*?(\w[\w\s]*)\*?\*?/);
552
- if (attrMatch)
553
- attrs.push(attrMatch[1].trim());
554
- }
706
+ for (const section of this.extractSections(content)) {
707
+ const attrs = section.bullets
708
+ .map((bullet) => bullet.replace(/\*\*/g, "").split(/[:—–-]/)[0]?.trim() ?? "")
709
+ .filter((attr) => /^[A-Za-z]/.test(attr));
555
710
  if (attrs.length > 0) {
556
- entities.push({ name: heading, attributes: attrs });
711
+ entities.push({ name: section.heading, attributes: attrs });
557
712
  }
558
713
  }
559
714
  return entities;
560
715
  }
561
- extractInterfaces(content) {
562
- const interfaces = [];
563
- const regex = /(?:interface|class|service|module)\s+(\w+)/gi;
716
+ /**
717
+ * Class/interface candidates for class and c4_code diagrams.
718
+ * 1. Explicit `interface X { ... }` / `class X { ... }` declarations (methods
719
+ * read from the real body — never invented).
720
+ * 2. Real section names with methods derived from their bullet items.
721
+ * 3. Last resort: one class built from the artifact's opening words, so
722
+ * different artifacts never collapse to an identical stub.
723
+ */
724
+ extractClassCandidates(content) {
725
+ const classes = [];
726
+ const seen = new Set();
727
+ const declRegex = /\b(interface|class)\s+([A-Za-z_]\w*)(?:\s*\{([^}]*)\})?/g;
564
728
  let match;
565
- while ((match = regex.exec(content)) !== null) {
566
- interfaces.push({ name: match[1], methods: ["execute", "validate", "process"] });
729
+ while ((match = declRegex.exec(content)) !== null) {
730
+ const kind = match[1].toLowerCase() === "interface" ? "interface" : "class";
731
+ const name = match[2];
732
+ const key = name.toLowerCase();
733
+ if (seen.has(key))
734
+ continue;
735
+ seen.add(key);
736
+ const methods = [];
737
+ if (match[3]) {
738
+ const methodRegex = /([A-Za-z_]\w*)\s*\(/g;
739
+ let methodMatch;
740
+ while ((methodMatch = methodRegex.exec(match[3])) !== null) {
741
+ if (!methods.includes(methodMatch[1]))
742
+ methods.push(methodMatch[1]);
743
+ }
744
+ }
745
+ classes.push({ name, methods, kind });
746
+ }
747
+ if (classes.length > 0)
748
+ return classes;
749
+ for (const section of this.extractSections(content)) {
750
+ const name = this.toPascalCase(section.heading);
751
+ const key = name.toLowerCase();
752
+ if (!name || seen.has(key))
753
+ continue;
754
+ seen.add(key);
755
+ const methods = section.bullets
756
+ .map((bullet) => this.toCamelCase((bullet.split(/[.:—–]/)[0] ?? bullet).substring(0, 40)))
757
+ .filter((method) => method.length > 1)
758
+ .slice(0, 5);
759
+ classes.push({ name, methods, kind: "class" });
760
+ }
761
+ if (classes.length > 0)
762
+ return classes;
763
+ const words = content
764
+ .replace(/[^A-Za-z0-9\s]/g, " ")
765
+ .trim()
766
+ .split(/\s+/)
767
+ .filter((word) => word.length > 2);
768
+ if (words.length > 0) {
769
+ classes.push({ name: this.toPascalCase(words.slice(0, 3).join(" ")), methods: [], kind: "class" });
567
770
  }
568
- return interfaces;
771
+ return classes;
569
772
  }
570
773
  extractStates(content) {
571
774
  const states = [];
572
- const regex = /\b(pending|active|processing|completed|failed|cancelled|approved|rejected|draft|published|archived)\b/gi;
775
+ const regex = /\b(pending|active|processing|completed|failed|cancelled|approved|rejected|draft|published|archived|created|submitted|shipped|delivered|expired|suspended)\b/gi;
573
776
  const seen = new Set();
574
777
  let match;
575
778
  while ((match = regex.exec(content)) !== null) {
@@ -579,7 +782,7 @@ export class DiagramGenerator {
579
782
  states.push(state);
580
783
  }
581
784
  }
582
- return states.length > 0 ? states : ["Pending", "Active", "Completed"];
785
+ return states;
583
786
  }
584
787
  extractSystems(content) {
585
788
  const systems = [];
@@ -592,16 +795,27 @@ export class DiagramGenerator {
592
795
  }
593
796
  extractComponents(content) {
594
797
  const components = [];
595
- const regex = /^#{2,3}\s+(.+(?:Service|API|Gateway|Database|Cache|Queue|Worker|Engine|Manager|Controller))/gm;
798
+ const seen = new Set();
799
+ const push = (raw) => {
800
+ const name = this.cleanHeading(raw);
801
+ if (!name || this.isStructuralHeading(name))
802
+ return;
803
+ const key = name.toLowerCase();
804
+ if (seen.has(key))
805
+ return;
806
+ seen.add(key);
807
+ components.push(name);
808
+ };
809
+ const regex = /^#{2,3}\s+(.+(?:Service|API|Gateway|Database|Cache|Queue|Worker|Engine|Manager|Controller))\s*$/gm;
596
810
  let match;
597
811
  while ((match = regex.exec(content)) !== null) {
598
- components.push(match[1].trim());
812
+ push(match[1]);
599
813
  }
600
814
  if (components.length === 0) {
601
- // Fallback: extract from headings
815
+ // Fallback: extract from real (non-structural) headings
602
816
  const headingRegex = /^#{2,3}\s+(.+)/gm;
603
817
  while ((match = headingRegex.exec(content)) !== null) {
604
- components.push(match[1].trim());
818
+ push(match[1]);
605
819
  }
606
820
  }
607
821
  return components;
@@ -624,23 +838,77 @@ export class DiagramGenerator {
624
838
  }
625
839
  return tasks;
626
840
  }
841
+ /**
842
+ * Real category counts for pie charts, tried in order:
843
+ * 1. Explicit EARS pattern tags, e.g. "(EVENT_DRIVEN)".
844
+ * 2. Classification of "shall" requirement sentences by EARS keyword.
845
+ * 3. REQ-ID prefixes (### REQ-XXX-NNN).
846
+ * 4. Section names weighted by their real bullet counts.
847
+ */
627
848
  extractCategories(content) {
628
- const categories = new Map();
629
- const regex = /### (REQ-([A-Z]+)-\d{3})/g;
849
+ const tagNames = {
850
+ UBIQUITOUS: "Ubiquitous",
851
+ EVENT_DRIVEN: "Event-Driven",
852
+ STATE_DRIVEN: "State-Driven",
853
+ OPTIONAL: "Optional",
854
+ UNWANTED: "Unwanted Behavior",
855
+ UNWANTED_BEHAVIOR: "Unwanted Behavior",
856
+ COMPLEX: "Complex",
857
+ };
858
+ const tagCounts = new Map();
859
+ const tagRegex = /\b(UBIQUITOUS|EVENT[_ ]DRIVEN|STATE[_ ]DRIVEN|OPTIONAL|UNWANTED(?:[_ ]BEHAVIOR)?|COMPLEX)\b/g;
630
860
  let match;
631
- while ((match = regex.exec(content)) !== null) {
861
+ while ((match = tagRegex.exec(content)) !== null) {
862
+ const name = tagNames[match[1].replace(/\s+/g, "_")] ?? match[1];
863
+ tagCounts.set(name, (tagCounts.get(name) || 0) + 1);
864
+ }
865
+ if (tagCounts.size > 0) {
866
+ return Array.from(tagCounts.entries()).map(([name, count]) => ({ name, count }));
867
+ }
868
+ const earsCounts = new Map();
869
+ const sentences = content.split(/(?<=[.!?])\s+|\n+/);
870
+ for (const sentence of sentences) {
871
+ if (!/\bshall\b/i.test(sentence))
872
+ continue;
873
+ const trimmed = sentence.trim().toLowerCase();
874
+ let pattern = "Ubiquitous";
875
+ if (/^while\b/.test(trimmed))
876
+ pattern = "State-Driven";
877
+ else if (/^when\b/.test(trimmed))
878
+ pattern = "Event-Driven";
879
+ else if (/^where\b/.test(trimmed))
880
+ pattern = "Optional";
881
+ else if (/^if\b/.test(trimmed))
882
+ pattern = "Unwanted Behavior";
883
+ earsCounts.set(pattern, (earsCounts.get(pattern) || 0) + 1);
884
+ }
885
+ if (earsCounts.size > 0) {
886
+ return Array.from(earsCounts.entries()).map(([name, count]) => ({ name, count }));
887
+ }
888
+ const prefixCounts = new Map();
889
+ const reqRegex = /### (REQ-([A-Z]+)-\d{3})/g;
890
+ while ((match = reqRegex.exec(content)) !== null) {
632
891
  const cat = match[2];
633
- categories.set(cat, (categories.get(cat) || 0) + 1);
892
+ prefixCounts.set(cat, (prefixCounts.get(cat) || 0) + 1);
634
893
  }
635
- return Array.from(categories.entries()).map(([name, count]) => ({ name, count }));
894
+ if (prefixCounts.size > 0) {
895
+ return Array.from(prefixCounts.entries()).map(([name, count]) => ({ name, count }));
896
+ }
897
+ return this.extractSections(content)
898
+ .slice(0, 8)
899
+ .map((section) => ({ name: section.heading, count: Math.max(section.bullets.length, 1) }));
636
900
  }
637
901
  extractTopics(content) {
638
902
  const topics = [];
639
903
  const sections = content.split(/^##\s+/m).filter(Boolean);
640
904
  for (const section of sections) {
641
905
  const lines = section.split("\n");
642
- const name = lines[0]?.trim();
643
- if (!name)
906
+ const firstLine = lines[0]?.trim() ?? "";
907
+ // Skip the pre-heading chunk (document title/frontmatter before the first ##)
908
+ if (firstLine.startsWith("#"))
909
+ continue;
910
+ const name = this.cleanHeading(firstLine);
911
+ if (!name || this.isStructuralHeading(name))
644
912
  continue;
645
913
  const subtopics = [];
646
914
  for (const line of lines.slice(1)) {
@@ -652,9 +920,128 @@ export class DiagramGenerator {
652
920
  }
653
921
  return topics;
654
922
  }
923
+ /**
924
+ * Real (non-structural) sections with their bullet items — the shared
925
+ * source-derived skeleton used by fallback paths.
926
+ */
927
+ extractSections(content) {
928
+ const sections = [];
929
+ let current = null;
930
+ for (const line of content.split("\n")) {
931
+ const headingMatch = line.match(/^#{2,4}\s+(.+)/);
932
+ if (headingMatch) {
933
+ const heading = this.cleanHeading(headingMatch[1]);
934
+ if (heading && !this.isStructuralHeading(heading)) {
935
+ current = { heading, bullets: [] };
936
+ sections.push(current);
937
+ }
938
+ else {
939
+ current = null;
940
+ }
941
+ continue;
942
+ }
943
+ const bulletMatch = line.match(/^\s*[-*]\s+(.+)/);
944
+ if (bulletMatch && current) {
945
+ current.bullets.push(bulletMatch[1].trim());
946
+ }
947
+ }
948
+ return sections;
949
+ }
655
950
  // ─── Utility ───
656
951
  sanitize(text) {
657
- return text.replace(/["\[\]{}()<>|]/g, "").replace(/\n/g, " ").trim().substring(0, 80);
952
+ return text.replace(/["'`\[\]{}()<>|*]/g, "").replace(/\n/g, " ").trim().substring(0, 80);
953
+ }
954
+ /** Text safe outside quotes (gantt names/sections, message-free positions). */
955
+ plainText(text) {
956
+ return this.sanitize(text).replace(/[:;,#]/g, " ").replace(/\s{2,}/g, " ").trim();
957
+ }
958
+ /** Sequence message text — keeps it on one line and free of ':' markers. */
959
+ messageText(text) {
960
+ const cleaned = this.sanitize(text).replace(/[:;]/g, " ").replace(/\s{2,}/g, " ").trim().substring(0, 60);
961
+ return cleaned || "Message";
962
+ }
963
+ /** Identifier containing only [A-Za-z0-9_], never starting with a digit. */
964
+ safeId(text, fallback) {
965
+ const cleaned = text
966
+ .replace(/[^A-Za-z0-9_]+/g, "_")
967
+ .replace(/_{2,}/g, "_")
968
+ .replace(/^_+|_+$/g, "")
969
+ .substring(0, 40)
970
+ .replace(/_+$/g, "");
971
+ if (!cleaned)
972
+ return fallback;
973
+ return /^[0-9]/.test(cleaned) ? `N${cleaned}` : cleaned;
974
+ }
975
+ uniqueId(base, used) {
976
+ let id = base;
977
+ let suffix = 2;
978
+ while (used.has(id)) {
979
+ id = `${base}_${suffix}`;
980
+ suffix++;
981
+ }
982
+ used.add(id);
983
+ return id;
984
+ }
985
+ cleanHeading(raw) {
986
+ return raw
987
+ .replace(/^\d+(?:\.\d+)*[.)]?\s+/, "")
988
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
989
+ .replace(/[*_`]/g, "")
990
+ .trim();
991
+ }
992
+ toPascalCase(text) {
993
+ return text
994
+ .replace(/[^A-Za-z0-9]+/g, " ")
995
+ .trim()
996
+ .split(/\s+/)
997
+ .filter(Boolean)
998
+ .slice(0, 4)
999
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
1000
+ .join("")
1001
+ .substring(0, 40);
1002
+ }
1003
+ toCamelCase(text) {
1004
+ const pascal = this.toPascalCase(text);
1005
+ return pascal ? pascal.charAt(0).toLowerCase() + pascal.slice(1) : "";
1006
+ }
1007
+ isStructuralHeading(heading) {
1008
+ return STRUCTURAL_HEADING_REGEX.test(heading.trim());
1009
+ }
1010
+ buildDiagramsMarkdown(featureNumber, diagrams) {
1011
+ const sourceLabels = {
1012
+ constitution: "CONSTITUTION.md",
1013
+ spec: "SPECIFICATION.md",
1014
+ design: "DESIGN.md",
1015
+ tasks: "TASKS.md",
1016
+ };
1017
+ const grouped = new Map();
1018
+ for (const diagram of diagrams) {
1019
+ const group = grouped.get(diagram.source);
1020
+ if (group) {
1021
+ group.push(diagram);
1022
+ }
1023
+ else {
1024
+ grouped.set(diagram.source, [diagram]);
1025
+ }
1026
+ }
1027
+ const canonicalOrder = ["constitution", "spec", "design", "tasks"];
1028
+ const sources = [
1029
+ ...canonicalOrder.filter((source) => grouped.has(source)),
1030
+ ...Array.from(grouped.keys()).filter((source) => !canonicalOrder.includes(source)),
1031
+ ];
1032
+ const lines = [
1033
+ `# Diagrams — Feature ${featureNumber}`,
1034
+ "",
1035
+ `> Generated by \`sdd_generate_all_diagrams\` — ${diagrams.length} diagrams, grouped by source artifact.`,
1036
+ "",
1037
+ ];
1038
+ for (const source of sources) {
1039
+ lines.push(`## Source: ${sourceLabels[source] ?? source}`, "");
1040
+ for (const diagram of grouped.get(source) ?? []) {
1041
+ lines.push(`### ${diagram.title} (${diagram.type})`, "", "```mermaid", diagram.mermaid_code, "```", "");
1042
+ }
1043
+ }
1044
+ return lines.join("\n");
658
1045
  }
659
1046
  async safeRead(_specDir, featureDir, fileName) {
660
1047
  try {