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/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, relative, resolve, extname, basename } from 'path';
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. Run model registration first."));
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
- const { registry, loadedFiles, errors: loadErrors } = await loadAllModels(config, cwd);
456
- if (loadErrors.length > 0) {
457
- console.log(chalk4.yellow(` \u26A0 ${loadErrors.length} file(s) failed to load`));
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(registry, options);
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(registry, options) {
261
+ function runModelLint(options) {
477
262
  const issues = [];
478
263
  const models = getAllModels();
479
264
  for (const model of models) {
480
- const specs = getSpecsFromRegistry(registry, model.id);
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
- const { registry } = await loadAllModels(config, cwd);
327
+ registerModelsFromConfig(config.models || []);
561
328
  const results = [];
562
329
  const models = getAllModels();
563
330
  for (const model of models) {
564
- const specs = getSpecsFromRegistry2(registry, model.id);
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
- const { registry } = await loadAllModels(config, cwd);
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 = getSpecsFromRegistry3(registry, model.id);
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(registry);
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(registry) {
531
+ function runAllCoverageChecks() {
799
532
  const models = getAllModels();
800
533
  const results = [];
801
- const registryRecord = {};
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 = getSpecsFromRegistry3(registry, model.id);
811
- const result = model.checkCoverage(specs, registryRecord);
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
- const { registry } = await loadAllModels(config, cwd);
930
- const target = findById(registry, targetId);
931
- if (!target) {
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(registry, targetId, target.type, maxDepth);
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 findById(registry, id) {
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 Object.entries(registry)) {
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