speckeeper 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -21
- package/dist/cli.js +1735 -331
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +38 -70
- package/dist/index.js +55 -267
- package/dist/index.js.map +1 -1
- package/dist/templates/init/design/requirements.ts +6 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import chalk4 from 'chalk';
|
|
4
|
-
import { dirname, join,
|
|
4
|
+
import { dirname, join, resolve } from 'path';
|
|
5
5
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
|
|
6
6
|
import { parse } from 'yaml';
|
|
7
7
|
import 'crypto';
|
|
@@ -123,6 +123,10 @@ var RelationSchema = z.object({
|
|
|
123
123
|
z.array(RelationSchema).optional();
|
|
124
124
|
|
|
125
125
|
// src/core/model.ts
|
|
126
|
+
var specStore = /* @__PURE__ */ new Map();
|
|
127
|
+
function getSpecStore() {
|
|
128
|
+
return specStore;
|
|
129
|
+
}
|
|
126
130
|
var modelRegistry = /* @__PURE__ */ new Map();
|
|
127
131
|
function registerModel(model) {
|
|
128
132
|
modelRegistry.set(model.id, model);
|
|
@@ -130,6 +134,24 @@ function registerModel(model) {
|
|
|
130
134
|
function getAllModels() {
|
|
131
135
|
return Array.from(modelRegistry.values());
|
|
132
136
|
}
|
|
137
|
+
function registerModelsFromConfig(models) {
|
|
138
|
+
for (const model of models) {
|
|
139
|
+
if (model && typeof model === "object" && "id" in model && "schema" in model) {
|
|
140
|
+
registerModel(model);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function getSpecs(modelId) {
|
|
145
|
+
const map = specStore.get(modelId);
|
|
146
|
+
if (!map) return [];
|
|
147
|
+
return Array.from(map.values());
|
|
148
|
+
}
|
|
149
|
+
function findModelTypeBySpecId(specId) {
|
|
150
|
+
for (const [modelId, map] of specStore) {
|
|
151
|
+
if (map.has(specId)) return modelId;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
133
155
|
|
|
134
156
|
// src/cli/build.ts
|
|
135
157
|
async function buildCommand(options) {
|
|
@@ -142,12 +164,15 @@ async function buildCommand(options) {
|
|
|
142
164
|
console.log(chalk4.gray(` Specs: ${config.specsDir}/`));
|
|
143
165
|
console.log("");
|
|
144
166
|
try {
|
|
167
|
+
console.log(chalk4.blue(" Loading models..."));
|
|
168
|
+
registerModelsFromConfig(config.models || []);
|
|
169
|
+
console.log("");
|
|
145
170
|
ensureDir(join(cwd, config.docsDir));
|
|
146
171
|
ensureDir(join(cwd, config.specsDir));
|
|
147
172
|
const files = [];
|
|
148
173
|
const models = getAllModels();
|
|
149
174
|
if (models.length === 0) {
|
|
150
|
-
console.log(chalk4.yellow(" No models registered.
|
|
175
|
+
console.log(chalk4.yellow(" No models registered. Add models to speckeeper.config.ts."));
|
|
151
176
|
return;
|
|
152
177
|
}
|
|
153
178
|
console.log(chalk4.blue(` Processing ${models.length} model types...`));
|
|
@@ -159,7 +184,7 @@ async function buildCommand(options) {
|
|
|
159
184
|
}
|
|
160
185
|
continue;
|
|
161
186
|
}
|
|
162
|
-
const specs =
|
|
187
|
+
const specs = getSpecs(model.id);
|
|
163
188
|
if (specs.length === 0) {
|
|
164
189
|
if (options.verbose) {
|
|
165
190
|
console.log(chalk4.gray(` ${model.name}: no specs found`));
|
|
@@ -206,244 +231,9 @@ async function buildCommand(options) {
|
|
|
206
231
|
process.exit(1);
|
|
207
232
|
}
|
|
208
233
|
}
|
|
209
|
-
function createEmptyRegistry() {
|
|
210
|
-
return {
|
|
211
|
-
requirements: /* @__PURE__ */ new Map(),
|
|
212
|
-
useCases: /* @__PURE__ */ new Map(),
|
|
213
|
-
actors: /* @__PURE__ */ new Map(),
|
|
214
|
-
components: /* @__PURE__ */ new Map(),
|
|
215
|
-
entities: /* @__PURE__ */ new Map(),
|
|
216
|
-
relations: /* @__PURE__ */ new Map(),
|
|
217
|
-
rules: /* @__PURE__ */ new Map(),
|
|
218
|
-
screens: /* @__PURE__ */ new Map(),
|
|
219
|
-
transitions: /* @__PURE__ */ new Map(),
|
|
220
|
-
forms: /* @__PURE__ */ new Map(),
|
|
221
|
-
processFlows: /* @__PURE__ */ new Map(),
|
|
222
|
-
glossaryTerms: /* @__PURE__ */ new Map(),
|
|
223
|
-
apiRefs: /* @__PURE__ */ new Map(),
|
|
224
|
-
tableRefs: /* @__PURE__ */ new Map(),
|
|
225
|
-
testRefs: /* @__PURE__ */ new Map(),
|
|
226
|
-
layers: /* @__PURE__ */ new Map(),
|
|
227
|
-
boundaries: /* @__PURE__ */ new Map(),
|
|
228
|
-
artifacts: /* @__PURE__ */ new Map()
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
var globalRegistry = createEmptyRegistry();
|
|
232
|
-
function getGlobalRegistry() {
|
|
233
|
-
return globalRegistry;
|
|
234
|
-
}
|
|
235
|
-
function resetGlobalRegistry() {
|
|
236
|
-
globalRegistry = createEmptyRegistry();
|
|
237
|
-
}
|
|
238
|
-
function isModelInstance(value) {
|
|
239
|
-
if (!value || typeof value !== "object") return false;
|
|
240
|
-
const obj = value;
|
|
241
|
-
return typeof obj.id === "string" && typeof obj.name === "string" && typeof obj.idPrefix === "string" && obj.schema !== void 0 && typeof obj.validate === "function" && typeof obj.lint === "function";
|
|
242
|
-
}
|
|
243
|
-
function registerExportedModels(module) {
|
|
244
|
-
for (const [exportName, exportValue] of Object.entries(module)) {
|
|
245
|
-
if (!exportValue || typeof exportValue !== "object") continue;
|
|
246
|
-
if (isModelInstance(exportValue)) {
|
|
247
|
-
registerModel(exportValue);
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
if (Array.isArray(exportValue)) {
|
|
251
|
-
registerArrayItems(exportName, exportValue);
|
|
252
|
-
continue;
|
|
253
|
-
}
|
|
254
|
-
const value = exportValue;
|
|
255
|
-
if (value.components && value.layers && value.boundaries && value.relations) {
|
|
256
|
-
const arch = value;
|
|
257
|
-
for (const comp of arch.components) {
|
|
258
|
-
if (comp.id) globalRegistry.components.set(comp.id, comp);
|
|
259
|
-
}
|
|
260
|
-
for (const layer of arch.layers) {
|
|
261
|
-
if (layer.id) globalRegistry.layers.set(layer.id, layer);
|
|
262
|
-
}
|
|
263
|
-
for (const boundary of arch.boundaries) {
|
|
264
|
-
if (boundary.id) globalRegistry.boundaries.set(boundary.id, boundary);
|
|
265
|
-
}
|
|
266
|
-
for (const rel of arch.relations) {
|
|
267
|
-
if (rel.id) globalRegistry.relations.set(rel.id, rel);
|
|
268
|
-
}
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
if (value.entities && value.relations && Array.isArray(value.entities)) {
|
|
272
|
-
const concept = value;
|
|
273
|
-
for (const entity of concept.entities) {
|
|
274
|
-
if (entity.id) globalRegistry.entities.set(entity.id, entity);
|
|
275
|
-
}
|
|
276
|
-
for (const rel of concept.relations) {
|
|
277
|
-
if (rel.id) globalRegistry.relations.set(rel.id, rel);
|
|
278
|
-
}
|
|
279
|
-
for (const rule of concept.rules || []) {
|
|
280
|
-
if (rule.id) globalRegistry.rules.set(rule.id, rule);
|
|
281
|
-
}
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
if (value.useCases && value.actors) {
|
|
285
|
-
const ucModel = value;
|
|
286
|
-
for (const uc of ucModel.useCases) {
|
|
287
|
-
if (uc.id) globalRegistry.useCases.set(uc.id, uc);
|
|
288
|
-
}
|
|
289
|
-
for (const actor of ucModel.actors) {
|
|
290
|
-
if (actor.id) globalRegistry.actors.set(actor.id, actor);
|
|
291
|
-
}
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
if (value.terms && Array.isArray(value.terms)) {
|
|
295
|
-
const glossary = value;
|
|
296
|
-
for (const term of glossary.terms) {
|
|
297
|
-
if (term.id) globalRegistry.glossaryTerms.set(term.id, term);
|
|
298
|
-
}
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
if (value.id && value.title && (value.kind || value.type || value.acceptance)) {
|
|
302
|
-
globalRegistry.requirements.set(value.id, value);
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
if (value.id && value.name && value.attributes && Array.isArray(value.attributes)) {
|
|
306
|
-
globalRegistry.entities.set(value.id, value);
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
if (value.id && value.name && value.actor && value.mainFlow) {
|
|
310
|
-
globalRegistry.useCases.set(value.id, value);
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
if (value.id && value.name && value.type && ["human", "system", "external"].includes(value.type)) {
|
|
314
|
-
globalRegistry.actors.set(value.id, value);
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
if (value.id && (value.term || value.abbreviation) && value.definition) {
|
|
318
|
-
globalRegistry.glossaryTerms.set(value.id, value);
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
function registerArrayItems(exportName, items) {
|
|
324
|
-
for (const item of items) {
|
|
325
|
-
if (!item || typeof item !== "object") continue;
|
|
326
|
-
const obj = item;
|
|
327
|
-
if (!obj.id || typeof obj.id !== "string") continue;
|
|
328
|
-
if (exportName.toLowerCase().includes("requirement") || exportName.toLowerCase().includes("constraints") || obj.type && obj.acceptanceCriteria) {
|
|
329
|
-
globalRegistry.requirements.set(obj.id, item);
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
if (exportName.toLowerCase().includes("usecase") || obj.actor && obj.mainFlow) {
|
|
333
|
-
globalRegistry.useCases.set(obj.id, item);
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
if (exportName.toLowerCase().includes("actor") || obj.type && ["human", "system", "external"].includes(obj.type) && !obj.mainFlow) {
|
|
337
|
-
globalRegistry.actors.set(obj.id, item);
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
if (exportName.toLowerCase().includes("component") || exportName.toLowerCase().includes("container") || obj.type && ["person", "system", "container", "component"].includes(obj.type)) {
|
|
341
|
-
globalRegistry.components.set(obj.id, item);
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
if (exportName.toLowerCase().includes("entit") || obj.name && obj.attributes && Array.isArray(obj.attributes)) {
|
|
345
|
-
globalRegistry.entities.set(obj.id, item);
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
if (exportName.toLowerCase().includes("term") || exportName.toLowerCase().includes("acronym") || exportName.toLowerCase().includes("glossary") || obj.term && obj.definition) {
|
|
349
|
-
globalRegistry.glossaryTerms.set(obj.id, item);
|
|
350
|
-
continue;
|
|
351
|
-
}
|
|
352
|
-
if (exportName.toLowerCase().includes("relation") || obj.from && obj.to && obj.label) {
|
|
353
|
-
globalRegistry.relations.set(obj.id, item);
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
if (exportName.toLowerCase().includes("layer") || obj.name && typeof obj.order === "number") {
|
|
357
|
-
globalRegistry.layers.set(obj.id, item);
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
if (exportName.toLowerCase().includes("boundar") || obj.type && ["system", "context", "container", "deployment"].includes(obj.type) && obj.description) {
|
|
361
|
-
globalRegistry.boundaries.set(obj.id, item);
|
|
362
|
-
continue;
|
|
363
|
-
}
|
|
364
|
-
if (exportName.toLowerCase().includes("screen") || obj.type && ["page", "modal", "drawer", "panel", "wizard"].includes(obj.type) && obj.auth) {
|
|
365
|
-
globalRegistry.screens.set(obj.id, item);
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
if (exportName.toLowerCase().includes("testref") || exportName.toLowerCase().includes("test-ref") || obj.source && obj.verifiesRequirements && Array.isArray(obj.verifiesRequirements)) {
|
|
369
|
-
globalRegistry.testRefs.set(obj.id, item);
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
if (exportName.toLowerCase().includes("artifact") || obj.category && obj.location && obj.purpose && ["ssot", "human-readable", "machine-readable", "implementation"].includes(obj.category)) {
|
|
373
|
-
globalRegistry.artifacts.set(obj.id, item);
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
async function loadModelsFromDirectory(dirPath, options = {}) {
|
|
379
|
-
const loadedFiles = [];
|
|
380
|
-
if (!existsSync(dirPath)) {
|
|
381
|
-
return loadedFiles;
|
|
382
|
-
}
|
|
383
|
-
const entries = readdirSync(dirPath);
|
|
384
|
-
for (const entry of entries) {
|
|
385
|
-
const fullPath = join(dirPath, entry);
|
|
386
|
-
const stat = statSync(fullPath);
|
|
387
|
-
if (stat.isDirectory() && options.recursive) {
|
|
388
|
-
const subFiles = await loadModelsFromDirectory(fullPath, options);
|
|
389
|
-
loadedFiles.push(...subFiles);
|
|
390
|
-
} else if (stat.isFile()) {
|
|
391
|
-
const ext = extname(entry);
|
|
392
|
-
if ([".ts", ".js", ".mts", ".mjs"].includes(ext) && !entry.endsWith(".d.ts")) {
|
|
393
|
-
if (basename(entry, ext) === "index") {
|
|
394
|
-
continue;
|
|
395
|
-
}
|
|
396
|
-
try {
|
|
397
|
-
const module = await import(fullPath);
|
|
398
|
-
loadedFiles.push(fullPath);
|
|
399
|
-
registerExportedModels(module);
|
|
400
|
-
} catch (error) {
|
|
401
|
-
console.error(`Failed to load model from ${fullPath}:`, error);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
return loadedFiles;
|
|
407
|
-
}
|
|
408
|
-
async function loadAllModels(config, cwd = process.cwd()) {
|
|
409
|
-
resetGlobalRegistry();
|
|
410
|
-
const loadedFiles = [];
|
|
411
|
-
const errors = [];
|
|
412
|
-
const designDir = resolve(cwd, config.designDir || "design");
|
|
413
|
-
const modelDirs = [
|
|
414
|
-
{ path: join(designDir, "requirements") },
|
|
415
|
-
{ path: join(designDir, "usecases") },
|
|
416
|
-
{ path: join(designDir, "architecture") },
|
|
417
|
-
{ path: join(designDir, "data-model") },
|
|
418
|
-
{ path: join(designDir, "screens") },
|
|
419
|
-
{ path: join(designDir, "flows") },
|
|
420
|
-
{ path: join(designDir, "glossary") },
|
|
421
|
-
// Also load files directly under design/ by default (index.ts, etc.)
|
|
422
|
-
{ path: designDir }
|
|
423
|
-
];
|
|
424
|
-
for (const { path: dirPath } of modelDirs) {
|
|
425
|
-
if (!existsSync(dirPath)) {
|
|
426
|
-
continue;
|
|
427
|
-
}
|
|
428
|
-
try {
|
|
429
|
-
const files = await loadModelsFromDirectory(dirPath, { recursive: true });
|
|
430
|
-
loadedFiles.push(...files);
|
|
431
|
-
} catch (error) {
|
|
432
|
-
errors.push({ file: dirPath, error });
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
return {
|
|
436
|
-
registry: getGlobalRegistry(),
|
|
437
|
-
loadedFiles,
|
|
438
|
-
errors
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// src/cli/lint.ts
|
|
443
234
|
async function lintCommand(options) {
|
|
444
235
|
console.log(chalk4.blue("speckeeper lint"));
|
|
445
236
|
console.log("");
|
|
446
|
-
const cwd = process.cwd();
|
|
447
237
|
const config = await loadConfig(options.config);
|
|
448
238
|
console.log(chalk4.gray(` Design: ${config.designDir || "design"}/`));
|
|
449
239
|
if (options.phase) {
|
|
@@ -452,17 +242,12 @@ async function lintCommand(options) {
|
|
|
452
242
|
console.log("");
|
|
453
243
|
try {
|
|
454
244
|
console.log(chalk4.blue(" Loading models..."));
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
for (const { file, error } of loadErrors) {
|
|
459
|
-
console.log(chalk4.yellow(` - ${relative(cwd, file)}: ${error}`));
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
console.log(chalk4.gray(` Loaded: ${loadedFiles.length} files`));
|
|
245
|
+
registerModelsFromConfig(config.models || []);
|
|
246
|
+
const models = getAllModels();
|
|
247
|
+
console.log(chalk4.gray(` Loaded: ${models.length} models`));
|
|
463
248
|
console.log("");
|
|
464
249
|
console.log(chalk4.blue(" Running lint checks..."));
|
|
465
|
-
const result = runModelLint(
|
|
250
|
+
const result = runModelLint(options);
|
|
466
251
|
console.log("");
|
|
467
252
|
outputLintResults(result, options);
|
|
468
253
|
if (result.errors > 0) {
|
|
@@ -473,11 +258,11 @@ async function lintCommand(options) {
|
|
|
473
258
|
process.exit(1);
|
|
474
259
|
}
|
|
475
260
|
}
|
|
476
|
-
function runModelLint(
|
|
261
|
+
function runModelLint(options) {
|
|
477
262
|
const issues = [];
|
|
478
263
|
const models = getAllModels();
|
|
479
264
|
for (const model of models) {
|
|
480
|
-
const specs =
|
|
265
|
+
const specs = getSpecs(model.id);
|
|
481
266
|
if (specs.length === 0) continue;
|
|
482
267
|
const lintResults = model.lintAll(specs);
|
|
483
268
|
for (const result of lintResults) {
|
|
@@ -503,24 +288,6 @@ function runModelLint(registry, options) {
|
|
|
503
288
|
}
|
|
504
289
|
return { issues, errors, warnings, infos };
|
|
505
290
|
}
|
|
506
|
-
function getSpecsFromRegistry(registry, modelId) {
|
|
507
|
-
const registryMap = {
|
|
508
|
-
"requirement": "requirements",
|
|
509
|
-
"usecase": "useCases",
|
|
510
|
-
"actor": "actors",
|
|
511
|
-
"term": "glossaryTerms",
|
|
512
|
-
"entity": "entities",
|
|
513
|
-
"screen": "screens",
|
|
514
|
-
"process-flow": "processFlows",
|
|
515
|
-
"component": "components",
|
|
516
|
-
"boundary": "boundaries",
|
|
517
|
-
"layer": "layers",
|
|
518
|
-
"relation": "relations"
|
|
519
|
-
};
|
|
520
|
-
const key = registryMap[modelId];
|
|
521
|
-
if (!key || !registry[key]) return [];
|
|
522
|
-
return Array.from(registry[key].values());
|
|
523
|
-
}
|
|
524
291
|
function outputLintResults(result, options) {
|
|
525
292
|
if (options.format === "json") {
|
|
526
293
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -557,11 +324,11 @@ async function driftCommand(options) {
|
|
|
557
324
|
console.log("");
|
|
558
325
|
try {
|
|
559
326
|
console.log(chalk4.blue(" Loading models..."));
|
|
560
|
-
|
|
327
|
+
registerModelsFromConfig(config.models || []);
|
|
561
328
|
const results = [];
|
|
562
329
|
const models = getAllModels();
|
|
563
330
|
for (const model of models) {
|
|
564
|
-
const specs =
|
|
331
|
+
const specs = getSpecs(model.id);
|
|
565
332
|
if (specs.length === 0) continue;
|
|
566
333
|
for (const exporter of model.getExporters()) {
|
|
567
334
|
if (exporter.format !== "markdown") continue;
|
|
@@ -609,21 +376,6 @@ async function driftCommand(options) {
|
|
|
609
376
|
process.exit(1);
|
|
610
377
|
}
|
|
611
378
|
}
|
|
612
|
-
function getSpecsFromRegistry2(registry, modelId) {
|
|
613
|
-
const registryMap = {
|
|
614
|
-
"requirement": "requirements",
|
|
615
|
-
"usecase": "useCases",
|
|
616
|
-
"actor": "actors",
|
|
617
|
-
"term": "glossaryTerms",
|
|
618
|
-
"entity": "entities",
|
|
619
|
-
"screen": "screens",
|
|
620
|
-
"process-flow": "processFlows",
|
|
621
|
-
"component": "components"
|
|
622
|
-
};
|
|
623
|
-
const key = registryMap[modelId];
|
|
624
|
-
if (!key || !registry[key]) return [];
|
|
625
|
-
return Array.from(registry[key].values());
|
|
626
|
-
}
|
|
627
379
|
function normalizeContent(content) {
|
|
628
380
|
return content.trim().replace(/\r\n/g, "\n");
|
|
629
381
|
}
|
|
@@ -663,14 +415,14 @@ async function checkCommand(type, options) {
|
|
|
663
415
|
console.log("");
|
|
664
416
|
try {
|
|
665
417
|
console.log(chalk4.blue(" Loading models..."));
|
|
666
|
-
|
|
418
|
+
registerModelsFromConfig(config.models || []);
|
|
667
419
|
const results = [];
|
|
668
420
|
const models = getAllModels();
|
|
669
421
|
if (options.verbose) {
|
|
670
422
|
console.log(chalk4.gray(` Registered models: ${models.map((m) => m.id).join(", ")}`));
|
|
671
423
|
}
|
|
672
424
|
for (const model of models) {
|
|
673
|
-
const specs =
|
|
425
|
+
const specs = getSpecs(model.id);
|
|
674
426
|
if (specs.length === 0) continue;
|
|
675
427
|
for (const spec of specs) {
|
|
676
428
|
const sourcePath = model.getExternalSourcePath(spec);
|
|
@@ -706,7 +458,7 @@ async function checkCommand(type, options) {
|
|
|
706
458
|
if (options.coverage) {
|
|
707
459
|
console.log("");
|
|
708
460
|
console.log(chalk4.blue(" Coverage checks..."));
|
|
709
|
-
const coverageResults = runAllCoverageChecks(
|
|
461
|
+
const coverageResults = runAllCoverageChecks();
|
|
710
462
|
if (coverageResults.length > 0) {
|
|
711
463
|
outputAllCoverageResults(coverageResults);
|
|
712
464
|
for (const check of coverageResults) {
|
|
@@ -745,25 +497,6 @@ async function checkCommand(type, options) {
|
|
|
745
497
|
process.exit(1);
|
|
746
498
|
}
|
|
747
499
|
}
|
|
748
|
-
function getSpecsFromRegistry3(registry, modelId) {
|
|
749
|
-
const registryMap = {
|
|
750
|
-
"requirement": "requirements",
|
|
751
|
-
"usecase": "useCases",
|
|
752
|
-
"actor": "actors",
|
|
753
|
-
"term": "glossaryTerms",
|
|
754
|
-
"entity": "entities",
|
|
755
|
-
"screen": "screens",
|
|
756
|
-
"process-flow": "processFlows",
|
|
757
|
-
"component": "components",
|
|
758
|
-
"api-ref": "apiRefs",
|
|
759
|
-
"table-ref": "tableRefs",
|
|
760
|
-
"test-ref": "testRefs",
|
|
761
|
-
"artifact": "artifacts"
|
|
762
|
-
};
|
|
763
|
-
const key = registryMap[modelId];
|
|
764
|
-
if (!key || !registry[key]) return [];
|
|
765
|
-
return Array.from(registry[key].values());
|
|
766
|
-
}
|
|
767
500
|
function loadExternalData(filePath) {
|
|
768
501
|
const content = readFileSync(filePath, "utf-8");
|
|
769
502
|
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
|
|
@@ -795,20 +528,15 @@ function outputCheckResults(results) {
|
|
|
795
528
|
console.log("");
|
|
796
529
|
console.log(chalk4.gray(` Summary: ${totalErrors} errors, ${totalWarnings} warnings`));
|
|
797
530
|
}
|
|
798
|
-
function runAllCoverageChecks(
|
|
531
|
+
function runAllCoverageChecks() {
|
|
799
532
|
const models = getAllModels();
|
|
800
533
|
const results = [];
|
|
801
|
-
const
|
|
802
|
-
for (const [key, value] of Object.entries(registry)) {
|
|
803
|
-
if (value instanceof Map) {
|
|
804
|
-
registryRecord[key] = value;
|
|
805
|
-
}
|
|
806
|
-
}
|
|
534
|
+
const registry = Object.fromEntries(getSpecStore());
|
|
807
535
|
for (const model of models) {
|
|
808
536
|
const checker = model.getCoverageChecker();
|
|
809
537
|
if (checker) {
|
|
810
|
-
const specs =
|
|
811
|
-
const result = model.checkCoverage(specs,
|
|
538
|
+
const specs = getSpecs(model.id);
|
|
539
|
+
const result = model.checkCoverage(specs, registry);
|
|
812
540
|
if (result) {
|
|
813
541
|
results.push({
|
|
814
542
|
modelId: model.id,
|
|
@@ -918,7 +646,6 @@ async function impactCommand(targetId, options) {
|
|
|
918
646
|
console.log(chalk4.gray(" Usage: speckeeper impact <id>"));
|
|
919
647
|
process.exit(1);
|
|
920
648
|
}
|
|
921
|
-
const cwd = process.cwd();
|
|
922
649
|
const config = await loadConfig(options.config);
|
|
923
650
|
const maxDepth = options.depth ? parseInt(options.depth, 10) : 3;
|
|
924
651
|
console.log(chalk4.gray(` Target: ${targetId}`));
|
|
@@ -926,35 +653,27 @@ async function impactCommand(targetId, options) {
|
|
|
926
653
|
console.log("");
|
|
927
654
|
try {
|
|
928
655
|
console.log(chalk4.blue(" Loading models..."));
|
|
929
|
-
|
|
930
|
-
const
|
|
931
|
-
|
|
656
|
+
registerModelsFromConfig(config.models || []);
|
|
657
|
+
const store = getSpecStore();
|
|
658
|
+
const targetType = findModelTypeBySpecId(targetId);
|
|
659
|
+
if (!targetType) {
|
|
932
660
|
console.error(chalk4.red(` Error: Target '${targetId}' not found`));
|
|
933
661
|
process.exit(1);
|
|
934
662
|
}
|
|
935
663
|
console.log(chalk4.blue(" Analyzing impact..."));
|
|
936
|
-
const result = analyzeImpact(
|
|
664
|
+
const result = analyzeImpact(store, targetId, targetType, maxDepth);
|
|
937
665
|
outputImpactResults(result, options);
|
|
938
666
|
} catch (error) {
|
|
939
667
|
console.error(chalk4.red("Impact analysis failed:"), error);
|
|
940
668
|
process.exit(1);
|
|
941
669
|
}
|
|
942
670
|
}
|
|
943
|
-
function
|
|
944
|
-
for (const [type, map] of Object.entries(registry)) {
|
|
945
|
-
if (map instanceof Map && map.has(id)) {
|
|
946
|
-
return { type, data: map.get(id) };
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
return null;
|
|
950
|
-
}
|
|
951
|
-
function analyzeImpact(registry, targetId, targetType, maxDepth) {
|
|
671
|
+
function analyzeImpact(store, targetId, targetType, maxDepth) {
|
|
952
672
|
const impactedNodes = [];
|
|
953
673
|
const visited = /* @__PURE__ */ new Set([targetId]);
|
|
954
674
|
function findReferences(id, depth) {
|
|
955
675
|
if (depth > maxDepth) return;
|
|
956
|
-
for (const [type, map] of
|
|
957
|
-
if (!(map instanceof Map)) continue;
|
|
676
|
+
for (const [type, map] of store) {
|
|
958
677
|
for (const [itemId, item] of map) {
|
|
959
678
|
if (visited.has(itemId)) continue;
|
|
960
679
|
const itemStr = JSON.stringify(item);
|
|
@@ -1095,6 +814,1690 @@ function copyTemplateDir(srcDir, destDir, projectName, force, onFile, relativePa
|
|
|
1095
814
|
}
|
|
1096
815
|
}
|
|
1097
816
|
|
|
817
|
+
// src/scaffold/mermaid-parser.ts
|
|
818
|
+
function extractMermaidBlocks(markdown) {
|
|
819
|
+
const blocks = [];
|
|
820
|
+
const regex = /```mermaid\s*\n([\s\S]*?)```/g;
|
|
821
|
+
let match;
|
|
822
|
+
while ((match = regex.exec(markdown)) !== null) {
|
|
823
|
+
blocks.push(match[1]);
|
|
824
|
+
}
|
|
825
|
+
return blocks;
|
|
826
|
+
}
|
|
827
|
+
function parseFlowchart(mermaidSource) {
|
|
828
|
+
const lines = mermaidSource.split("\n");
|
|
829
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
830
|
+
const edges = [];
|
|
831
|
+
const classDefs = [];
|
|
832
|
+
const classAssignments = [];
|
|
833
|
+
let direction = "TB";
|
|
834
|
+
for (const rawLine of lines) {
|
|
835
|
+
const line = rawLine.trim();
|
|
836
|
+
if (!line || line.startsWith("%%")) continue;
|
|
837
|
+
const flowchartMatch = line.match(/^flowchart\s+(\w+)/);
|
|
838
|
+
if (flowchartMatch) {
|
|
839
|
+
direction = flowchartMatch[1];
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (line.startsWith("graph ")) {
|
|
843
|
+
const gMatch = line.match(/^graph\s+(\w+)/);
|
|
844
|
+
if (gMatch) direction = gMatch[1];
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
const classDefMatch = line.match(/^classDef\s+(\S+)\s+(.*)/);
|
|
848
|
+
if (classDefMatch) {
|
|
849
|
+
classDefs.push({ name: classDefMatch[1], styles: classDefMatch[2] });
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
const classAssignMatch = line.match(/^class\s+(\S+)\s+(\S+)/);
|
|
853
|
+
if (classAssignMatch) {
|
|
854
|
+
const nodeIds = classAssignMatch[1].split(",").map((s) => s.trim());
|
|
855
|
+
classAssignments.push({ nodeIds, className: classAssignMatch[2] });
|
|
856
|
+
for (const id of nodeIds) {
|
|
857
|
+
ensureNode(nodes, id);
|
|
858
|
+
const node = nodes.get(id);
|
|
859
|
+
if (!node.classes.includes(classAssignMatch[2])) {
|
|
860
|
+
node.classes.push(classAssignMatch[2]);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
const edgeParsed = tryParseEdgeLine(line);
|
|
866
|
+
if (edgeParsed) {
|
|
867
|
+
for (const { sourceId, sourceLabel, targetId, targetLabel, label, direction: dir } of edgeParsed) {
|
|
868
|
+
ensureNode(nodes, sourceId, sourceLabel);
|
|
869
|
+
ensureNode(nodes, targetId, targetLabel);
|
|
870
|
+
edges.push({
|
|
871
|
+
sourceId,
|
|
872
|
+
targetId,
|
|
873
|
+
rawLabel: label,
|
|
874
|
+
direction: dir
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
const standaloneNode = tryParseStandaloneNode(line);
|
|
880
|
+
if (standaloneNode) {
|
|
881
|
+
ensureNode(nodes, standaloneNode.id, standaloneNode.label);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return { direction, nodes, edges, classDefs, classAssignments };
|
|
885
|
+
}
|
|
886
|
+
function parseMarkdownFlowchart(markdown) {
|
|
887
|
+
const blocks = extractMermaidBlocks(markdown);
|
|
888
|
+
for (const block of blocks) {
|
|
889
|
+
const trimmed = block.trim();
|
|
890
|
+
if (trimmed.startsWith("flowchart") || trimmed.startsWith("graph")) {
|
|
891
|
+
return parseFlowchart(trimmed);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
function ensureNode(nodes, id, label) {
|
|
897
|
+
const existing = nodes.get(id);
|
|
898
|
+
if (existing) {
|
|
899
|
+
if (label && !existing.label) {
|
|
900
|
+
existing.label = label;
|
|
901
|
+
}
|
|
902
|
+
} else {
|
|
903
|
+
nodes.set(id, { id, label, classes: [] });
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
var NODE_PATTERN = /([A-Za-z_]\w*)(?:\[([^\]]*)\])?/;
|
|
907
|
+
function tryParseEdgeLine(line) {
|
|
908
|
+
const arrowPatterns = [
|
|
909
|
+
{ regex: /<-{2,}>/, direction: "bidirectional" },
|
|
910
|
+
{ regex: /<={2,}>/, direction: "bidirectional" },
|
|
911
|
+
{ regex: /-{2,}>/, direction: "forward" },
|
|
912
|
+
{ regex: /={2,}>/, direction: "forward" },
|
|
913
|
+
{ regex: /-\.->/, direction: "forward" }
|
|
914
|
+
];
|
|
915
|
+
let bestMatch = null;
|
|
916
|
+
for (const ap of arrowPatterns) {
|
|
917
|
+
const m = line.match(ap.regex);
|
|
918
|
+
if (m && m.index !== void 0) {
|
|
919
|
+
if (!bestMatch || m.index < bestMatch.index) {
|
|
920
|
+
bestMatch = { index: m.index, length: m[0].length, direction: ap.direction };
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (!bestMatch) return null;
|
|
925
|
+
const leftPart = line.substring(0, bestMatch.index).trim();
|
|
926
|
+
const rightPart = line.substring(bestMatch.index + bestMatch.length).trim();
|
|
927
|
+
const sourceMatch = leftPart.match(NODE_PATTERN);
|
|
928
|
+
if (!sourceMatch) return null;
|
|
929
|
+
const sourceId = sourceMatch[1];
|
|
930
|
+
const sourceLabel = sourceMatch[2] || void 0;
|
|
931
|
+
let label;
|
|
932
|
+
let targetStr = rightPart;
|
|
933
|
+
const labelMatch = rightPart.match(/^\|([^|]*)\|\s*(.*)/);
|
|
934
|
+
if (labelMatch) {
|
|
935
|
+
label = labelMatch[1].trim();
|
|
936
|
+
targetStr = labelMatch[2];
|
|
937
|
+
}
|
|
938
|
+
const targetMatch = targetStr.match(NODE_PATTERN);
|
|
939
|
+
if (!targetMatch) return null;
|
|
940
|
+
const targetId = targetMatch[1];
|
|
941
|
+
const targetLabel = targetMatch[2] || void 0;
|
|
942
|
+
return [{
|
|
943
|
+
sourceId,
|
|
944
|
+
sourceLabel,
|
|
945
|
+
targetId,
|
|
946
|
+
targetLabel,
|
|
947
|
+
label,
|
|
948
|
+
direction: bestMatch.direction
|
|
949
|
+
}];
|
|
950
|
+
}
|
|
951
|
+
function tryParseStandaloneNode(line) {
|
|
952
|
+
const m = line.match(/^([A-Za-z_]\w*)\[([^\]]*)\]\s*$/);
|
|
953
|
+
if (m) {
|
|
954
|
+
return { id: m[1], label: m[2] };
|
|
955
|
+
}
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/scaffold/edge-vocabulary.ts
|
|
960
|
+
var EDGE_VOCABULARY = [
|
|
961
|
+
// A. lint targets (speckeeper ↔ speckeeper reference integrity)
|
|
962
|
+
{
|
|
963
|
+
label: "refines",
|
|
964
|
+
expectedDirection: "forward",
|
|
965
|
+
relationType: "refines",
|
|
966
|
+
category: "lint",
|
|
967
|
+
description: "Decompose higher-level item into lower-level detail"
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
label: "relatedTo",
|
|
971
|
+
expectedDirection: "bidirectional",
|
|
972
|
+
relationType: "relatedTo",
|
|
973
|
+
category: "lint",
|
|
974
|
+
description: "Bidirectional association or consistency constraint"
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
label: "uses",
|
|
978
|
+
expectedDirection: "forward",
|
|
979
|
+
relationType: "uses",
|
|
980
|
+
category: "lint",
|
|
981
|
+
description: "Reference / dependency"
|
|
982
|
+
},
|
|
983
|
+
// B. check targets (speckeeper → external SSOT)
|
|
984
|
+
{
|
|
985
|
+
label: "implements",
|
|
986
|
+
expectedDirection: "forward",
|
|
987
|
+
relationType: "implements",
|
|
988
|
+
category: "check",
|
|
989
|
+
description: "Realize speckeeper spec as external artifact, interface, or test"
|
|
990
|
+
},
|
|
991
|
+
// C. coverage targets
|
|
992
|
+
{
|
|
993
|
+
label: "includes",
|
|
994
|
+
expectedDirection: "forward",
|
|
995
|
+
relationType: "includes",
|
|
996
|
+
category: "coverage",
|
|
997
|
+
description: "Parent contains child items"
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
label: "traces",
|
|
1001
|
+
expectedDirection: "forward",
|
|
1002
|
+
relationType: "traces",
|
|
1003
|
+
category: "coverage",
|
|
1004
|
+
description: "Derive target from source"
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
label: "verifies",
|
|
1008
|
+
expectedDirection: "forward",
|
|
1009
|
+
relationType: "verifies",
|
|
1010
|
+
category: "coverage",
|
|
1011
|
+
description: "Test verifies target"
|
|
1012
|
+
},
|
|
1013
|
+
// Additional RELATION_TYPES (usable but no special scaffold behaviour)
|
|
1014
|
+
{
|
|
1015
|
+
label: "dependsOn",
|
|
1016
|
+
expectedDirection: "forward",
|
|
1017
|
+
relationType: "dependsOn",
|
|
1018
|
+
category: "lint",
|
|
1019
|
+
description: "Dependency relationship"
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
label: "satisfies",
|
|
1023
|
+
expectedDirection: "forward",
|
|
1024
|
+
relationType: "satisfies",
|
|
1025
|
+
category: "lint",
|
|
1026
|
+
description: "Satisfies business/requirements"
|
|
1027
|
+
}
|
|
1028
|
+
];
|
|
1029
|
+
var EXTERNAL_LABELS = [
|
|
1030
|
+
{
|
|
1031
|
+
label: "generate",
|
|
1032
|
+
expectedDirection: "forward",
|
|
1033
|
+
relationType: void 0,
|
|
1034
|
+
category: "drift",
|
|
1035
|
+
description: "Automated generation (external tool)"
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
label: "apply",
|
|
1039
|
+
expectedDirection: "forward",
|
|
1040
|
+
relationType: void 0,
|
|
1041
|
+
category: "external",
|
|
1042
|
+
description: "Apply to external system"
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
label: "deploy",
|
|
1046
|
+
expectedDirection: "forward",
|
|
1047
|
+
relationType: void 0,
|
|
1048
|
+
category: "external",
|
|
1049
|
+
description: "Deploy artifact"
|
|
1050
|
+
}
|
|
1051
|
+
];
|
|
1052
|
+
var ALL_VOCABULARY = [...EDGE_VOCABULARY, ...EXTERNAL_LABELS];
|
|
1053
|
+
var SORTED_VOCABULARY = [...ALL_VOCABULARY].sort(
|
|
1054
|
+
(a, b) => b.label.length - a.label.length
|
|
1055
|
+
);
|
|
1056
|
+
var SORTED_SPECKEEPER_VOCABULARY = [...EDGE_VOCABULARY].sort(
|
|
1057
|
+
(a, b) => b.label.length - a.label.length
|
|
1058
|
+
);
|
|
1059
|
+
function normalizeLabel(rawLabel, vocabulary = SORTED_VOCABULARY) {
|
|
1060
|
+
if (!rawLabel) return null;
|
|
1061
|
+
const lower = rawLabel.toLowerCase().trim();
|
|
1062
|
+
for (const entry of vocabulary) {
|
|
1063
|
+
if (lower === entry.label.toLowerCase()) {
|
|
1064
|
+
return { entry, modifier: void 0 };
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
for (const entry of vocabulary) {
|
|
1068
|
+
const entryLower = entry.label.toLowerCase();
|
|
1069
|
+
if (lower.endsWith(entryLower)) {
|
|
1070
|
+
const mod = rawLabel.slice(0, rawLabel.length - entry.label.length).trim();
|
|
1071
|
+
return { entry, modifier: mod || void 0 };
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
for (const entry of vocabulary) {
|
|
1075
|
+
const entryLower = entry.label.toLowerCase();
|
|
1076
|
+
if (lower.includes(entryLower)) {
|
|
1077
|
+
const idx = lower.indexOf(entryLower);
|
|
1078
|
+
const before = rawLabel.slice(0, idx).trim();
|
|
1079
|
+
const after = rawLabel.slice(idx + entry.label.length).trim();
|
|
1080
|
+
const mod = [before, after].filter(Boolean).join(" ");
|
|
1081
|
+
return { entry, modifier: mod || void 0 };
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
function resolveEdges(edges, nodes, speckeeperClassName) {
|
|
1087
|
+
const diagnostics = [];
|
|
1088
|
+
const resolved = [];
|
|
1089
|
+
const isSpk = (id) => nodes.get(id)?.classes.includes(speckeeperClassName) ?? false;
|
|
1090
|
+
const fallback = EDGE_VOCABULARY.find((v) => v.label === "relatedTo");
|
|
1091
|
+
for (const edge of edges) {
|
|
1092
|
+
const involvesSpeckeeper = isSpk(edge.sourceId) || isSpk(edge.targetId);
|
|
1093
|
+
const norm = normalizeLabel(edge.rawLabel);
|
|
1094
|
+
if (!norm) {
|
|
1095
|
+
if (involvesSpeckeeper) {
|
|
1096
|
+
diagnostics.push({
|
|
1097
|
+
severity: "warning",
|
|
1098
|
+
message: `Edge label "${edge.rawLabel}" is not a valid speckeeper RelationType \u2014 falling back to "relatedTo"`,
|
|
1099
|
+
context: `${edge.sourceId} \u2192 ${edge.targetId}`
|
|
1100
|
+
});
|
|
1101
|
+
resolved.push({
|
|
1102
|
+
...edge,
|
|
1103
|
+
normalizedLabel: "relatedTo",
|
|
1104
|
+
vocabulary: fallback,
|
|
1105
|
+
modifier: void 0
|
|
1106
|
+
});
|
|
1107
|
+
} else {
|
|
1108
|
+
resolved.push({
|
|
1109
|
+
...edge,
|
|
1110
|
+
normalizedLabel: edge.rawLabel ?? "",
|
|
1111
|
+
vocabulary: { label: edge.rawLabel ?? "", expectedDirection: "forward", relationType: void 0, category: "external", description: "" },
|
|
1112
|
+
modifier: void 0
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
if (involvesSpeckeeper && !norm.entry.relationType) {
|
|
1118
|
+
const spkNorm = normalizeLabel(edge.rawLabel, SORTED_SPECKEEPER_VOCABULARY);
|
|
1119
|
+
if (!spkNorm) {
|
|
1120
|
+
diagnostics.push({
|
|
1121
|
+
severity: "warning",
|
|
1122
|
+
message: `Edge label "${edge.rawLabel}" is not a valid speckeeper RelationType \u2014 falling back to "relatedTo"`,
|
|
1123
|
+
context: `${edge.sourceId} \u2192 ${edge.targetId}`
|
|
1124
|
+
});
|
|
1125
|
+
resolved.push({
|
|
1126
|
+
...edge,
|
|
1127
|
+
normalizedLabel: "relatedTo",
|
|
1128
|
+
vocabulary: fallback,
|
|
1129
|
+
modifier: void 0
|
|
1130
|
+
});
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
resolved.push({
|
|
1135
|
+
...edge,
|
|
1136
|
+
normalizedLabel: norm.entry.label,
|
|
1137
|
+
vocabulary: norm.entry,
|
|
1138
|
+
modifier: norm.modifier
|
|
1139
|
+
});
|
|
1140
|
+
if (involvesSpeckeeper && edge.direction !== norm.entry.expectedDirection) {
|
|
1141
|
+
const expected = norm.entry.expectedDirection === "bidirectional" ? "<-->" : "-->";
|
|
1142
|
+
diagnostics.push({
|
|
1143
|
+
severity: "warning",
|
|
1144
|
+
message: `"${norm.entry.label}" expects ${expected} arrow but got ${edge.direction === "bidirectional" ? "<-->" : "-->"}`,
|
|
1145
|
+
context: `${edge.sourceId} \u2192 ${edge.targetId}`
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
if (norm.entry.label === "implements") {
|
|
1149
|
+
if (isSpk(edge.sourceId) && isSpk(edge.targetId)) {
|
|
1150
|
+
diagnostics.push({
|
|
1151
|
+
severity: "warning",
|
|
1152
|
+
message: '"implements" should be speckeeper \u2192 external; consider using "refines" for speckeeper \u2192 speckeeper edges',
|
|
1153
|
+
context: `${edge.sourceId} \u2192 ${edge.targetId}`
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (norm.entry.label === "includes" || norm.entry.label === "traces") {
|
|
1158
|
+
if (!isSpk(edge.sourceId) || !isSpk(edge.targetId)) {
|
|
1159
|
+
diagnostics.push({
|
|
1160
|
+
severity: "warning",
|
|
1161
|
+
message: `"${norm.entry.label}" should connect two speckeeper-managed nodes`,
|
|
1162
|
+
context: `${edge.sourceId} \u2192 ${edge.targetId}`
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return { resolved, diagnostics };
|
|
1168
|
+
}
|
|
1169
|
+
function isCheckEdge(entry) {
|
|
1170
|
+
return entry.category === "check";
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/scaffold/template-registry.ts
|
|
1174
|
+
var NODE_ALIAS = {
|
|
1175
|
+
// L0
|
|
1176
|
+
TERM: "term",
|
|
1177
|
+
CDM: "entity",
|
|
1178
|
+
// L1
|
|
1179
|
+
SR: "requirement",
|
|
1180
|
+
FR: "requirement",
|
|
1181
|
+
NFR: "requirement",
|
|
1182
|
+
UC: "usecase",
|
|
1183
|
+
// L2
|
|
1184
|
+
LDM: "logical-entity",
|
|
1185
|
+
AT: "acceptance-test",
|
|
1186
|
+
DT: "data-test",
|
|
1187
|
+
VC: "validation-constraint"
|
|
1188
|
+
};
|
|
1189
|
+
var TEMPLATE_META = {
|
|
1190
|
+
term: { level: "L0", fileName: "term", primaryTypeName: "Term" },
|
|
1191
|
+
entity: { level: "L0", fileName: "entity", primaryTypeName: "Entity" },
|
|
1192
|
+
requirement: { level: "L1", fileName: "requirement", primaryTypeName: "Requirement" },
|
|
1193
|
+
usecase: { level: "L1", fileName: "usecase", primaryTypeName: "UseCase" },
|
|
1194
|
+
"logical-entity": { level: "L2", fileName: "logical-entity", primaryTypeName: "LogicalEntity" },
|
|
1195
|
+
"acceptance-test": { level: "L2", fileName: "acceptance-test", primaryTypeName: "AcceptanceTest" },
|
|
1196
|
+
"data-test": { level: "L2", fileName: "data-test", primaryTypeName: "DataTest" },
|
|
1197
|
+
"validation-constraint": { level: "L2", fileName: "validation-constraint", primaryTypeName: "ValidationConstraint" }
|
|
1198
|
+
};
|
|
1199
|
+
function resolveModelTemplate(nodeId) {
|
|
1200
|
+
const templateName = NODE_ALIAS[nodeId];
|
|
1201
|
+
if (templateName) {
|
|
1202
|
+
const meta = TEMPLATE_META[templateName];
|
|
1203
|
+
return {
|
|
1204
|
+
templateName,
|
|
1205
|
+
defaultLevel: meta.level,
|
|
1206
|
+
defaultIdPrefix: nodeId,
|
|
1207
|
+
modelName: meta.primaryTypeName,
|
|
1208
|
+
fileName: meta.fileName
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
return {
|
|
1212
|
+
templateName: "base",
|
|
1213
|
+
defaultLevel: "L1",
|
|
1214
|
+
defaultIdPrefix: nodeId,
|
|
1215
|
+
modelName: toPascalCase(nodeId),
|
|
1216
|
+
fileName: toKebabCase(nodeId)
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
var CHECKER_ALIAS = {
|
|
1220
|
+
DDL: {
|
|
1221
|
+
templateName: "ddl-checker",
|
|
1222
|
+
targetType: "ddl",
|
|
1223
|
+
fileName: "ddl-checker"
|
|
1224
|
+
},
|
|
1225
|
+
API: {
|
|
1226
|
+
templateName: "openapi-checker",
|
|
1227
|
+
targetType: "openapi",
|
|
1228
|
+
fileName: "openapi-checker"
|
|
1229
|
+
},
|
|
1230
|
+
E2ET: {
|
|
1231
|
+
templateName: "test-checker",
|
|
1232
|
+
targetType: "test",
|
|
1233
|
+
fileName: "e2e-test-checker"
|
|
1234
|
+
},
|
|
1235
|
+
UT: {
|
|
1236
|
+
templateName: "test-checker",
|
|
1237
|
+
targetType: "test",
|
|
1238
|
+
fileName: "unit-test-checker"
|
|
1239
|
+
},
|
|
1240
|
+
DUT: {
|
|
1241
|
+
templateName: "test-checker",
|
|
1242
|
+
targetType: "test",
|
|
1243
|
+
fileName: "data-unit-test-checker"
|
|
1244
|
+
},
|
|
1245
|
+
IT: {
|
|
1246
|
+
templateName: "test-checker",
|
|
1247
|
+
targetType: "test",
|
|
1248
|
+
fileName: "integration-test-checker"
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
function resolveCheckerTemplate(targetNodeId) {
|
|
1252
|
+
return CHECKER_ALIAS[targetNodeId];
|
|
1253
|
+
}
|
|
1254
|
+
function toPascalCase(s) {
|
|
1255
|
+
return s.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join("");
|
|
1256
|
+
}
|
|
1257
|
+
function toKebabCase(s) {
|
|
1258
|
+
return s.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// src/scaffold/templates/requirement.ts
|
|
1262
|
+
function generateRequirementModel(_params) {
|
|
1263
|
+
return `/**
|
|
1264
|
+
* Requirement Model Definitions
|
|
1265
|
+
*
|
|
1266
|
+
* Contains SystemRequirement (SR), FunctionalRequirement (FR),
|
|
1267
|
+
* and NonFunctionalRequirement (NFR) models sharing the same schema.
|
|
1268
|
+
*/
|
|
1269
|
+
import { z } from 'zod';
|
|
1270
|
+
import { Model, RelationSchema } from 'speckeeper';
|
|
1271
|
+
import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
|
|
1272
|
+
|
|
1273
|
+
// =============================================================================
|
|
1274
|
+
// Schema Definition
|
|
1275
|
+
// =============================================================================
|
|
1276
|
+
|
|
1277
|
+
export const AcceptanceCriteriaSchema = z.object({
|
|
1278
|
+
id: z.string(),
|
|
1279
|
+
description: z.string(),
|
|
1280
|
+
verificationMethod: z.enum(['test', 'review', 'demo', 'inspection']).optional(),
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
export const RequirementSchema = z.object({
|
|
1284
|
+
id: z.string(),
|
|
1285
|
+
name: z.string().min(1),
|
|
1286
|
+
description: z.string(),
|
|
1287
|
+
type: z.enum(['functional', 'non-functional', 'constraint']),
|
|
1288
|
+
priority: z.enum(['must', 'should', 'could']),
|
|
1289
|
+
rationale: z.string().optional(),
|
|
1290
|
+
acceptanceCriteria: z.array(AcceptanceCriteriaSchema).min(1),
|
|
1291
|
+
category: z.string().optional(),
|
|
1292
|
+
relations: z.array(RelationSchema).optional(),
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
// =============================================================================
|
|
1296
|
+
// Type Export
|
|
1297
|
+
// =============================================================================
|
|
1298
|
+
|
|
1299
|
+
export type AcceptanceCriteria = z.infer<typeof AcceptanceCriteriaSchema>;
|
|
1300
|
+
export type Requirement = z.input<typeof RequirementSchema>;
|
|
1301
|
+
|
|
1302
|
+
// =============================================================================
|
|
1303
|
+
// Shared lint rules and exporters
|
|
1304
|
+
// =============================================================================
|
|
1305
|
+
|
|
1306
|
+
const requirementLintRules: LintRule<Requirement>[] = [
|
|
1307
|
+
{
|
|
1308
|
+
id: 'req-acceptance-not-empty',
|
|
1309
|
+
severity: 'error',
|
|
1310
|
+
message: 'Requirement must have at least one acceptance criteria',
|
|
1311
|
+
check: (spec) => !spec.acceptanceCriteria || spec.acceptanceCriteria.length === 0,
|
|
1312
|
+
},
|
|
1313
|
+
{
|
|
1314
|
+
id: 'req-acceptance-id-format',
|
|
1315
|
+
severity: 'warning',
|
|
1316
|
+
message: 'Acceptance criteria ID should follow parent requirement ID (e.g., FR-001-01)',
|
|
1317
|
+
check: (spec) => {
|
|
1318
|
+
if (!spec.acceptanceCriteria) return false;
|
|
1319
|
+
return spec.acceptanceCriteria.some(ac => !ac.id.startsWith(spec.id + '-'));
|
|
1320
|
+
},
|
|
1321
|
+
},
|
|
1322
|
+
];
|
|
1323
|
+
|
|
1324
|
+
const requirementExporters: Exporter<Requirement>[] = [
|
|
1325
|
+
{
|
|
1326
|
+
format: 'markdown',
|
|
1327
|
+
single: (spec) => {
|
|
1328
|
+
const lines: string[] = [];
|
|
1329
|
+
lines.push(\`# \${spec.name}\`);
|
|
1330
|
+
lines.push('');
|
|
1331
|
+
lines.push(\`**ID**: \${spec.id}\`);
|
|
1332
|
+
lines.push(\`**Type**: \${spec.type}\`);
|
|
1333
|
+
lines.push(\`**Priority**: \${spec.priority}\`);
|
|
1334
|
+
lines.push('');
|
|
1335
|
+
lines.push('## Description');
|
|
1336
|
+
lines.push('');
|
|
1337
|
+
lines.push(spec.description);
|
|
1338
|
+
lines.push('');
|
|
1339
|
+
lines.push('## Acceptance Criteria');
|
|
1340
|
+
lines.push('');
|
|
1341
|
+
for (const ac of spec.acceptanceCriteria) {
|
|
1342
|
+
lines.push(\`- **\${ac.id}**: \${ac.description}\`);
|
|
1343
|
+
}
|
|
1344
|
+
return lines.join('\\n');
|
|
1345
|
+
},
|
|
1346
|
+
outputDir: 'requirements',
|
|
1347
|
+
filename: (spec) => spec.id,
|
|
1348
|
+
},
|
|
1349
|
+
];
|
|
1350
|
+
|
|
1351
|
+
// =============================================================================
|
|
1352
|
+
// Model Classes
|
|
1353
|
+
// =============================================================================
|
|
1354
|
+
|
|
1355
|
+
class SystemRequirementModel extends Model<typeof RequirementSchema> {
|
|
1356
|
+
readonly id = 'system-requirement';
|
|
1357
|
+
readonly name = 'SystemRequirement';
|
|
1358
|
+
readonly idPrefix = 'SR';
|
|
1359
|
+
readonly schema = RequirementSchema;
|
|
1360
|
+
readonly description = 'Defines system-level requirements';
|
|
1361
|
+
protected modelLevel: ModelLevel = 'L1';
|
|
1362
|
+
protected lintRules = requirementLintRules;
|
|
1363
|
+
protected exporters = requirementExporters;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
class FunctionalRequirementModel extends Model<typeof RequirementSchema> {
|
|
1367
|
+
readonly id = 'functional-requirement';
|
|
1368
|
+
readonly name = 'FunctionalRequirement';
|
|
1369
|
+
readonly idPrefix = 'FR';
|
|
1370
|
+
readonly schema = RequirementSchema;
|
|
1371
|
+
readonly description = 'Defines functional requirements';
|
|
1372
|
+
protected modelLevel: ModelLevel = 'L1';
|
|
1373
|
+
protected lintRules = requirementLintRules;
|
|
1374
|
+
protected exporters = requirementExporters;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
class NonFunctionalRequirementModel extends Model<typeof RequirementSchema> {
|
|
1378
|
+
readonly id = 'non-functional-requirement';
|
|
1379
|
+
readonly name = 'NonFunctionalRequirement';
|
|
1380
|
+
readonly idPrefix = 'NFR';
|
|
1381
|
+
readonly schema = RequirementSchema;
|
|
1382
|
+
readonly description = 'Defines non-functional requirements and constraints';
|
|
1383
|
+
protected modelLevel: ModelLevel = 'L1';
|
|
1384
|
+
protected lintRules = requirementLintRules;
|
|
1385
|
+
protected exporters = requirementExporters;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
export { SystemRequirementModel, FunctionalRequirementModel, NonFunctionalRequirementModel };
|
|
1389
|
+
`;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// src/scaffold/templates/usecase.ts
|
|
1393
|
+
function generateUseCaseModel(_params) {
|
|
1394
|
+
return `/**
|
|
1395
|
+
* UseCase Model Definition
|
|
1396
|
+
*/
|
|
1397
|
+
import { z } from 'zod';
|
|
1398
|
+
import { Model, RelationSchema } from 'speckeeper';
|
|
1399
|
+
import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
|
|
1400
|
+
|
|
1401
|
+
// =============================================================================
|
|
1402
|
+
// Schema Definition
|
|
1403
|
+
// =============================================================================
|
|
1404
|
+
|
|
1405
|
+
export const ActorSchema = z.object({
|
|
1406
|
+
id: z.string(),
|
|
1407
|
+
name: z.string(),
|
|
1408
|
+
description: z.string().optional(),
|
|
1409
|
+
type: z.enum(['human', 'system', 'external']),
|
|
1410
|
+
relations: z.array(RelationSchema).optional(),
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
export const UseCaseStepSchema = z.object({
|
|
1414
|
+
stepNumber: z.number(),
|
|
1415
|
+
type: z.enum(['user_action', 'system_response', 'alternative']),
|
|
1416
|
+
description: z.string(),
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
export const UseCaseSchema = z.object({
|
|
1420
|
+
id: z.string(),
|
|
1421
|
+
name: z.string(),
|
|
1422
|
+
description: z.string(),
|
|
1423
|
+
actor: z.string(),
|
|
1424
|
+
preconditions: z.array(z.string()).optional().default([]),
|
|
1425
|
+
postconditions: z.array(z.string()).optional().default([]),
|
|
1426
|
+
mainFlow: z.array(UseCaseStepSchema),
|
|
1427
|
+
relations: z.array(RelationSchema).optional(),
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
// =============================================================================
|
|
1431
|
+
// Type Export
|
|
1432
|
+
// =============================================================================
|
|
1433
|
+
|
|
1434
|
+
export type Actor = z.input<typeof ActorSchema>;
|
|
1435
|
+
export type UseCaseStep = z.infer<typeof UseCaseStepSchema>;
|
|
1436
|
+
export type UseCase = z.input<typeof UseCaseSchema>;
|
|
1437
|
+
|
|
1438
|
+
// =============================================================================
|
|
1439
|
+
// Actor Model Class
|
|
1440
|
+
// =============================================================================
|
|
1441
|
+
|
|
1442
|
+
class ActorModel extends Model<typeof ActorSchema> {
|
|
1443
|
+
readonly id = 'actor';
|
|
1444
|
+
readonly name = 'Actor';
|
|
1445
|
+
readonly idPrefix = 'ACT';
|
|
1446
|
+
readonly schema = ActorSchema;
|
|
1447
|
+
readonly description = 'Defines actors';
|
|
1448
|
+
protected modelLevel: ModelLevel = 'L0';
|
|
1449
|
+
|
|
1450
|
+
protected lintRules: LintRule<Actor>[] = [
|
|
1451
|
+
{
|
|
1452
|
+
id: 'actor-has-description',
|
|
1453
|
+
severity: 'warning',
|
|
1454
|
+
message: 'Actor should have a description',
|
|
1455
|
+
check: (spec) => !spec.description || spec.description.trim() === '',
|
|
1456
|
+
},
|
|
1457
|
+
];
|
|
1458
|
+
|
|
1459
|
+
protected exporters: Exporter<Actor>[] = [];
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// =============================================================================
|
|
1463
|
+
// UseCase Model Class
|
|
1464
|
+
// =============================================================================
|
|
1465
|
+
|
|
1466
|
+
class UseCaseModel extends Model<typeof UseCaseSchema> {
|
|
1467
|
+
readonly id = 'usecase';
|
|
1468
|
+
readonly name = 'UseCase';
|
|
1469
|
+
readonly idPrefix = 'UC';
|
|
1470
|
+
readonly schema = UseCaseSchema;
|
|
1471
|
+
readonly description = 'Defines use cases (business flows)';
|
|
1472
|
+
protected modelLevel: ModelLevel = 'L1';
|
|
1473
|
+
|
|
1474
|
+
protected lintRules: LintRule<UseCase>[] = [
|
|
1475
|
+
{
|
|
1476
|
+
id: 'usecase-has-main-flow',
|
|
1477
|
+
severity: 'error',
|
|
1478
|
+
message: 'UseCase must have a main flow',
|
|
1479
|
+
check: (spec) => !spec.mainFlow || spec.mainFlow.length === 0,
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
id: 'usecase-has-actor',
|
|
1483
|
+
severity: 'error',
|
|
1484
|
+
message: 'UseCase must have an actor',
|
|
1485
|
+
check: (spec) => !spec.actor || spec.actor.trim() === '',
|
|
1486
|
+
},
|
|
1487
|
+
];
|
|
1488
|
+
|
|
1489
|
+
protected exporters: Exporter<UseCase>[] = [];
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
export { ActorModel, UseCaseModel };
|
|
1493
|
+
`;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// src/scaffold/templates/term.ts
|
|
1497
|
+
function generateTermModel(_params) {
|
|
1498
|
+
return `/**
|
|
1499
|
+
* Term Model Definition
|
|
1500
|
+
*/
|
|
1501
|
+
import { z } from 'zod';
|
|
1502
|
+
import { Model, RelationSchema } from 'speckeeper';
|
|
1503
|
+
import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
|
|
1504
|
+
|
|
1505
|
+
// =============================================================================
|
|
1506
|
+
// Schema Definition
|
|
1507
|
+
// =============================================================================
|
|
1508
|
+
|
|
1509
|
+
export const TermSchema = z.object({
|
|
1510
|
+
id: z.string(),
|
|
1511
|
+
term: z.string(),
|
|
1512
|
+
definition: z.string(),
|
|
1513
|
+
category: z.enum(['business', 'technical', 'acronym', 'process', 'role']),
|
|
1514
|
+
abbreviation: z.string().optional(),
|
|
1515
|
+
expandedForm: z.string().optional(),
|
|
1516
|
+
relations: z.array(RelationSchema).optional(),
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
// =============================================================================
|
|
1520
|
+
// Type Export
|
|
1521
|
+
// =============================================================================
|
|
1522
|
+
|
|
1523
|
+
export type Term = z.input<typeof TermSchema>;
|
|
1524
|
+
|
|
1525
|
+
// =============================================================================
|
|
1526
|
+
// Model Class
|
|
1527
|
+
// =============================================================================
|
|
1528
|
+
|
|
1529
|
+
class TermModel extends Model<typeof TermSchema> {
|
|
1530
|
+
readonly id = 'term';
|
|
1531
|
+
readonly name = 'Term';
|
|
1532
|
+
readonly idPrefix = 'TERM';
|
|
1533
|
+
readonly schema = TermSchema;
|
|
1534
|
+
readonly description = 'Defines terms (glossary)';
|
|
1535
|
+
protected modelLevel: ModelLevel = 'L0';
|
|
1536
|
+
|
|
1537
|
+
protected lintRules: LintRule<Term>[] = [
|
|
1538
|
+
{
|
|
1539
|
+
id: 'term-has-definition',
|
|
1540
|
+
severity: 'error',
|
|
1541
|
+
message: 'Term must have a definition',
|
|
1542
|
+
check: (spec) => !spec.definition || spec.definition.trim() === '',
|
|
1543
|
+
},
|
|
1544
|
+
{
|
|
1545
|
+
id: 'term-acronym-has-expanded-form',
|
|
1546
|
+
severity: 'warning',
|
|
1547
|
+
message: 'Acronym terms should have an expanded form',
|
|
1548
|
+
check: (spec) => spec.category === 'acronym' && !spec.expandedForm,
|
|
1549
|
+
},
|
|
1550
|
+
];
|
|
1551
|
+
|
|
1552
|
+
protected exporters: Exporter<Term>[] = [];
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
export { TermModel };
|
|
1556
|
+
`;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// src/scaffold/templates/entity.ts
|
|
1560
|
+
function generateEntityModel(_params) {
|
|
1561
|
+
return `/**
|
|
1562
|
+
* Entity Model Definition (Conceptual Data Model)
|
|
1563
|
+
*/
|
|
1564
|
+
import { z } from 'zod';
|
|
1565
|
+
import { Model, RelationSchema } from 'speckeeper';
|
|
1566
|
+
import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
|
|
1567
|
+
|
|
1568
|
+
// =============================================================================
|
|
1569
|
+
// Schema Definition
|
|
1570
|
+
// =============================================================================
|
|
1571
|
+
|
|
1572
|
+
export const AttributeSchema = z.object({
|
|
1573
|
+
name: z.string(),
|
|
1574
|
+
type: z.enum(['string', 'number', 'boolean', 'date', 'datetime', 'uuid', 'enum', 'reference']),
|
|
1575
|
+
required: z.boolean().optional().default(true),
|
|
1576
|
+
description: z.string().optional(),
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
export const EntitySchema = z.object({
|
|
1580
|
+
id: z.string(),
|
|
1581
|
+
name: z.string(),
|
|
1582
|
+
description: z.string(),
|
|
1583
|
+
attributes: z.array(AttributeSchema),
|
|
1584
|
+
isAggregateRoot: z.boolean().optional().default(false),
|
|
1585
|
+
relations: z.array(RelationSchema).optional(),
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
// =============================================================================
|
|
1589
|
+
// Type Export
|
|
1590
|
+
// =============================================================================
|
|
1591
|
+
|
|
1592
|
+
export type Attribute = z.infer<typeof AttributeSchema>;
|
|
1593
|
+
export type Entity = z.input<typeof EntitySchema>;
|
|
1594
|
+
|
|
1595
|
+
// =============================================================================
|
|
1596
|
+
// Model Class
|
|
1597
|
+
// =============================================================================
|
|
1598
|
+
|
|
1599
|
+
class ConceptualDataModel extends Model<typeof EntitySchema> {
|
|
1600
|
+
readonly id = 'conceptual-data-model';
|
|
1601
|
+
readonly name = 'ConceptualDataModel';
|
|
1602
|
+
readonly idPrefix = 'CDM';
|
|
1603
|
+
readonly schema = EntitySchema;
|
|
1604
|
+
readonly description = 'Defines conceptual entities (domain model)';
|
|
1605
|
+
protected modelLevel: ModelLevel = 'L0';
|
|
1606
|
+
|
|
1607
|
+
protected lintRules: LintRule<Entity>[] = [
|
|
1608
|
+
{
|
|
1609
|
+
id: 'entity-has-attributes',
|
|
1610
|
+
severity: 'warning',
|
|
1611
|
+
message: 'Entity should have at least one attribute',
|
|
1612
|
+
check: (spec) => !spec.attributes || spec.attributes.length === 0,
|
|
1613
|
+
},
|
|
1614
|
+
];
|
|
1615
|
+
|
|
1616
|
+
protected exporters: Exporter<Entity>[] = [
|
|
1617
|
+
{
|
|
1618
|
+
format: 'markdown',
|
|
1619
|
+
single: (spec) => {
|
|
1620
|
+
const lines: string[] = [];
|
|
1621
|
+
lines.push(\`# \${spec.name}\`);
|
|
1622
|
+
lines.push('');
|
|
1623
|
+
lines.push(\`**ID**: \${spec.id}\`);
|
|
1624
|
+
lines.push('');
|
|
1625
|
+
lines.push(spec.description);
|
|
1626
|
+
lines.push('');
|
|
1627
|
+
lines.push('## Attributes');
|
|
1628
|
+
lines.push('');
|
|
1629
|
+
lines.push('| Name | Type | Required | Description |');
|
|
1630
|
+
lines.push('|------|------|----------|-------------|');
|
|
1631
|
+
for (const attr of spec.attributes) {
|
|
1632
|
+
lines.push(\`| \${attr.name} | \${attr.type} | \${attr.required ? 'Yes' : 'No'} | \${attr.description || ''} |\`);
|
|
1633
|
+
}
|
|
1634
|
+
return lines.join('\\n');
|
|
1635
|
+
},
|
|
1636
|
+
outputDir: 'data-model',
|
|
1637
|
+
filename: (spec) => spec.id,
|
|
1638
|
+
},
|
|
1639
|
+
];
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
export { ConceptualDataModel };
|
|
1643
|
+
`;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// src/scaffold/templates/logical-entity.ts
|
|
1647
|
+
function generateLogicalEntityModel(_params) {
|
|
1648
|
+
return `/**
|
|
1649
|
+
* LogicalEntity Model Definition (Logical Data Model)
|
|
1650
|
+
*/
|
|
1651
|
+
import { z } from 'zod';
|
|
1652
|
+
import { Model, RelationSchema } from 'speckeeper';
|
|
1653
|
+
import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
|
|
1654
|
+
|
|
1655
|
+
// =============================================================================
|
|
1656
|
+
// Schema Definition
|
|
1657
|
+
// =============================================================================
|
|
1658
|
+
|
|
1659
|
+
export const ColumnSchema = z.object({
|
|
1660
|
+
name: z.string(),
|
|
1661
|
+
dataType: z.string(),
|
|
1662
|
+
nullable: z.boolean().optional().default(false),
|
|
1663
|
+
primaryKey: z.boolean().optional().default(false),
|
|
1664
|
+
foreignKey: z.object({
|
|
1665
|
+
table: z.string(),
|
|
1666
|
+
column: z.string(),
|
|
1667
|
+
}).optional(),
|
|
1668
|
+
description: z.string().optional(),
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
export const ConstraintSchema = z.object({
|
|
1672
|
+
name: z.string(),
|
|
1673
|
+
type: z.enum(['unique', 'check', 'foreign_key', 'index']),
|
|
1674
|
+
columns: z.array(z.string()),
|
|
1675
|
+
expression: z.string().optional(),
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
export const LogicalEntitySchema = z.object({
|
|
1679
|
+
id: z.string(),
|
|
1680
|
+
name: z.string(),
|
|
1681
|
+
description: z.string(),
|
|
1682
|
+
tableName: z.string(),
|
|
1683
|
+
columns: z.array(ColumnSchema).min(1),
|
|
1684
|
+
constraints: z.array(ConstraintSchema).optional(),
|
|
1685
|
+
relations: z.array(RelationSchema).optional(),
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
// =============================================================================
|
|
1689
|
+
// Type Export
|
|
1690
|
+
// =============================================================================
|
|
1691
|
+
|
|
1692
|
+
export type Column = z.infer<typeof ColumnSchema>;
|
|
1693
|
+
export type Constraint = z.infer<typeof ConstraintSchema>;
|
|
1694
|
+
export type LogicalEntity = z.input<typeof LogicalEntitySchema>;
|
|
1695
|
+
|
|
1696
|
+
// =============================================================================
|
|
1697
|
+
// Model Class
|
|
1698
|
+
// =============================================================================
|
|
1699
|
+
|
|
1700
|
+
class LogicalDataModel extends Model<typeof LogicalEntitySchema> {
|
|
1701
|
+
readonly id = 'logical-data-model';
|
|
1702
|
+
readonly name = 'LogicalDataModel';
|
|
1703
|
+
readonly idPrefix = 'LDM';
|
|
1704
|
+
readonly schema = LogicalEntitySchema;
|
|
1705
|
+
readonly description = 'Defines logical data model entities (tables, columns, constraints)';
|
|
1706
|
+
protected modelLevel: ModelLevel = 'L2';
|
|
1707
|
+
|
|
1708
|
+
protected lintRules: LintRule<LogicalEntity>[] = [
|
|
1709
|
+
{
|
|
1710
|
+
id: 'lent-has-columns',
|
|
1711
|
+
severity: 'error',
|
|
1712
|
+
message: 'LogicalEntity must have at least one column',
|
|
1713
|
+
check: (spec) => !spec.columns || spec.columns.length === 0,
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
id: 'lent-has-primary-key',
|
|
1717
|
+
severity: 'warning',
|
|
1718
|
+
message: 'LogicalEntity should have at least one primary key column',
|
|
1719
|
+
check: (spec) => {
|
|
1720
|
+
if (!spec.columns) return true;
|
|
1721
|
+
return !spec.columns.some(c => c.primaryKey);
|
|
1722
|
+
},
|
|
1723
|
+
},
|
|
1724
|
+
];
|
|
1725
|
+
|
|
1726
|
+
protected exporters: Exporter<LogicalEntity>[] = [
|
|
1727
|
+
{
|
|
1728
|
+
format: 'markdown',
|
|
1729
|
+
single: (spec) => {
|
|
1730
|
+
const lines: string[] = [];
|
|
1731
|
+
lines.push(\`# \${spec.name}\`);
|
|
1732
|
+
lines.push('');
|
|
1733
|
+
lines.push(\`**ID**: \${spec.id} \`);
|
|
1734
|
+
lines.push(\`**Table**: \${spec.tableName}\`);
|
|
1735
|
+
lines.push('');
|
|
1736
|
+
lines.push(spec.description);
|
|
1737
|
+
lines.push('');
|
|
1738
|
+
lines.push('## Columns');
|
|
1739
|
+
lines.push('');
|
|
1740
|
+
lines.push('| Name | Type | Nullable | PK | Description |');
|
|
1741
|
+
lines.push('|------|------|----------|----|-------------|');
|
|
1742
|
+
for (const col of spec.columns) {
|
|
1743
|
+
lines.push(\`| \${col.name} | \${col.dataType} | \${col.nullable ? 'Yes' : 'No'} | \${col.primaryKey ? 'Yes' : ''} | \${col.description || ''} |\`);
|
|
1744
|
+
}
|
|
1745
|
+
return lines.join('\\n');
|
|
1746
|
+
},
|
|
1747
|
+
outputDir: 'data-model',
|
|
1748
|
+
filename: (spec) => spec.id,
|
|
1749
|
+
},
|
|
1750
|
+
];
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
export { LogicalDataModel };
|
|
1754
|
+
`;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// src/scaffold/templates/acceptance-test.ts
|
|
1758
|
+
function generateAcceptanceTestModel(_params) {
|
|
1759
|
+
return `/**
|
|
1760
|
+
* AcceptanceTest Model Definition
|
|
1761
|
+
*/
|
|
1762
|
+
import { z } from 'zod';
|
|
1763
|
+
import { Model, RelationSchema } from 'speckeeper';
|
|
1764
|
+
import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
|
|
1765
|
+
|
|
1766
|
+
// =============================================================================
|
|
1767
|
+
// Schema Definition
|
|
1768
|
+
// =============================================================================
|
|
1769
|
+
|
|
1770
|
+
export const TestStepSchema = z.object({
|
|
1771
|
+
stepNumber: z.number(),
|
|
1772
|
+
action: z.string(),
|
|
1773
|
+
expectedResult: z.string(),
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
export const AcceptanceTestSchema = z.object({
|
|
1777
|
+
id: z.string(),
|
|
1778
|
+
name: z.string(),
|
|
1779
|
+
description: z.string(),
|
|
1780
|
+
targetId: z.string(),
|
|
1781
|
+
testType: z.enum(['e2e', 'manual', 'review']),
|
|
1782
|
+
steps: z.array(TestStepSchema).optional(),
|
|
1783
|
+
expectedResult: z.string(),
|
|
1784
|
+
relations: z.array(RelationSchema).optional(),
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
// =============================================================================
|
|
1788
|
+
// Type Export
|
|
1789
|
+
// =============================================================================
|
|
1790
|
+
|
|
1791
|
+
export type TestStep = z.infer<typeof TestStepSchema>;
|
|
1792
|
+
export type AcceptanceTest = z.input<typeof AcceptanceTestSchema>;
|
|
1793
|
+
|
|
1794
|
+
// =============================================================================
|
|
1795
|
+
// Model Class
|
|
1796
|
+
// =============================================================================
|
|
1797
|
+
|
|
1798
|
+
class AcceptanceTestModel extends Model<typeof AcceptanceTestSchema> {
|
|
1799
|
+
readonly id = 'acceptance-test';
|
|
1800
|
+
readonly name = 'AcceptanceTest';
|
|
1801
|
+
readonly idPrefix = 'AT';
|
|
1802
|
+
readonly schema = AcceptanceTestSchema;
|
|
1803
|
+
readonly description = 'Defines acceptance test specifications';
|
|
1804
|
+
protected modelLevel: ModelLevel = 'L2';
|
|
1805
|
+
|
|
1806
|
+
protected lintRules: LintRule<AcceptanceTest>[] = [
|
|
1807
|
+
{
|
|
1808
|
+
id: 'at-has-target',
|
|
1809
|
+
severity: 'error',
|
|
1810
|
+
message: 'AcceptanceTest must reference a target requirement or use case',
|
|
1811
|
+
check: (spec) => !spec.targetId || spec.targetId.trim() === '',
|
|
1812
|
+
},
|
|
1813
|
+
];
|
|
1814
|
+
|
|
1815
|
+
protected exporters: Exporter<AcceptanceTest>[] = [];
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
export { AcceptanceTestModel };
|
|
1819
|
+
`;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// src/scaffold/templates/data-test.ts
|
|
1823
|
+
function generateDataTestModel(_params) {
|
|
1824
|
+
return `/**
|
|
1825
|
+
* DataTest Model Definition
|
|
1826
|
+
*/
|
|
1827
|
+
import { z } from 'zod';
|
|
1828
|
+
import { Model, RelationSchema } from 'speckeeper';
|
|
1829
|
+
import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
|
|
1830
|
+
|
|
1831
|
+
// =============================================================================
|
|
1832
|
+
// Schema Definition
|
|
1833
|
+
// =============================================================================
|
|
1834
|
+
|
|
1835
|
+
export const DataTestSchema = z.object({
|
|
1836
|
+
id: z.string(),
|
|
1837
|
+
name: z.string(),
|
|
1838
|
+
description: z.string(),
|
|
1839
|
+
targetEntityId: z.string(),
|
|
1840
|
+
ruleType: z.enum(['fk', 'unique', 'type', 'not-null']),
|
|
1841
|
+
condition: z.string(),
|
|
1842
|
+
relations: z.array(RelationSchema).optional(),
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
// =============================================================================
|
|
1846
|
+
// Type Export
|
|
1847
|
+
// =============================================================================
|
|
1848
|
+
|
|
1849
|
+
export type DataTest = z.input<typeof DataTestSchema>;
|
|
1850
|
+
|
|
1851
|
+
// =============================================================================
|
|
1852
|
+
// Model Class
|
|
1853
|
+
// =============================================================================
|
|
1854
|
+
|
|
1855
|
+
class DataTestModel extends Model<typeof DataTestSchema> {
|
|
1856
|
+
readonly id = 'data-test';
|
|
1857
|
+
readonly name = 'DataTest';
|
|
1858
|
+
readonly idPrefix = 'DT';
|
|
1859
|
+
readonly schema = DataTestSchema;
|
|
1860
|
+
readonly description = 'Defines data integrity test specifications';
|
|
1861
|
+
protected modelLevel: ModelLevel = 'L2';
|
|
1862
|
+
|
|
1863
|
+
protected lintRules: LintRule<DataTest>[] = [
|
|
1864
|
+
{
|
|
1865
|
+
id: 'dt-has-target-entity',
|
|
1866
|
+
severity: 'error',
|
|
1867
|
+
message: 'DataTest must reference a target entity',
|
|
1868
|
+
check: (spec) => !spec.targetEntityId || spec.targetEntityId.trim() === '',
|
|
1869
|
+
},
|
|
1870
|
+
];
|
|
1871
|
+
|
|
1872
|
+
protected exporters: Exporter<DataTest>[] = [];
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
export { DataTestModel };
|
|
1876
|
+
`;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// src/scaffold/templates/validation-constraint.ts
|
|
1880
|
+
function generateValidationConstraintModel(_params) {
|
|
1881
|
+
return `/**
|
|
1882
|
+
* ValidationConstraint Model Definition
|
|
1883
|
+
*/
|
|
1884
|
+
import { z } from 'zod';
|
|
1885
|
+
import { Model, RelationSchema } from 'speckeeper';
|
|
1886
|
+
import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
|
|
1887
|
+
|
|
1888
|
+
// =============================================================================
|
|
1889
|
+
// Schema Definition
|
|
1890
|
+
// =============================================================================
|
|
1891
|
+
|
|
1892
|
+
export const ValidationConstraintSchema = z.object({
|
|
1893
|
+
id: z.string(),
|
|
1894
|
+
name: z.string(),
|
|
1895
|
+
description: z.string(),
|
|
1896
|
+
targetField: z.string(),
|
|
1897
|
+
constraintType: z.enum(['range', 'format', 'required', 'enum', 'custom']),
|
|
1898
|
+
rule: z.string(),
|
|
1899
|
+
errorMessage: z.string().optional(),
|
|
1900
|
+
relations: z.array(RelationSchema).optional(),
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
// =============================================================================
|
|
1904
|
+
// Type Export
|
|
1905
|
+
// =============================================================================
|
|
1906
|
+
|
|
1907
|
+
export type ValidationConstraint = z.input<typeof ValidationConstraintSchema>;
|
|
1908
|
+
|
|
1909
|
+
// =============================================================================
|
|
1910
|
+
// Model Class
|
|
1911
|
+
// =============================================================================
|
|
1912
|
+
|
|
1913
|
+
class ValidationConstraintModel extends Model<typeof ValidationConstraintSchema> {
|
|
1914
|
+
readonly id = 'validation-constraint';
|
|
1915
|
+
readonly name = 'ValidationConstraint';
|
|
1916
|
+
readonly idPrefix = 'VC';
|
|
1917
|
+
readonly schema = ValidationConstraintSchema;
|
|
1918
|
+
readonly description = 'Defines validation constraints (input rules, boundary values, formats)';
|
|
1919
|
+
protected modelLevel: ModelLevel = 'L2';
|
|
1920
|
+
|
|
1921
|
+
protected lintRules: LintRule<ValidationConstraint>[] = [
|
|
1922
|
+
{
|
|
1923
|
+
id: 'vc-has-rule',
|
|
1924
|
+
severity: 'error',
|
|
1925
|
+
message: 'ValidationConstraint must have a rule definition',
|
|
1926
|
+
check: (spec) => !spec.rule || spec.rule.trim() === '',
|
|
1927
|
+
},
|
|
1928
|
+
];
|
|
1929
|
+
|
|
1930
|
+
protected exporters: Exporter<ValidationConstraint>[] = [];
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
export { ValidationConstraintModel };
|
|
1934
|
+
`;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// src/scaffold/templates/base.ts
|
|
1938
|
+
function generateBaseModel(params) {
|
|
1939
|
+
const schemaName = `${params.modelName}Schema`;
|
|
1940
|
+
const className = `${params.modelName}Model`;
|
|
1941
|
+
return `/**
|
|
1942
|
+
* ${params.modelName} Model Definition
|
|
1943
|
+
*
|
|
1944
|
+
* Generated by speckeeper scaffold \u2014 customise as needed.
|
|
1945
|
+
*/
|
|
1946
|
+
import { z } from 'zod';
|
|
1947
|
+
import { Model, RelationSchema } from 'speckeeper';
|
|
1948
|
+
import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
|
|
1949
|
+
|
|
1950
|
+
// =============================================================================
|
|
1951
|
+
// Schema Definition
|
|
1952
|
+
// =============================================================================
|
|
1953
|
+
|
|
1954
|
+
export const ${schemaName} = z.object({
|
|
1955
|
+
id: z.string(),
|
|
1956
|
+
name: z.string().min(1),
|
|
1957
|
+
description: z.string(),
|
|
1958
|
+
relations: z.array(RelationSchema).optional(),
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
// =============================================================================
|
|
1962
|
+
// Type Export
|
|
1963
|
+
// =============================================================================
|
|
1964
|
+
|
|
1965
|
+
export type ${params.modelName} = z.input<typeof ${schemaName}>;
|
|
1966
|
+
|
|
1967
|
+
// =============================================================================
|
|
1968
|
+
// Model Class
|
|
1969
|
+
// =============================================================================
|
|
1970
|
+
|
|
1971
|
+
class ${className} extends Model<typeof ${schemaName}> {
|
|
1972
|
+
readonly id = '${params.modelId}';
|
|
1973
|
+
readonly name = '${params.modelName}';
|
|
1974
|
+
readonly idPrefix = '${params.idPrefix}';
|
|
1975
|
+
readonly schema = ${schemaName};
|
|
1976
|
+
readonly description = '${params.description}';
|
|
1977
|
+
// TODO: adjust modelLevel
|
|
1978
|
+
protected modelLevel: ModelLevel = '${params.level}';
|
|
1979
|
+
|
|
1980
|
+
protected lintRules: LintRule<${params.modelName}>[] = [
|
|
1981
|
+
{
|
|
1982
|
+
id: '${params.modelId}-has-description',
|
|
1983
|
+
severity: 'warning',
|
|
1984
|
+
message: '${params.modelName} should have a description',
|
|
1985
|
+
check: (spec) => !spec.description || spec.description.trim() === '',
|
|
1986
|
+
},
|
|
1987
|
+
];
|
|
1988
|
+
|
|
1989
|
+
protected exporters: Exporter<${params.modelName}>[] = [];
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
export { ${className} };
|
|
1993
|
+
`;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// src/scaffold/templates/checkers/ddl-checker.ts
|
|
1997
|
+
function generateDdlChecker(_params) {
|
|
1998
|
+
return `import { readFileSync, existsSync } from 'node:fs';
|
|
1999
|
+
import { resolve } from 'node:path';
|
|
2000
|
+
import type { ExternalChecker, CheckResult } from 'speckeeper';
|
|
2001
|
+
import type { LogicalEntity } from '../_models/logical-entity.ts';
|
|
2002
|
+
|
|
2003
|
+
/**
|
|
2004
|
+
* DDL Checker: verifies that logical entity definitions
|
|
2005
|
+
* have corresponding tables/columns in schema.sql.
|
|
2006
|
+
*/
|
|
2007
|
+
export const ddlChecker: ExternalChecker<LogicalEntity> = {
|
|
2008
|
+
targetType: 'ddl',
|
|
2009
|
+
|
|
2010
|
+
sourcePath: () => resolve('..', 'db', 'schema.sql'),
|
|
2011
|
+
|
|
2012
|
+
check: (spec, _externalData): CheckResult => {
|
|
2013
|
+
const errors: CheckResult['errors'] = [];
|
|
2014
|
+
const warnings: CheckResult['warnings'] = [];
|
|
2015
|
+
|
|
2016
|
+
const schemaPath = resolve('..', 'db', 'schema.sql');
|
|
2017
|
+
if (!existsSync(schemaPath)) {
|
|
2018
|
+
warnings.push({
|
|
2019
|
+
message: \`schema.sql not found: \${schemaPath}\`,
|
|
2020
|
+
specId: spec.id,
|
|
2021
|
+
});
|
|
2022
|
+
return { success: true, errors, warnings };
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
const _schemaSql = readFileSync(schemaPath, 'utf-8');
|
|
2026
|
+
|
|
2027
|
+
// TODO: implement table/column existence check
|
|
2028
|
+
warnings.push({
|
|
2029
|
+
message: \`DDL checker not fully implemented for \${spec.id} \u2014 add table/column verification logic\`,
|
|
2030
|
+
specId: spec.id,
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
return {
|
|
2034
|
+
success: errors.length === 0,
|
|
2035
|
+
errors,
|
|
2036
|
+
warnings,
|
|
2037
|
+
};
|
|
2038
|
+
},
|
|
2039
|
+
};
|
|
2040
|
+
`;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// src/scaffold/templates/checkers/openapi-checker.ts
|
|
2044
|
+
function generateOpenapiChecker(_params) {
|
|
2045
|
+
return `import { existsSync } from 'node:fs';
|
|
2046
|
+
import { resolve } from 'node:path';
|
|
2047
|
+
import type { ExternalChecker, CheckResult } from 'speckeeper';
|
|
2048
|
+
import type { UseCase } from '../_models/usecase.ts';
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* OpenAPI Checker: verifies that use cases have corresponding
|
|
2052
|
+
* endpoint definitions in the OpenAPI spec.
|
|
2053
|
+
*/
|
|
2054
|
+
export const openapiChecker: ExternalChecker<UseCase> = {
|
|
2055
|
+
targetType: 'openapi',
|
|
2056
|
+
|
|
2057
|
+
sourcePath: () => resolve('..', 'api', 'spec'),
|
|
2058
|
+
|
|
2059
|
+
check: (spec, _externalData): CheckResult => {
|
|
2060
|
+
const errors: CheckResult['errors'] = [];
|
|
2061
|
+
const warnings: CheckResult['warnings'] = [];
|
|
2062
|
+
|
|
2063
|
+
const specDir = resolve('..', 'api', 'spec');
|
|
2064
|
+
if (!existsSync(specDir)) {
|
|
2065
|
+
warnings.push({
|
|
2066
|
+
message: \`OpenAPI spec directory not found: \${specDir}\`,
|
|
2067
|
+
specId: spec.id,
|
|
2068
|
+
});
|
|
2069
|
+
return { success: true, errors, warnings };
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// TODO: implement endpoint existence check
|
|
2073
|
+
warnings.push({
|
|
2074
|
+
message: \`OpenAPI checker not fully implemented for \${spec.id} \u2014 add endpoint verification logic\`,
|
|
2075
|
+
specId: spec.id,
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
return {
|
|
2079
|
+
success: errors.length === 0,
|
|
2080
|
+
errors,
|
|
2081
|
+
warnings,
|
|
2082
|
+
};
|
|
2083
|
+
},
|
|
2084
|
+
};
|
|
2085
|
+
`;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
// src/scaffold/templates/checkers/test-checker.ts
|
|
2089
|
+
function generateTestChecker(params) {
|
|
2090
|
+
return `import { existsSync, readFileSync } from 'node:fs';
|
|
2091
|
+
import { join } from 'node:path';
|
|
2092
|
+
import { glob } from 'glob';
|
|
2093
|
+
import type { ExternalChecker, CheckResult } from 'speckeeper';
|
|
2094
|
+
import type { ${params.sourceModelName} } from '../_models/${params.sourceModelFile}.ts';
|
|
2095
|
+
|
|
2096
|
+
/**
|
|
2097
|
+
* Test Checker: verifies that ${params.sourceModelName} items have
|
|
2098
|
+
* corresponding test code that exists and references the spec ID.
|
|
2099
|
+
*
|
|
2100
|
+
* Checks:
|
|
2101
|
+
* 1. Test file(s) exist at the expected path
|
|
2102
|
+
* 2. Test file content references the spec ID (in describe/it/test blocks or embedoc markers)
|
|
2103
|
+
*/
|
|
2104
|
+
export const ${toCamelCase(params.checkerName)}: ExternalChecker<${params.sourceModelName}> = {
|
|
2105
|
+
targetType: '${params.targetType}',
|
|
2106
|
+
|
|
2107
|
+
sourcePath: () => '.',
|
|
2108
|
+
|
|
2109
|
+
check: (spec, _externalData): CheckResult => {
|
|
2110
|
+
const errors: CheckResult['errors'] = [];
|
|
2111
|
+
const warnings: CheckResult['warnings'] = [];
|
|
2112
|
+
const basePath = process.cwd();
|
|
2113
|
+
|
|
2114
|
+
// TODO: configure test file path pattern per spec
|
|
2115
|
+
// For now, search common test directories
|
|
2116
|
+
const testPatterns = [
|
|
2117
|
+
'test/**/*.test.ts',
|
|
2118
|
+
'test/**/*.spec.ts',
|
|
2119
|
+
'tests/**/*.test.ts',
|
|
2120
|
+
'tests/**/*.spec.ts',
|
|
2121
|
+
];
|
|
2122
|
+
|
|
2123
|
+
let testFiles: string[] = [];
|
|
2124
|
+
for (const pattern of testPatterns) {
|
|
2125
|
+
testFiles = testFiles.concat(glob.sync(pattern, { cwd: basePath }));
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
if (testFiles.length === 0) {
|
|
2129
|
+
warnings.push({
|
|
2130
|
+
message: \`No test files found for \${spec.id}\`,
|
|
2131
|
+
specId: spec.id,
|
|
2132
|
+
});
|
|
2133
|
+
return { success: true, errors, warnings };
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// Check if spec ID is referenced in any test file
|
|
2137
|
+
let specIdFound = false;
|
|
2138
|
+
for (const testFile of testFiles) {
|
|
2139
|
+
const fullPath = join(basePath, testFile);
|
|
2140
|
+
try {
|
|
2141
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
2142
|
+
const patterns = [
|
|
2143
|
+
new RegExp(\`describe\\\\s*\\\\(\\\\s*['\\\`"].*\${spec.id}\`, 'm'),
|
|
2144
|
+
new RegExp(\`it\\\\s*\\\\(\\\\s*['\\\`"].*\${spec.id}\`, 'm'),
|
|
2145
|
+
new RegExp(\`test\\\\s*\\\\(\\\\s*['\\\`"].*\${spec.id}\`, 'm'),
|
|
2146
|
+
new RegExp(\`@embedoc.*\${spec.id}\`, 'm'),
|
|
2147
|
+
];
|
|
2148
|
+
if (patterns.some(p => p.test(content))) {
|
|
2149
|
+
specIdFound = true;
|
|
2150
|
+
break;
|
|
2151
|
+
}
|
|
2152
|
+
} catch {
|
|
2153
|
+
// skip unreadable files
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
if (!specIdFound) {
|
|
2158
|
+
warnings.push({
|
|
2159
|
+
message: \`Spec ID "\${spec.id}" not found in any test file \u2014 test should reference the spec ID in describe/it/test or embedoc marker\`,
|
|
2160
|
+
specId: spec.id,
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
return {
|
|
2165
|
+
success: errors.length === 0,
|
|
2166
|
+
errors,
|
|
2167
|
+
warnings,
|
|
2168
|
+
};
|
|
2169
|
+
},
|
|
2170
|
+
};
|
|
2171
|
+
`;
|
|
2172
|
+
}
|
|
2173
|
+
function toCamelCase(s) {
|
|
2174
|
+
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// src/scaffold/templates/checkers/base-checker.ts
|
|
2178
|
+
function generateBaseChecker(params) {
|
|
2179
|
+
return `import type { ExternalChecker, CheckResult } from 'speckeeper';
|
|
2180
|
+
import type { ${params.sourceModelName} } from '../_models/${params.sourceModelFile}.ts';
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* ${params.checkerName}: verifies ${params.sourceModelName} against external ${params.targetType} artifacts.
|
|
2184
|
+
*
|
|
2185
|
+
* Generated by speckeeper scaffold \u2014 implement check logic.
|
|
2186
|
+
*/
|
|
2187
|
+
export const ${toCamelCase2(params.checkerName)}: ExternalChecker<${params.sourceModelName}> = {
|
|
2188
|
+
targetType: '${params.targetType}',
|
|
2189
|
+
|
|
2190
|
+
sourcePath: () => '.',
|
|
2191
|
+
|
|
2192
|
+
check: (spec, _externalData): CheckResult => {
|
|
2193
|
+
const errors: CheckResult['errors'] = [];
|
|
2194
|
+
const warnings: CheckResult['warnings'] = [];
|
|
2195
|
+
|
|
2196
|
+
// TODO: implement check logic for ${params.targetType}
|
|
2197
|
+
warnings.push({
|
|
2198
|
+
message: \`${params.checkerName} not implemented for \${spec.id}\`,
|
|
2199
|
+
specId: spec.id,
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
return {
|
|
2203
|
+
success: errors.length === 0,
|
|
2204
|
+
errors,
|
|
2205
|
+
warnings,
|
|
2206
|
+
};
|
|
2207
|
+
},
|
|
2208
|
+
};
|
|
2209
|
+
`;
|
|
2210
|
+
}
|
|
2211
|
+
function toCamelCase2(s) {
|
|
2212
|
+
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// src/scaffold/templates/index.ts
|
|
2216
|
+
var MODEL_TEMPLATE_FUNCTIONS = {
|
|
2217
|
+
requirement: generateRequirementModel,
|
|
2218
|
+
usecase: generateUseCaseModel,
|
|
2219
|
+
term: generateTermModel,
|
|
2220
|
+
entity: generateEntityModel,
|
|
2221
|
+
"logical-entity": generateLogicalEntityModel,
|
|
2222
|
+
"acceptance-test": generateAcceptanceTestModel,
|
|
2223
|
+
"data-test": generateDataTestModel,
|
|
2224
|
+
"validation-constraint": generateValidationConstraintModel,
|
|
2225
|
+
base: generateBaseModel
|
|
2226
|
+
};
|
|
2227
|
+
var CHECKER_TEMPLATE_FUNCTIONS = {
|
|
2228
|
+
"ddl-checker": generateDdlChecker,
|
|
2229
|
+
"openapi-checker": generateOpenapiChecker,
|
|
2230
|
+
"test-checker": generateTestChecker,
|
|
2231
|
+
"base-checker": generateBaseChecker
|
|
2232
|
+
};
|
|
2233
|
+
|
|
2234
|
+
// src/scaffold/model-generator.ts
|
|
2235
|
+
function generateModelFile(node, _incomingEdges, _outgoingEdges) {
|
|
2236
|
+
const templateInfo = resolveModelTemplate(node.id);
|
|
2237
|
+
const templateFn = MODEL_TEMPLATE_FUNCTIONS[templateInfo.templateName];
|
|
2238
|
+
if (!templateFn) {
|
|
2239
|
+
const baseFn = MODEL_TEMPLATE_FUNCTIONS["base"];
|
|
2240
|
+
return {
|
|
2241
|
+
relativePath: `_models/${templateInfo.fileName}.ts`,
|
|
2242
|
+
content: baseFn({
|
|
2243
|
+
modelId: templateInfo.fileName,
|
|
2244
|
+
modelName: templateInfo.modelName,
|
|
2245
|
+
idPrefix: templateInfo.defaultIdPrefix,
|
|
2246
|
+
level: templateInfo.defaultLevel,
|
|
2247
|
+
description: node.label ?? node.id
|
|
2248
|
+
})
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
const content = templateFn({
|
|
2252
|
+
modelId: templateInfo.fileName,
|
|
2253
|
+
modelName: templateInfo.modelName,
|
|
2254
|
+
idPrefix: templateInfo.defaultIdPrefix,
|
|
2255
|
+
level: templateInfo.defaultLevel,
|
|
2256
|
+
description: node.label ?? node.id
|
|
2257
|
+
});
|
|
2258
|
+
return {
|
|
2259
|
+
relativePath: `_models/${templateInfo.fileName}.ts`,
|
|
2260
|
+
content
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
function generateSpecDataFile(node) {
|
|
2264
|
+
const templateInfo = resolveModelTemplate(node.id);
|
|
2265
|
+
const className = `${templateInfo.modelName}Model`;
|
|
2266
|
+
const typeName = templateInfo.modelName;
|
|
2267
|
+
const varName = toCamelCase3(templateInfo.modelName) + "s";
|
|
2268
|
+
const fileName = templateInfo.fileName;
|
|
2269
|
+
const content = `/**
|
|
2270
|
+
* ${templateInfo.modelName} Specifications
|
|
2271
|
+
*
|
|
2272
|
+
* Generated by speckeeper scaffold \u2014 add your specs below.
|
|
2273
|
+
*/
|
|
2274
|
+
import type { ${typeName} } from './_models/${fileName}';
|
|
2275
|
+
import { ${className} } from './_models/${fileName}';
|
|
2276
|
+
|
|
2277
|
+
export const ${varName}: ${typeName}[] = [
|
|
2278
|
+
// { id: '${templateInfo.defaultIdPrefix}-001', name: '...', description: '...' },
|
|
2279
|
+
];
|
|
2280
|
+
|
|
2281
|
+
${className}.instance.register(${varName});
|
|
2282
|
+
`;
|
|
2283
|
+
return {
|
|
2284
|
+
relativePath: `${fileName}.ts`,
|
|
2285
|
+
content
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
function toCamelCase3(pascalCase) {
|
|
2289
|
+
return pascalCase.charAt(0).toLowerCase() + pascalCase.slice(1);
|
|
2290
|
+
}
|
|
2291
|
+
function generateAllModelFiles(speckeeperNodes, resolvedEdges) {
|
|
2292
|
+
const files = [];
|
|
2293
|
+
const generated = /* @__PURE__ */ new Set();
|
|
2294
|
+
for (const node of speckeeperNodes) {
|
|
2295
|
+
const templateInfo = resolveModelTemplate(node.id);
|
|
2296
|
+
const key = templateInfo.templateName === "base" ? `base:${node.id}` : templateInfo.templateName;
|
|
2297
|
+
if (generated.has(key)) continue;
|
|
2298
|
+
generated.add(key);
|
|
2299
|
+
resolvedEdges.filter((e) => e.targetId === node.id);
|
|
2300
|
+
resolvedEdges.filter((e) => e.sourceId === node.id);
|
|
2301
|
+
files.push(generateModelFile(node));
|
|
2302
|
+
files.push(generateSpecDataFile(node));
|
|
2303
|
+
}
|
|
2304
|
+
return files;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// src/scaffold/checker-generator.ts
|
|
2308
|
+
function findCheckerEdges(resolvedEdges, nodes, speckeeperClassName) {
|
|
2309
|
+
const isSpk = (id) => nodes.get(id)?.classes.includes(speckeeperClassName) ?? false;
|
|
2310
|
+
return resolvedEdges.filter((edge) => {
|
|
2311
|
+
if (!isCheckEdge(edge.vocabulary)) return false;
|
|
2312
|
+
if (!isSpk(edge.sourceId)) return false;
|
|
2313
|
+
if (isSpk(edge.targetId)) return false;
|
|
2314
|
+
return true;
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
function generateCheckerFile(edge, _nodes) {
|
|
2318
|
+
const checkerInfo = resolveCheckerTemplate(edge.targetId);
|
|
2319
|
+
const sourceTemplateInfo = resolveModelTemplate(edge.sourceId);
|
|
2320
|
+
if (checkerInfo) {
|
|
2321
|
+
const templateFn = CHECKER_TEMPLATE_FUNCTIONS[checkerInfo.templateName];
|
|
2322
|
+
if (templateFn) {
|
|
2323
|
+
return {
|
|
2324
|
+
relativePath: `_checkers/${checkerInfo.fileName}.ts`,
|
|
2325
|
+
content: templateFn({
|
|
2326
|
+
checkerName: checkerInfo.fileName,
|
|
2327
|
+
targetType: checkerInfo.targetType,
|
|
2328
|
+
sourceModelName: sourceTemplateInfo.modelName,
|
|
2329
|
+
sourceModelFile: sourceTemplateInfo.fileName,
|
|
2330
|
+
description: `Checks ${sourceTemplateInfo.modelName} against ${edge.targetId}`
|
|
2331
|
+
})
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
const baseFn = CHECKER_TEMPLATE_FUNCTIONS["base-checker"];
|
|
2336
|
+
const targetId = edge.targetId.toLowerCase();
|
|
2337
|
+
const fileName = `${targetId}-checker`;
|
|
2338
|
+
return {
|
|
2339
|
+
relativePath: `_checkers/${fileName}.ts`,
|
|
2340
|
+
content: baseFn({
|
|
2341
|
+
checkerName: fileName,
|
|
2342
|
+
targetType: targetId,
|
|
2343
|
+
sourceModelName: sourceTemplateInfo.modelName,
|
|
2344
|
+
sourceModelFile: sourceTemplateInfo.fileName,
|
|
2345
|
+
description: `Checks ${sourceTemplateInfo.modelName} against ${edge.targetId}`
|
|
2346
|
+
})
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
function generateAllCheckerFiles(resolvedEdges, nodes, speckeeperClassName) {
|
|
2350
|
+
const checkerEdges = findCheckerEdges(resolvedEdges, nodes, speckeeperClassName);
|
|
2351
|
+
const files = [];
|
|
2352
|
+
const generated = /* @__PURE__ */ new Set();
|
|
2353
|
+
for (const edge of checkerEdges) {
|
|
2354
|
+
if (generated.has(edge.targetId)) continue;
|
|
2355
|
+
generated.add(edge.targetId);
|
|
2356
|
+
files.push(generateCheckerFile(edge));
|
|
2357
|
+
}
|
|
2358
|
+
return files;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// src/scaffold/index-generator.ts
|
|
2362
|
+
function extractExportedClassNames(content) {
|
|
2363
|
+
const match = content.match(/export\s*\{([^}]+)\}\s*;?\s*$/m);
|
|
2364
|
+
if (!match) return [];
|
|
2365
|
+
return match[1].split(",").map((s) => s.trim()).filter(Boolean);
|
|
2366
|
+
}
|
|
2367
|
+
function generateModelsIndex(modelFiles) {
|
|
2368
|
+
const entries = [];
|
|
2369
|
+
for (const file of modelFiles) {
|
|
2370
|
+
const fileName = file.relativePath.replace(/^_models\//, "").replace(/\.ts$/, "");
|
|
2371
|
+
const classNames = extractExportedClassNames(file.content);
|
|
2372
|
+
if (classNames.length > 0) {
|
|
2373
|
+
entries.push({ fileName, classNames });
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
entries.sort((a, b) => a.fileName.localeCompare(b.fileName));
|
|
2377
|
+
const lines = [];
|
|
2378
|
+
lines.push("/**");
|
|
2379
|
+
lines.push(" * Model index \u2014 generated by speckeeper scaffold");
|
|
2380
|
+
lines.push(" */");
|
|
2381
|
+
lines.push("");
|
|
2382
|
+
for (const entry of entries) {
|
|
2383
|
+
lines.push(`export { ${entry.classNames.join(", ")} } from './${entry.fileName}.js';`);
|
|
2384
|
+
}
|
|
2385
|
+
lines.push("");
|
|
2386
|
+
const modelInstanceNames = entries.flatMap(
|
|
2387
|
+
(e) => e.classNames.filter((n) => n.endsWith("Model"))
|
|
2388
|
+
);
|
|
2389
|
+
if (modelInstanceNames.length > 0) {
|
|
2390
|
+
lines.push("import { Model } from 'speckeeper';");
|
|
2391
|
+
lines.push("import type { ZodType } from 'zod';");
|
|
2392
|
+
lines.push("");
|
|
2393
|
+
for (const entry of entries) {
|
|
2394
|
+
const models = entry.classNames.filter((n) => n.endsWith("Model"));
|
|
2395
|
+
if (models.length > 0) {
|
|
2396
|
+
lines.push(`import { ${models.join(", ")} } from './${entry.fileName}.js';`);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
lines.push("");
|
|
2400
|
+
lines.push("// eslint-disable-next-line @typescript-eslint/no-explicit-any");
|
|
2401
|
+
lines.push("export const allModels: Model<ZodType>[] = [");
|
|
2402
|
+
for (const name of modelInstanceNames) {
|
|
2403
|
+
lines.push(` ${name}.instance as Model<ZodType>,`);
|
|
2404
|
+
}
|
|
2405
|
+
lines.push("];");
|
|
2406
|
+
}
|
|
2407
|
+
lines.push("");
|
|
2408
|
+
return {
|
|
2409
|
+
relativePath: "_models/index.ts",
|
|
2410
|
+
content: lines.join("\n")
|
|
2411
|
+
};
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// src/cli/scaffold.ts
|
|
2415
|
+
var SPECKEEPER_CLASS = "speckeeper";
|
|
2416
|
+
async function scaffoldCommand(options) {
|
|
2417
|
+
console.log(chalk4.blue("speckeeper scaffold"));
|
|
2418
|
+
console.log("");
|
|
2419
|
+
const sourcePath = resolve(options.source);
|
|
2420
|
+
if (!existsSync(sourcePath)) {
|
|
2421
|
+
console.error(chalk4.red(` Error: Source file not found: ${sourcePath}`));
|
|
2422
|
+
process.exit(1);
|
|
2423
|
+
}
|
|
2424
|
+
const markdown = readFileSync(sourcePath, "utf-8");
|
|
2425
|
+
const flowchart = parseMarkdownFlowchart(markdown);
|
|
2426
|
+
if (!flowchart) {
|
|
2427
|
+
console.error(chalk4.red(" Error: No mermaid flowchart found in the source file"));
|
|
2428
|
+
process.exit(1);
|
|
2429
|
+
}
|
|
2430
|
+
console.log(chalk4.gray(` Parsed: ${flowchart.nodes.size} nodes, ${flowchart.edges.length} edges`));
|
|
2431
|
+
const speckeeperNodes = [];
|
|
2432
|
+
for (const node of flowchart.nodes.values()) {
|
|
2433
|
+
if (node.classes.includes(SPECKEEPER_CLASS)) {
|
|
2434
|
+
speckeeperNodes.push(node);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
if (speckeeperNodes.length === 0) {
|
|
2438
|
+
console.error(chalk4.red(` Error: No nodes with class "${SPECKEEPER_CLASS}" found`));
|
|
2439
|
+
console.log(chalk4.gray(" Add `classDef speckeeper ...` and `class NODE1,NODE2 speckeeper` to your flowchart"));
|
|
2440
|
+
process.exit(1);
|
|
2441
|
+
}
|
|
2442
|
+
console.log(chalk4.gray(` speckeeper-managed nodes: ${speckeeperNodes.map((n) => n.id).join(", ")}`));
|
|
2443
|
+
const { resolved, diagnostics } = resolveEdges(
|
|
2444
|
+
flowchart.edges,
|
|
2445
|
+
flowchart.nodes,
|
|
2446
|
+
SPECKEEPER_CLASS
|
|
2447
|
+
);
|
|
2448
|
+
printDiagnostics(diagnostics);
|
|
2449
|
+
const outputDir = options.output ? resolve(options.output) : join(process.cwd(), "design");
|
|
2450
|
+
const modelFiles = generateAllModelFiles(speckeeperNodes, resolved);
|
|
2451
|
+
const checkerFiles = generateAllCheckerFiles(resolved, flowchart.nodes, SPECKEEPER_CLASS);
|
|
2452
|
+
const indexFile = generateModelsIndex(modelFiles);
|
|
2453
|
+
const allFiles = [...modelFiles, indexFile, ...checkerFiles];
|
|
2454
|
+
console.log("");
|
|
2455
|
+
console.log(chalk4.cyan(` Files to generate (${allFiles.length}):`));
|
|
2456
|
+
for (const file of allFiles) {
|
|
2457
|
+
console.log(chalk4.gray(` ${file.relativePath}`));
|
|
2458
|
+
}
|
|
2459
|
+
if (options.dryRun) {
|
|
2460
|
+
console.log("");
|
|
2461
|
+
console.log(chalk4.yellow(" Dry run \u2014 no files written"));
|
|
2462
|
+
console.log("");
|
|
2463
|
+
for (const file of allFiles) {
|
|
2464
|
+
console.log(chalk4.cyan(` === ${file.relativePath} ===`));
|
|
2465
|
+
console.log(file.content);
|
|
2466
|
+
}
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
console.log("");
|
|
2470
|
+
let created = 0;
|
|
2471
|
+
let skipped = 0;
|
|
2472
|
+
for (const file of allFiles) {
|
|
2473
|
+
const fullPath = join(outputDir, file.relativePath);
|
|
2474
|
+
const dir = dirname(fullPath);
|
|
2475
|
+
if (!existsSync(dir)) {
|
|
2476
|
+
mkdirSync(dir, { recursive: true });
|
|
2477
|
+
}
|
|
2478
|
+
if (existsSync(fullPath) && !options.force) {
|
|
2479
|
+
console.log(chalk4.yellow(` Skipped: ${file.relativePath} (already exists, use --force to overwrite)`));
|
|
2480
|
+
skipped++;
|
|
2481
|
+
continue;
|
|
2482
|
+
}
|
|
2483
|
+
writeFileSync(fullPath, file.content);
|
|
2484
|
+
console.log(chalk4.green(` Created: ${file.relativePath}`));
|
|
2485
|
+
created++;
|
|
2486
|
+
}
|
|
2487
|
+
console.log("");
|
|
2488
|
+
console.log(chalk4.cyan(` Done: ${created} created, ${skipped} skipped`));
|
|
2489
|
+
}
|
|
2490
|
+
function printDiagnostics(diagnostics) {
|
|
2491
|
+
if (diagnostics.length === 0) return;
|
|
2492
|
+
console.log("");
|
|
2493
|
+
console.log(chalk4.cyan(" Diagnostics:"));
|
|
2494
|
+
for (const diag of diagnostics) {
|
|
2495
|
+
const prefix = diag.severity === "error" ? chalk4.red(" ERROR") : diag.severity === "warning" ? chalk4.yellow(" WARN ") : chalk4.gray(" INFO ");
|
|
2496
|
+
const ctx = diag.context ? chalk4.gray(` [${diag.context}]`) : "";
|
|
2497
|
+
console.log(`${prefix} ${diag.message}${ctx}`);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
1098
2501
|
// src/cli/index.ts
|
|
1099
2502
|
var program = new Command();
|
|
1100
2503
|
program.name("speckeeper").description("Requirements and design management framework with TypeScript DSL").version("0.1.0");
|
|
@@ -1105,6 +2508,7 @@ program.command("check").description("Check external SSOT conformance (including
|
|
|
1105
2508
|
program.command("new").description("Create a new element with auto-generated ID").argument("<type>", "Type: requirement, usecase, entity, component, screen, flow, error-case, term").option("-k, --kind <kind>", "Sub-kind (e.g., functional, non-functional for requirements)").option("-n, --name <name>", "Name of the element").option("-o, --output <path>", "Output directory path").option("-t, --template <path>", "Path to template file").action(newCommand);
|
|
1106
2509
|
program.command("impact").description("Analyze impact of changes to an ID").argument("<id>", "ID to analyze (e.g., REQ-001, ENT-ORDER)").option("-c, --config <path>", "Path to config file").option("-d, --depth <depth>", "Analysis depth (reference tracking level)", "3").option("--direction <direction>", "Analysis direction: upstream, downstream, both", "both").option("-f, --format <format>", "Output format: text, json, mermaid", "text").action(impactCommand);
|
|
1107
2510
|
program.command("init").description("Initialize a new speckeeper project with starter templates").option("-f, --force", "Overwrite existing files").action(runInit);
|
|
2511
|
+
program.command("scaffold").description("Generate _models/ and _checkers/ from a mermaid flowchart definition").requiredOption("-s, --source <path>", "Path to Markdown file containing mermaid flowchart").option("-o, --output <path>", "Output directory (default: design/)").option("-f, --force", "Overwrite existing files").option("--dry-run", "Preview generated files without writing").action(scaffoldCommand);
|
|
1108
2512
|
program.parse();
|
|
1109
2513
|
//# sourceMappingURL=cli.js.map
|
|
1110
2514
|
//# sourceMappingURL=cli.js.map
|