specky-sdd 3.5.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.
- package/.apm/hooks/scripts/ears-validator.sh +56 -17
- package/.apm/prompts/specky-api.prompt.md +1 -1
- package/.apm/prompts/specky-research.prompt.md +2 -2
- package/.apm/skills/research-analyst/SKILL.md +2 -2
- package/.apm/skills/specky-onboarding/SKILL.md +6 -4
- package/CHANGELOG.md +36 -0
- package/README.md +16 -3
- package/apm.yml +1 -1
- package/config.yml +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -0
- package/dist/config.js.map +1 -1
- package/dist/services/diagram-generator.d.ts +53 -4
- package/dist/services/diagram-generator.d.ts.map +1 -1
- package/dist/services/diagram-generator.js +565 -178
- package/dist/services/diagram-generator.js.map +1 -1
- package/dist/services/doc-generator.d.ts +44 -0
- package/dist/services/doc-generator.d.ts.map +1 -1
- package/dist/services/doc-generator.js +352 -50
- package/dist/services/doc-generator.js.map +1 -1
- package/dist/services/document-converter.d.ts +10 -7
- package/dist/services/document-converter.d.ts.map +1 -1
- package/dist/services/document-converter.js +113 -38
- package/dist/services/document-converter.js.map +1 -1
- package/dist/services/iac-generator.d.ts +31 -3
- package/dist/services/iac-generator.d.ts.map +1 -1
- package/dist/services/iac-generator.js +509 -41
- package/dist/services/iac-generator.js.map +1 -1
- package/dist/services/pbt-generator.d.ts +12 -0
- package/dist/services/pbt-generator.d.ts.map +1 -1
- package/dist/services/pbt-generator.js +296 -145
- package/dist/services/pbt-generator.js.map +1 -1
- package/dist/services/state-machine.d.ts +15 -1
- package/dist/services/state-machine.d.ts.map +1 -1
- package/dist/services/state-machine.js +21 -3
- package/dist/services/state-machine.js.map +1 -1
- package/dist/services/test-generator.d.ts +8 -0
- package/dist/services/test-generator.d.ts.map +1 -1
- package/dist/services/test-generator.js +134 -33
- package/dist/services/test-generator.js.map +1 -1
- package/dist/services/test-traceability-mapper.d.ts.map +1 -1
- package/dist/services/test-traceability-mapper.js +8 -3
- package/dist/services/test-traceability-mapper.js.map +1 -1
- package/dist/services/work-item-exporter.d.ts +70 -9
- package/dist/services/work-item-exporter.d.ts.map +1 -1
- package/dist/services/work-item-exporter.js +115 -53
- package/dist/services/work-item-exporter.js.map +1 -1
- package/dist/templates/checklist.md +2 -3
- package/dist/templates/cross-analysis.md +2 -4
- package/dist/tools/documentation.d.ts.map +1 -1
- package/dist/tools/documentation.js +9 -3
- package/dist/tools/documentation.js.map +1 -1
- package/dist/tools/environment.d.ts.map +1 -1
- package/dist/tools/environment.js +75 -12
- package/dist/tools/environment.js.map +1 -1
- package/dist/tools/infrastructure.d.ts.map +1 -1
- package/dist/tools/infrastructure.js +7 -39
- package/dist/tools/infrastructure.js.map +1 -1
- package/dist/tools/input.js +4 -4
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/integration.js +4 -4
- package/dist/tools/integration.js.map +1 -1
- package/dist/tools/pipeline.d.ts.map +1 -1
- package/dist/tools/pipeline.js +42 -4
- package/dist/tools/pipeline.js.map +1 -1
- package/dist/tools/quality.d.ts.map +1 -1
- package/dist/tools/quality.js +36 -9
- package/dist/tools/quality.js.map +1 -1
- package/dist/tools/testing.d.ts +10 -0
- package/dist/tools/testing.d.ts.map +1 -1
- package/dist/tools/testing.js +66 -13
- package/dist/tools/testing.js.map +1 -1
- package/dist/tools/transcript.d.ts.map +1 -1
- package/dist/tools/transcript.js +66 -41
- package/dist/tools/transcript.js.map +1 -1
- package/dist/tools/utility.d.ts.map +1 -1
- package/dist/tools/utility.js +22 -12
- package/dist/tools/utility.js.map +1 -1
- package/dist/tools/visualization.d.ts +21 -0
- package/dist/tools/visualization.d.ts.map +1 -1
- package/dist/tools/visualization.js +130 -29
- package/dist/tools/visualization.js.map +1 -1
- package/package.json +1 -1
- package/templates/checklist.md +2 -3
- 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
|
-
|
|
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,
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
//
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 <
|
|
172
|
-
|
|
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,
|
|
179
|
-
const
|
|
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
|
-
|
|
184
|
-
lines.push(` class ${this.
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
lines.push(` Container(${
|
|
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, ${
|
|
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.
|
|
330
|
+
` title ${this.plainText(title)}`,
|
|
250
331
|
` dateFormat YYYY-MM-DD`,
|
|
251
332
|
];
|
|
252
|
-
let currentSection = "Setup";
|
|
253
333
|
let taskCount = 0;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
lines.push(` Component(${
|
|
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,
|
|
322
|
-
const
|
|
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
|
-
|
|
328
|
-
lines.push(` class ${this.
|
|
329
|
-
lines.
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
lines.push(` ${
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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(` ${
|
|
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)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
lines.push(` ${
|
|
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(` ${
|
|
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| ${
|
|
554
|
+
lines.push(` P1 -->|Response| ${extIds[0]}`);
|
|
418
555
|
}
|
|
419
556
|
else {
|
|
420
|
-
lines.push(` P0 -->|Response| ${
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
lines.push(` ${
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
lines.push(` ${
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
673
|
+
return actors;
|
|
525
674
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
702
|
+
return exchanges;
|
|
539
703
|
}
|
|
540
704
|
extractEntities(content) {
|
|
541
705
|
const entities = [];
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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 =
|
|
566
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
629
|
-
|
|
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 =
|
|
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
|
-
|
|
892
|
+
prefixCounts.set(cat, (prefixCounts.get(cat) || 0) + 1);
|
|
634
893
|
}
|
|
635
|
-
|
|
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
|
|
643
|
-
|
|
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(/["
|
|
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 {
|