speckeeper 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, relative, join, resolve, extname, basename } 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,13 @@ 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
+ }
130
+ function resetSpecStore() {
131
+ specStore.clear();
132
+ }
126
133
  var modelRegistry = /* @__PURE__ */ new Map();
127
134
  function registerModel(model) {
128
135
  modelRegistry.set(model.id, model);
@@ -130,6 +137,103 @@ function registerModel(model) {
130
137
  function getAllModels() {
131
138
  return Array.from(modelRegistry.values());
132
139
  }
140
+ function clearModelRegistry() {
141
+ modelRegistry.clear();
142
+ }
143
+ function isModelInstance(value) {
144
+ if (!value || typeof value !== "object") return false;
145
+ const obj = value;
146
+ 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";
147
+ }
148
+ function registerExportedModelInstances(module) {
149
+ for (const [, exportValue] of Object.entries(module)) {
150
+ if (!exportValue || typeof exportValue !== "object") continue;
151
+ if (isModelInstance(exportValue)) {
152
+ registerModel(exportValue);
153
+ }
154
+ }
155
+ }
156
+ async function loadModelsFromDirectory(dirPath, options = {}) {
157
+ const loadedFiles = [];
158
+ if (!existsSync(dirPath)) {
159
+ return loadedFiles;
160
+ }
161
+ const entries = readdirSync(dirPath);
162
+ for (const entry of entries) {
163
+ const fullPath = join(dirPath, entry);
164
+ const stat = statSync(fullPath);
165
+ if (stat.isDirectory() && options.recursive) {
166
+ const subFiles = await loadModelsFromDirectory(fullPath, options);
167
+ loadedFiles.push(...subFiles);
168
+ } else if (stat.isFile()) {
169
+ const ext = extname(entry);
170
+ if ([".ts", ".js", ".mts", ".mjs"].includes(ext) && !entry.endsWith(".d.ts")) {
171
+ if (basename(entry, ext) === "index") {
172
+ continue;
173
+ }
174
+ try {
175
+ const module = await import(fullPath);
176
+ loadedFiles.push(fullPath);
177
+ registerExportedModelInstances(module);
178
+ } catch (error) {
179
+ console.error(`Failed to load from ${fullPath}:`, error);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ return loadedFiles;
185
+ }
186
+ async function loadAllModels(config, cwd = process.cwd()) {
187
+ resetSpecStore();
188
+ clearModelRegistry();
189
+ const loadedFiles = [];
190
+ const errors = [];
191
+ if (config.models && Array.isArray(config.models)) {
192
+ for (const model of config.models) {
193
+ if (isModelInstance(model)) {
194
+ registerModel(model);
195
+ }
196
+ }
197
+ }
198
+ const designDir = resolve(cwd, config.designDir || "design");
199
+ const modelDirs = [
200
+ { path: join(designDir, "_models") },
201
+ { path: join(designDir, "requirements") },
202
+ { path: join(designDir, "usecases") },
203
+ { path: join(designDir, "architecture") },
204
+ { path: join(designDir, "data-model") },
205
+ { path: join(designDir, "screens") },
206
+ { path: join(designDir, "flows") },
207
+ { path: join(designDir, "glossary") },
208
+ { path: designDir }
209
+ ];
210
+ for (const { path: dirPath } of modelDirs) {
211
+ if (!existsSync(dirPath)) {
212
+ continue;
213
+ }
214
+ try {
215
+ const files = await loadModelsFromDirectory(dirPath, { recursive: true });
216
+ loadedFiles.push(...files);
217
+ } catch (error) {
218
+ errors.push({ file: dirPath, error });
219
+ }
220
+ }
221
+ const store = getSpecStore();
222
+ const registry = {};
223
+ for (const [modelId, specMap] of store) {
224
+ registry[modelId] = specMap;
225
+ }
226
+ return {
227
+ registry,
228
+ loadedFiles,
229
+ errors
230
+ };
231
+ }
232
+ function getSpecsFromRegistry(registry, modelId) {
233
+ const map = registry[modelId];
234
+ if (!map) return [];
235
+ return Array.from(map.values());
236
+ }
133
237
 
134
238
  // src/cli/build.ts
135
239
  async function buildCommand(options) {
@@ -142,12 +246,22 @@ async function buildCommand(options) {
142
246
  console.log(chalk4.gray(` Specs: ${config.specsDir}/`));
143
247
  console.log("");
144
248
  try {
249
+ console.log(chalk4.blue(" Loading models..."));
250
+ const { registry, loadedFiles, errors: loadErrors } = await loadAllModels(config, cwd);
251
+ if (loadErrors.length > 0) {
252
+ console.log(chalk4.yellow(` \u26A0 ${loadErrors.length} file(s) failed to load`));
253
+ for (const { file, error } of loadErrors) {
254
+ console.log(chalk4.yellow(` - ${relative(cwd, file)}: ${error}`));
255
+ }
256
+ }
257
+ console.log(chalk4.gray(` Loaded: ${loadedFiles.length} files`));
258
+ console.log("");
145
259
  ensureDir(join(cwd, config.docsDir));
146
260
  ensureDir(join(cwd, config.specsDir));
147
261
  const files = [];
148
262
  const models = getAllModels();
149
263
  if (models.length === 0) {
150
- console.log(chalk4.yellow(" No models registered. Run model registration first."));
264
+ console.log(chalk4.yellow(" No models registered. Add models to speckeeper.config.ts."));
151
265
  return;
152
266
  }
153
267
  console.log(chalk4.blue(` Processing ${models.length} model types...`));
@@ -159,7 +273,7 @@ async function buildCommand(options) {
159
273
  }
160
274
  continue;
161
275
  }
162
- const specs = [];
276
+ const specs = getSpecsFromRegistry(registry, model.id);
163
277
  if (specs.length === 0) {
164
278
  if (options.verbose) {
165
279
  console.log(chalk4.gray(` ${model.name}: no specs found`));
@@ -206,240 +320,6 @@ async function buildCommand(options) {
206
320
  process.exit(1);
207
321
  }
208
322
  }
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
323
  async function lintCommand(options) {
444
324
  console.log(chalk4.blue("speckeeper lint"));
445
325
  console.log("");
@@ -503,24 +383,6 @@ function runModelLint(registry, options) {
503
383
  }
504
384
  return { issues, errors, warnings, infos };
505
385
  }
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
386
  function outputLintResults(result, options) {
525
387
  if (options.format === "json") {
526
388
  console.log(JSON.stringify(result, null, 2));
@@ -561,7 +423,7 @@ async function driftCommand(options) {
561
423
  const results = [];
562
424
  const models = getAllModels();
563
425
  for (const model of models) {
564
- const specs = getSpecsFromRegistry2(registry, model.id);
426
+ const specs = getSpecsFromRegistry(registry, model.id);
565
427
  if (specs.length === 0) continue;
566
428
  for (const exporter of model.getExporters()) {
567
429
  if (exporter.format !== "markdown") continue;
@@ -609,21 +471,6 @@ async function driftCommand(options) {
609
471
  process.exit(1);
610
472
  }
611
473
  }
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
474
  function normalizeContent(content) {
628
475
  return content.trim().replace(/\r\n/g, "\n");
629
476
  }
@@ -670,7 +517,7 @@ async function checkCommand(type, options) {
670
517
  console.log(chalk4.gray(` Registered models: ${models.map((m) => m.id).join(", ")}`));
671
518
  }
672
519
  for (const model of models) {
673
- const specs = getSpecsFromRegistry3(registry, model.id);
520
+ const specs = getSpecsFromRegistry(registry, model.id);
674
521
  if (specs.length === 0) continue;
675
522
  for (const spec of specs) {
676
523
  const sourcePath = model.getExternalSourcePath(spec);
@@ -745,25 +592,6 @@ async function checkCommand(type, options) {
745
592
  process.exit(1);
746
593
  }
747
594
  }
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
595
  function loadExternalData(filePath) {
768
596
  const content = readFileSync(filePath, "utf-8");
769
597
  if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
@@ -798,17 +626,11 @@ function outputCheckResults(results) {
798
626
  function runAllCoverageChecks(registry) {
799
627
  const models = getAllModels();
800
628
  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
- }
807
629
  for (const model of models) {
808
630
  const checker = model.getCoverageChecker();
809
631
  if (checker) {
810
- const specs = getSpecsFromRegistry3(registry, model.id);
811
- const result = model.checkCoverage(specs, registryRecord);
632
+ const specs = getSpecsFromRegistry(registry, model.id);
633
+ const result = model.checkCoverage(specs, registry);
812
634
  if (result) {
813
635
  results.push({
814
636
  modelId: model.id,
@@ -1095,6 +917,1690 @@ function copyTemplateDir(srcDir, destDir, projectName, force, onFile, relativePa
1095
917
  }
1096
918
  }
1097
919
 
920
+ // src/scaffold/mermaid-parser.ts
921
+ function extractMermaidBlocks(markdown) {
922
+ const blocks = [];
923
+ const regex = /```mermaid\s*\n([\s\S]*?)```/g;
924
+ let match;
925
+ while ((match = regex.exec(markdown)) !== null) {
926
+ blocks.push(match[1]);
927
+ }
928
+ return blocks;
929
+ }
930
+ function parseFlowchart(mermaidSource) {
931
+ const lines = mermaidSource.split("\n");
932
+ const nodes = /* @__PURE__ */ new Map();
933
+ const edges = [];
934
+ const classDefs = [];
935
+ const classAssignments = [];
936
+ let direction = "TB";
937
+ for (const rawLine of lines) {
938
+ const line = rawLine.trim();
939
+ if (!line || line.startsWith("%%")) continue;
940
+ const flowchartMatch = line.match(/^flowchart\s+(\w+)/);
941
+ if (flowchartMatch) {
942
+ direction = flowchartMatch[1];
943
+ continue;
944
+ }
945
+ if (line.startsWith("graph ")) {
946
+ const gMatch = line.match(/^graph\s+(\w+)/);
947
+ if (gMatch) direction = gMatch[1];
948
+ continue;
949
+ }
950
+ const classDefMatch = line.match(/^classDef\s+(\S+)\s+(.*)/);
951
+ if (classDefMatch) {
952
+ classDefs.push({ name: classDefMatch[1], styles: classDefMatch[2] });
953
+ continue;
954
+ }
955
+ const classAssignMatch = line.match(/^class\s+(\S+)\s+(\S+)/);
956
+ if (classAssignMatch) {
957
+ const nodeIds = classAssignMatch[1].split(",").map((s) => s.trim());
958
+ classAssignments.push({ nodeIds, className: classAssignMatch[2] });
959
+ for (const id of nodeIds) {
960
+ ensureNode(nodes, id);
961
+ const node = nodes.get(id);
962
+ if (!node.classes.includes(classAssignMatch[2])) {
963
+ node.classes.push(classAssignMatch[2]);
964
+ }
965
+ }
966
+ continue;
967
+ }
968
+ const edgeParsed = tryParseEdgeLine(line);
969
+ if (edgeParsed) {
970
+ for (const { sourceId, sourceLabel, targetId, targetLabel, label, direction: dir } of edgeParsed) {
971
+ ensureNode(nodes, sourceId, sourceLabel);
972
+ ensureNode(nodes, targetId, targetLabel);
973
+ edges.push({
974
+ sourceId,
975
+ targetId,
976
+ rawLabel: label,
977
+ direction: dir
978
+ });
979
+ }
980
+ continue;
981
+ }
982
+ const standaloneNode = tryParseStandaloneNode(line);
983
+ if (standaloneNode) {
984
+ ensureNode(nodes, standaloneNode.id, standaloneNode.label);
985
+ }
986
+ }
987
+ return { direction, nodes, edges, classDefs, classAssignments };
988
+ }
989
+ function parseMarkdownFlowchart(markdown) {
990
+ const blocks = extractMermaidBlocks(markdown);
991
+ for (const block of blocks) {
992
+ const trimmed = block.trim();
993
+ if (trimmed.startsWith("flowchart") || trimmed.startsWith("graph")) {
994
+ return parseFlowchart(trimmed);
995
+ }
996
+ }
997
+ return null;
998
+ }
999
+ function ensureNode(nodes, id, label) {
1000
+ const existing = nodes.get(id);
1001
+ if (existing) {
1002
+ if (label && !existing.label) {
1003
+ existing.label = label;
1004
+ }
1005
+ } else {
1006
+ nodes.set(id, { id, label, classes: [] });
1007
+ }
1008
+ }
1009
+ var NODE_PATTERN = /([A-Za-z_]\w*)(?:\[([^\]]*)\])?/;
1010
+ function tryParseEdgeLine(line) {
1011
+ const arrowPatterns = [
1012
+ { regex: /<-{2,}>/, direction: "bidirectional" },
1013
+ { regex: /<={2,}>/, direction: "bidirectional" },
1014
+ { regex: /-{2,}>/, direction: "forward" },
1015
+ { regex: /={2,}>/, direction: "forward" },
1016
+ { regex: /-\.->/, direction: "forward" }
1017
+ ];
1018
+ let bestMatch = null;
1019
+ for (const ap of arrowPatterns) {
1020
+ const m = line.match(ap.regex);
1021
+ if (m && m.index !== void 0) {
1022
+ if (!bestMatch || m.index < bestMatch.index) {
1023
+ bestMatch = { index: m.index, length: m[0].length, direction: ap.direction };
1024
+ }
1025
+ }
1026
+ }
1027
+ if (!bestMatch) return null;
1028
+ const leftPart = line.substring(0, bestMatch.index).trim();
1029
+ const rightPart = line.substring(bestMatch.index + bestMatch.length).trim();
1030
+ const sourceMatch = leftPart.match(NODE_PATTERN);
1031
+ if (!sourceMatch) return null;
1032
+ const sourceId = sourceMatch[1];
1033
+ const sourceLabel = sourceMatch[2] || void 0;
1034
+ let label;
1035
+ let targetStr = rightPart;
1036
+ const labelMatch = rightPart.match(/^\|([^|]*)\|\s*(.*)/);
1037
+ if (labelMatch) {
1038
+ label = labelMatch[1].trim();
1039
+ targetStr = labelMatch[2];
1040
+ }
1041
+ const targetMatch = targetStr.match(NODE_PATTERN);
1042
+ if (!targetMatch) return null;
1043
+ const targetId = targetMatch[1];
1044
+ const targetLabel = targetMatch[2] || void 0;
1045
+ return [{
1046
+ sourceId,
1047
+ sourceLabel,
1048
+ targetId,
1049
+ targetLabel,
1050
+ label,
1051
+ direction: bestMatch.direction
1052
+ }];
1053
+ }
1054
+ function tryParseStandaloneNode(line) {
1055
+ const m = line.match(/^([A-Za-z_]\w*)\[([^\]]*)\]\s*$/);
1056
+ if (m) {
1057
+ return { id: m[1], label: m[2] };
1058
+ }
1059
+ return null;
1060
+ }
1061
+
1062
+ // src/scaffold/edge-vocabulary.ts
1063
+ var EDGE_VOCABULARY = [
1064
+ // A. lint targets (speckeeper ↔ speckeeper reference integrity)
1065
+ {
1066
+ label: "refines",
1067
+ expectedDirection: "forward",
1068
+ relationType: "refines",
1069
+ category: "lint",
1070
+ description: "Decompose higher-level item into lower-level detail"
1071
+ },
1072
+ {
1073
+ label: "relatedTo",
1074
+ expectedDirection: "bidirectional",
1075
+ relationType: "relatedTo",
1076
+ category: "lint",
1077
+ description: "Bidirectional association or consistency constraint"
1078
+ },
1079
+ {
1080
+ label: "uses",
1081
+ expectedDirection: "forward",
1082
+ relationType: "uses",
1083
+ category: "lint",
1084
+ description: "Reference / dependency"
1085
+ },
1086
+ // B. check targets (speckeeper → external SSOT)
1087
+ {
1088
+ label: "implements",
1089
+ expectedDirection: "forward",
1090
+ relationType: "implements",
1091
+ category: "check",
1092
+ description: "Realize speckeeper spec as external artifact, interface, or test"
1093
+ },
1094
+ // C. coverage targets
1095
+ {
1096
+ label: "includes",
1097
+ expectedDirection: "forward",
1098
+ relationType: "includes",
1099
+ category: "coverage",
1100
+ description: "Parent contains child items"
1101
+ },
1102
+ {
1103
+ label: "traces",
1104
+ expectedDirection: "forward",
1105
+ relationType: "traces",
1106
+ category: "coverage",
1107
+ description: "Derive target from source"
1108
+ },
1109
+ {
1110
+ label: "verifies",
1111
+ expectedDirection: "forward",
1112
+ relationType: "verifies",
1113
+ category: "coverage",
1114
+ description: "Test verifies target"
1115
+ },
1116
+ // Additional RELATION_TYPES (usable but no special scaffold behaviour)
1117
+ {
1118
+ label: "dependsOn",
1119
+ expectedDirection: "forward",
1120
+ relationType: "dependsOn",
1121
+ category: "lint",
1122
+ description: "Dependency relationship"
1123
+ },
1124
+ {
1125
+ label: "satisfies",
1126
+ expectedDirection: "forward",
1127
+ relationType: "satisfies",
1128
+ category: "lint",
1129
+ description: "Satisfies business/requirements"
1130
+ }
1131
+ ];
1132
+ var EXTERNAL_LABELS = [
1133
+ {
1134
+ label: "generate",
1135
+ expectedDirection: "forward",
1136
+ relationType: void 0,
1137
+ category: "drift",
1138
+ description: "Automated generation (external tool)"
1139
+ },
1140
+ {
1141
+ label: "apply",
1142
+ expectedDirection: "forward",
1143
+ relationType: void 0,
1144
+ category: "external",
1145
+ description: "Apply to external system"
1146
+ },
1147
+ {
1148
+ label: "deploy",
1149
+ expectedDirection: "forward",
1150
+ relationType: void 0,
1151
+ category: "external",
1152
+ description: "Deploy artifact"
1153
+ }
1154
+ ];
1155
+ var ALL_VOCABULARY = [...EDGE_VOCABULARY, ...EXTERNAL_LABELS];
1156
+ var SORTED_VOCABULARY = [...ALL_VOCABULARY].sort(
1157
+ (a, b) => b.label.length - a.label.length
1158
+ );
1159
+ var SORTED_SPECKEEPER_VOCABULARY = [...EDGE_VOCABULARY].sort(
1160
+ (a, b) => b.label.length - a.label.length
1161
+ );
1162
+ function normalizeLabel(rawLabel, vocabulary = SORTED_VOCABULARY) {
1163
+ if (!rawLabel) return null;
1164
+ const lower = rawLabel.toLowerCase().trim();
1165
+ for (const entry of vocabulary) {
1166
+ if (lower === entry.label.toLowerCase()) {
1167
+ return { entry, modifier: void 0 };
1168
+ }
1169
+ }
1170
+ for (const entry of vocabulary) {
1171
+ const entryLower = entry.label.toLowerCase();
1172
+ if (lower.endsWith(entryLower)) {
1173
+ const mod = rawLabel.slice(0, rawLabel.length - entry.label.length).trim();
1174
+ return { entry, modifier: mod || void 0 };
1175
+ }
1176
+ }
1177
+ for (const entry of vocabulary) {
1178
+ const entryLower = entry.label.toLowerCase();
1179
+ if (lower.includes(entryLower)) {
1180
+ const idx = lower.indexOf(entryLower);
1181
+ const before = rawLabel.slice(0, idx).trim();
1182
+ const after = rawLabel.slice(idx + entry.label.length).trim();
1183
+ const mod = [before, after].filter(Boolean).join(" ");
1184
+ return { entry, modifier: mod || void 0 };
1185
+ }
1186
+ }
1187
+ return null;
1188
+ }
1189
+ function resolveEdges(edges, nodes, speckeeperClassName) {
1190
+ const diagnostics = [];
1191
+ const resolved = [];
1192
+ const isSpk = (id) => nodes.get(id)?.classes.includes(speckeeperClassName) ?? false;
1193
+ const fallback = EDGE_VOCABULARY.find((v) => v.label === "relatedTo");
1194
+ for (const edge of edges) {
1195
+ const involvesSpeckeeper = isSpk(edge.sourceId) || isSpk(edge.targetId);
1196
+ const norm = normalizeLabel(edge.rawLabel);
1197
+ if (!norm) {
1198
+ if (involvesSpeckeeper) {
1199
+ diagnostics.push({
1200
+ severity: "warning",
1201
+ message: `Edge label "${edge.rawLabel}" is not a valid speckeeper RelationType \u2014 falling back to "relatedTo"`,
1202
+ context: `${edge.sourceId} \u2192 ${edge.targetId}`
1203
+ });
1204
+ resolved.push({
1205
+ ...edge,
1206
+ normalizedLabel: "relatedTo",
1207
+ vocabulary: fallback,
1208
+ modifier: void 0
1209
+ });
1210
+ } else {
1211
+ resolved.push({
1212
+ ...edge,
1213
+ normalizedLabel: edge.rawLabel ?? "",
1214
+ vocabulary: { label: edge.rawLabel ?? "", expectedDirection: "forward", relationType: void 0, category: "external", description: "" },
1215
+ modifier: void 0
1216
+ });
1217
+ }
1218
+ continue;
1219
+ }
1220
+ if (involvesSpeckeeper && !norm.entry.relationType) {
1221
+ const spkNorm = normalizeLabel(edge.rawLabel, SORTED_SPECKEEPER_VOCABULARY);
1222
+ if (!spkNorm) {
1223
+ diagnostics.push({
1224
+ severity: "warning",
1225
+ message: `Edge label "${edge.rawLabel}" is not a valid speckeeper RelationType \u2014 falling back to "relatedTo"`,
1226
+ context: `${edge.sourceId} \u2192 ${edge.targetId}`
1227
+ });
1228
+ resolved.push({
1229
+ ...edge,
1230
+ normalizedLabel: "relatedTo",
1231
+ vocabulary: fallback,
1232
+ modifier: void 0
1233
+ });
1234
+ continue;
1235
+ }
1236
+ }
1237
+ resolved.push({
1238
+ ...edge,
1239
+ normalizedLabel: norm.entry.label,
1240
+ vocabulary: norm.entry,
1241
+ modifier: norm.modifier
1242
+ });
1243
+ if (involvesSpeckeeper && edge.direction !== norm.entry.expectedDirection) {
1244
+ const expected = norm.entry.expectedDirection === "bidirectional" ? "<-->" : "-->";
1245
+ diagnostics.push({
1246
+ severity: "warning",
1247
+ message: `"${norm.entry.label}" expects ${expected} arrow but got ${edge.direction === "bidirectional" ? "<-->" : "-->"}`,
1248
+ context: `${edge.sourceId} \u2192 ${edge.targetId}`
1249
+ });
1250
+ }
1251
+ if (norm.entry.label === "implements") {
1252
+ if (isSpk(edge.sourceId) && isSpk(edge.targetId)) {
1253
+ diagnostics.push({
1254
+ severity: "warning",
1255
+ message: '"implements" should be speckeeper \u2192 external; consider using "refines" for speckeeper \u2192 speckeeper edges',
1256
+ context: `${edge.sourceId} \u2192 ${edge.targetId}`
1257
+ });
1258
+ }
1259
+ }
1260
+ if (norm.entry.label === "includes" || norm.entry.label === "traces") {
1261
+ if (!isSpk(edge.sourceId) || !isSpk(edge.targetId)) {
1262
+ diagnostics.push({
1263
+ severity: "warning",
1264
+ message: `"${norm.entry.label}" should connect two speckeeper-managed nodes`,
1265
+ context: `${edge.sourceId} \u2192 ${edge.targetId}`
1266
+ });
1267
+ }
1268
+ }
1269
+ }
1270
+ return { resolved, diagnostics };
1271
+ }
1272
+ function isCheckEdge(entry) {
1273
+ return entry.category === "check";
1274
+ }
1275
+
1276
+ // src/scaffold/template-registry.ts
1277
+ var NODE_ALIAS = {
1278
+ // L0
1279
+ TERM: "term",
1280
+ CDM: "entity",
1281
+ // L1
1282
+ SR: "requirement",
1283
+ FR: "requirement",
1284
+ NFR: "requirement",
1285
+ UC: "usecase",
1286
+ // L2
1287
+ LDM: "logical-entity",
1288
+ AT: "acceptance-test",
1289
+ DT: "data-test",
1290
+ VC: "validation-constraint"
1291
+ };
1292
+ var TEMPLATE_META = {
1293
+ term: { level: "L0", fileName: "term", primaryTypeName: "Term" },
1294
+ entity: { level: "L0", fileName: "entity", primaryTypeName: "Entity" },
1295
+ requirement: { level: "L1", fileName: "requirement", primaryTypeName: "Requirement" },
1296
+ usecase: { level: "L1", fileName: "usecase", primaryTypeName: "UseCase" },
1297
+ "logical-entity": { level: "L2", fileName: "logical-entity", primaryTypeName: "LogicalEntity" },
1298
+ "acceptance-test": { level: "L2", fileName: "acceptance-test", primaryTypeName: "AcceptanceTest" },
1299
+ "data-test": { level: "L2", fileName: "data-test", primaryTypeName: "DataTest" },
1300
+ "validation-constraint": { level: "L2", fileName: "validation-constraint", primaryTypeName: "ValidationConstraint" }
1301
+ };
1302
+ function resolveModelTemplate(nodeId) {
1303
+ const templateName = NODE_ALIAS[nodeId];
1304
+ if (templateName) {
1305
+ const meta = TEMPLATE_META[templateName];
1306
+ return {
1307
+ templateName,
1308
+ defaultLevel: meta.level,
1309
+ defaultIdPrefix: nodeId,
1310
+ modelName: meta.primaryTypeName,
1311
+ fileName: meta.fileName
1312
+ };
1313
+ }
1314
+ return {
1315
+ templateName: "base",
1316
+ defaultLevel: "L1",
1317
+ defaultIdPrefix: nodeId,
1318
+ modelName: toPascalCase(nodeId),
1319
+ fileName: toKebabCase(nodeId)
1320
+ };
1321
+ }
1322
+ var CHECKER_ALIAS = {
1323
+ DDL: {
1324
+ templateName: "ddl-checker",
1325
+ targetType: "ddl",
1326
+ fileName: "ddl-checker"
1327
+ },
1328
+ API: {
1329
+ templateName: "openapi-checker",
1330
+ targetType: "openapi",
1331
+ fileName: "openapi-checker"
1332
+ },
1333
+ E2ET: {
1334
+ templateName: "test-checker",
1335
+ targetType: "test",
1336
+ fileName: "e2e-test-checker"
1337
+ },
1338
+ UT: {
1339
+ templateName: "test-checker",
1340
+ targetType: "test",
1341
+ fileName: "unit-test-checker"
1342
+ },
1343
+ DUT: {
1344
+ templateName: "test-checker",
1345
+ targetType: "test",
1346
+ fileName: "data-unit-test-checker"
1347
+ },
1348
+ IT: {
1349
+ templateName: "test-checker",
1350
+ targetType: "test",
1351
+ fileName: "integration-test-checker"
1352
+ }
1353
+ };
1354
+ function resolveCheckerTemplate(targetNodeId) {
1355
+ return CHECKER_ALIAS[targetNodeId];
1356
+ }
1357
+ function toPascalCase(s) {
1358
+ return s.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join("");
1359
+ }
1360
+ function toKebabCase(s) {
1361
+ return s.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
1362
+ }
1363
+
1364
+ // src/scaffold/templates/requirement.ts
1365
+ function generateRequirementModel(_params) {
1366
+ return `/**
1367
+ * Requirement Model Definitions
1368
+ *
1369
+ * Contains SystemRequirement (SR), FunctionalRequirement (FR),
1370
+ * and NonFunctionalRequirement (NFR) models sharing the same schema.
1371
+ */
1372
+ import { z } from 'zod';
1373
+ import { Model, RelationSchema } from 'speckeeper';
1374
+ import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
1375
+
1376
+ // =============================================================================
1377
+ // Schema Definition
1378
+ // =============================================================================
1379
+
1380
+ export const AcceptanceCriteriaSchema = z.object({
1381
+ id: z.string(),
1382
+ description: z.string(),
1383
+ verificationMethod: z.enum(['test', 'review', 'demo', 'inspection']).optional(),
1384
+ });
1385
+
1386
+ export const RequirementSchema = z.object({
1387
+ id: z.string(),
1388
+ name: z.string().min(1),
1389
+ description: z.string(),
1390
+ type: z.enum(['functional', 'non-functional', 'constraint']),
1391
+ priority: z.enum(['must', 'should', 'could']),
1392
+ rationale: z.string().optional(),
1393
+ acceptanceCriteria: z.array(AcceptanceCriteriaSchema).min(1),
1394
+ category: z.string().optional(),
1395
+ relations: z.array(RelationSchema).optional(),
1396
+ });
1397
+
1398
+ // =============================================================================
1399
+ // Type Export
1400
+ // =============================================================================
1401
+
1402
+ export type AcceptanceCriteria = z.infer<typeof AcceptanceCriteriaSchema>;
1403
+ export type Requirement = z.input<typeof RequirementSchema>;
1404
+
1405
+ // =============================================================================
1406
+ // Shared lint rules and exporters
1407
+ // =============================================================================
1408
+
1409
+ const requirementLintRules: LintRule<Requirement>[] = [
1410
+ {
1411
+ id: 'req-acceptance-not-empty',
1412
+ severity: 'error',
1413
+ message: 'Requirement must have at least one acceptance criteria',
1414
+ check: (spec) => !spec.acceptanceCriteria || spec.acceptanceCriteria.length === 0,
1415
+ },
1416
+ {
1417
+ id: 'req-acceptance-id-format',
1418
+ severity: 'warning',
1419
+ message: 'Acceptance criteria ID should follow parent requirement ID (e.g., FR-001-01)',
1420
+ check: (spec) => {
1421
+ if (!spec.acceptanceCriteria) return false;
1422
+ return spec.acceptanceCriteria.some(ac => !ac.id.startsWith(spec.id + '-'));
1423
+ },
1424
+ },
1425
+ ];
1426
+
1427
+ const requirementExporters: Exporter<Requirement>[] = [
1428
+ {
1429
+ format: 'markdown',
1430
+ single: (spec) => {
1431
+ const lines: string[] = [];
1432
+ lines.push(\`# \${spec.name}\`);
1433
+ lines.push('');
1434
+ lines.push(\`**ID**: \${spec.id}\`);
1435
+ lines.push(\`**Type**: \${spec.type}\`);
1436
+ lines.push(\`**Priority**: \${spec.priority}\`);
1437
+ lines.push('');
1438
+ lines.push('## Description');
1439
+ lines.push('');
1440
+ lines.push(spec.description);
1441
+ lines.push('');
1442
+ lines.push('## Acceptance Criteria');
1443
+ lines.push('');
1444
+ for (const ac of spec.acceptanceCriteria) {
1445
+ lines.push(\`- **\${ac.id}**: \${ac.description}\`);
1446
+ }
1447
+ return lines.join('\\n');
1448
+ },
1449
+ outputDir: 'requirements',
1450
+ filename: (spec) => spec.id,
1451
+ },
1452
+ ];
1453
+
1454
+ // =============================================================================
1455
+ // Model Classes
1456
+ // =============================================================================
1457
+
1458
+ class SystemRequirementModel extends Model<typeof RequirementSchema> {
1459
+ readonly id = 'system-requirement';
1460
+ readonly name = 'SystemRequirement';
1461
+ readonly idPrefix = 'SR';
1462
+ readonly schema = RequirementSchema;
1463
+ readonly description = 'Defines system-level requirements';
1464
+ protected modelLevel: ModelLevel = 'L1';
1465
+ protected lintRules = requirementLintRules;
1466
+ protected exporters = requirementExporters;
1467
+ }
1468
+
1469
+ class FunctionalRequirementModel extends Model<typeof RequirementSchema> {
1470
+ readonly id = 'functional-requirement';
1471
+ readonly name = 'FunctionalRequirement';
1472
+ readonly idPrefix = 'FR';
1473
+ readonly schema = RequirementSchema;
1474
+ readonly description = 'Defines functional requirements';
1475
+ protected modelLevel: ModelLevel = 'L1';
1476
+ protected lintRules = requirementLintRules;
1477
+ protected exporters = requirementExporters;
1478
+ }
1479
+
1480
+ class NonFunctionalRequirementModel extends Model<typeof RequirementSchema> {
1481
+ readonly id = 'non-functional-requirement';
1482
+ readonly name = 'NonFunctionalRequirement';
1483
+ readonly idPrefix = 'NFR';
1484
+ readonly schema = RequirementSchema;
1485
+ readonly description = 'Defines non-functional requirements and constraints';
1486
+ protected modelLevel: ModelLevel = 'L1';
1487
+ protected lintRules = requirementLintRules;
1488
+ protected exporters = requirementExporters;
1489
+ }
1490
+
1491
+ export { SystemRequirementModel, FunctionalRequirementModel, NonFunctionalRequirementModel };
1492
+ `;
1493
+ }
1494
+
1495
+ // src/scaffold/templates/usecase.ts
1496
+ function generateUseCaseModel(_params) {
1497
+ return `/**
1498
+ * UseCase Model Definition
1499
+ */
1500
+ import { z } from 'zod';
1501
+ import { Model, RelationSchema } from 'speckeeper';
1502
+ import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
1503
+
1504
+ // =============================================================================
1505
+ // Schema Definition
1506
+ // =============================================================================
1507
+
1508
+ export const ActorSchema = z.object({
1509
+ id: z.string(),
1510
+ name: z.string(),
1511
+ description: z.string().optional(),
1512
+ type: z.enum(['human', 'system', 'external']),
1513
+ relations: z.array(RelationSchema).optional(),
1514
+ });
1515
+
1516
+ export const UseCaseStepSchema = z.object({
1517
+ stepNumber: z.number(),
1518
+ type: z.enum(['user_action', 'system_response', 'alternative']),
1519
+ description: z.string(),
1520
+ });
1521
+
1522
+ export const UseCaseSchema = z.object({
1523
+ id: z.string(),
1524
+ name: z.string(),
1525
+ description: z.string(),
1526
+ actor: z.string(),
1527
+ preconditions: z.array(z.string()).optional().default([]),
1528
+ postconditions: z.array(z.string()).optional().default([]),
1529
+ mainFlow: z.array(UseCaseStepSchema),
1530
+ relations: z.array(RelationSchema).optional(),
1531
+ });
1532
+
1533
+ // =============================================================================
1534
+ // Type Export
1535
+ // =============================================================================
1536
+
1537
+ export type Actor = z.input<typeof ActorSchema>;
1538
+ export type UseCaseStep = z.infer<typeof UseCaseStepSchema>;
1539
+ export type UseCase = z.input<typeof UseCaseSchema>;
1540
+
1541
+ // =============================================================================
1542
+ // Actor Model Class
1543
+ // =============================================================================
1544
+
1545
+ class ActorModel extends Model<typeof ActorSchema> {
1546
+ readonly id = 'actor';
1547
+ readonly name = 'Actor';
1548
+ readonly idPrefix = 'ACT';
1549
+ readonly schema = ActorSchema;
1550
+ readonly description = 'Defines actors';
1551
+ protected modelLevel: ModelLevel = 'L0';
1552
+
1553
+ protected lintRules: LintRule<Actor>[] = [
1554
+ {
1555
+ id: 'actor-has-description',
1556
+ severity: 'warning',
1557
+ message: 'Actor should have a description',
1558
+ check: (spec) => !spec.description || spec.description.trim() === '',
1559
+ },
1560
+ ];
1561
+
1562
+ protected exporters: Exporter<Actor>[] = [];
1563
+ }
1564
+
1565
+ // =============================================================================
1566
+ // UseCase Model Class
1567
+ // =============================================================================
1568
+
1569
+ class UseCaseModel extends Model<typeof UseCaseSchema> {
1570
+ readonly id = 'usecase';
1571
+ readonly name = 'UseCase';
1572
+ readonly idPrefix = 'UC';
1573
+ readonly schema = UseCaseSchema;
1574
+ readonly description = 'Defines use cases (business flows)';
1575
+ protected modelLevel: ModelLevel = 'L1';
1576
+
1577
+ protected lintRules: LintRule<UseCase>[] = [
1578
+ {
1579
+ id: 'usecase-has-main-flow',
1580
+ severity: 'error',
1581
+ message: 'UseCase must have a main flow',
1582
+ check: (spec) => !spec.mainFlow || spec.mainFlow.length === 0,
1583
+ },
1584
+ {
1585
+ id: 'usecase-has-actor',
1586
+ severity: 'error',
1587
+ message: 'UseCase must have an actor',
1588
+ check: (spec) => !spec.actor || spec.actor.trim() === '',
1589
+ },
1590
+ ];
1591
+
1592
+ protected exporters: Exporter<UseCase>[] = [];
1593
+ }
1594
+
1595
+ export { ActorModel, UseCaseModel };
1596
+ `;
1597
+ }
1598
+
1599
+ // src/scaffold/templates/term.ts
1600
+ function generateTermModel(_params) {
1601
+ return `/**
1602
+ * Term Model Definition
1603
+ */
1604
+ import { z } from 'zod';
1605
+ import { Model, RelationSchema } from 'speckeeper';
1606
+ import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
1607
+
1608
+ // =============================================================================
1609
+ // Schema Definition
1610
+ // =============================================================================
1611
+
1612
+ export const TermSchema = z.object({
1613
+ id: z.string(),
1614
+ term: z.string(),
1615
+ definition: z.string(),
1616
+ category: z.enum(['business', 'technical', 'acronym', 'process', 'role']),
1617
+ abbreviation: z.string().optional(),
1618
+ expandedForm: z.string().optional(),
1619
+ relations: z.array(RelationSchema).optional(),
1620
+ });
1621
+
1622
+ // =============================================================================
1623
+ // Type Export
1624
+ // =============================================================================
1625
+
1626
+ export type Term = z.input<typeof TermSchema>;
1627
+
1628
+ // =============================================================================
1629
+ // Model Class
1630
+ // =============================================================================
1631
+
1632
+ class TermModel extends Model<typeof TermSchema> {
1633
+ readonly id = 'term';
1634
+ readonly name = 'Term';
1635
+ readonly idPrefix = 'TERM';
1636
+ readonly schema = TermSchema;
1637
+ readonly description = 'Defines terms (glossary)';
1638
+ protected modelLevel: ModelLevel = 'L0';
1639
+
1640
+ protected lintRules: LintRule<Term>[] = [
1641
+ {
1642
+ id: 'term-has-definition',
1643
+ severity: 'error',
1644
+ message: 'Term must have a definition',
1645
+ check: (spec) => !spec.definition || spec.definition.trim() === '',
1646
+ },
1647
+ {
1648
+ id: 'term-acronym-has-expanded-form',
1649
+ severity: 'warning',
1650
+ message: 'Acronym terms should have an expanded form',
1651
+ check: (spec) => spec.category === 'acronym' && !spec.expandedForm,
1652
+ },
1653
+ ];
1654
+
1655
+ protected exporters: Exporter<Term>[] = [];
1656
+ }
1657
+
1658
+ export { TermModel };
1659
+ `;
1660
+ }
1661
+
1662
+ // src/scaffold/templates/entity.ts
1663
+ function generateEntityModel(_params) {
1664
+ return `/**
1665
+ * Entity Model Definition (Conceptual Data Model)
1666
+ */
1667
+ import { z } from 'zod';
1668
+ import { Model, RelationSchema } from 'speckeeper';
1669
+ import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
1670
+
1671
+ // =============================================================================
1672
+ // Schema Definition
1673
+ // =============================================================================
1674
+
1675
+ export const AttributeSchema = z.object({
1676
+ name: z.string(),
1677
+ type: z.enum(['string', 'number', 'boolean', 'date', 'datetime', 'uuid', 'enum', 'reference']),
1678
+ required: z.boolean().optional().default(true),
1679
+ description: z.string().optional(),
1680
+ });
1681
+
1682
+ export const EntitySchema = z.object({
1683
+ id: z.string(),
1684
+ name: z.string(),
1685
+ description: z.string(),
1686
+ attributes: z.array(AttributeSchema),
1687
+ isAggregateRoot: z.boolean().optional().default(false),
1688
+ relations: z.array(RelationSchema).optional(),
1689
+ });
1690
+
1691
+ // =============================================================================
1692
+ // Type Export
1693
+ // =============================================================================
1694
+
1695
+ export type Attribute = z.infer<typeof AttributeSchema>;
1696
+ export type Entity = z.input<typeof EntitySchema>;
1697
+
1698
+ // =============================================================================
1699
+ // Model Class
1700
+ // =============================================================================
1701
+
1702
+ class ConceptualDataModel extends Model<typeof EntitySchema> {
1703
+ readonly id = 'conceptual-data-model';
1704
+ readonly name = 'ConceptualDataModel';
1705
+ readonly idPrefix = 'CDM';
1706
+ readonly schema = EntitySchema;
1707
+ readonly description = 'Defines conceptual entities (domain model)';
1708
+ protected modelLevel: ModelLevel = 'L0';
1709
+
1710
+ protected lintRules: LintRule<Entity>[] = [
1711
+ {
1712
+ id: 'entity-has-attributes',
1713
+ severity: 'warning',
1714
+ message: 'Entity should have at least one attribute',
1715
+ check: (spec) => !spec.attributes || spec.attributes.length === 0,
1716
+ },
1717
+ ];
1718
+
1719
+ protected exporters: Exporter<Entity>[] = [
1720
+ {
1721
+ format: 'markdown',
1722
+ single: (spec) => {
1723
+ const lines: string[] = [];
1724
+ lines.push(\`# \${spec.name}\`);
1725
+ lines.push('');
1726
+ lines.push(\`**ID**: \${spec.id}\`);
1727
+ lines.push('');
1728
+ lines.push(spec.description);
1729
+ lines.push('');
1730
+ lines.push('## Attributes');
1731
+ lines.push('');
1732
+ lines.push('| Name | Type | Required | Description |');
1733
+ lines.push('|------|------|----------|-------------|');
1734
+ for (const attr of spec.attributes) {
1735
+ lines.push(\`| \${attr.name} | \${attr.type} | \${attr.required ? 'Yes' : 'No'} | \${attr.description || ''} |\`);
1736
+ }
1737
+ return lines.join('\\n');
1738
+ },
1739
+ outputDir: 'data-model',
1740
+ filename: (spec) => spec.id,
1741
+ },
1742
+ ];
1743
+ }
1744
+
1745
+ export { ConceptualDataModel };
1746
+ `;
1747
+ }
1748
+
1749
+ // src/scaffold/templates/logical-entity.ts
1750
+ function generateLogicalEntityModel(_params) {
1751
+ return `/**
1752
+ * LogicalEntity Model Definition (Logical Data Model)
1753
+ */
1754
+ import { z } from 'zod';
1755
+ import { Model, RelationSchema } from 'speckeeper';
1756
+ import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
1757
+
1758
+ // =============================================================================
1759
+ // Schema Definition
1760
+ // =============================================================================
1761
+
1762
+ export const ColumnSchema = z.object({
1763
+ name: z.string(),
1764
+ dataType: z.string(),
1765
+ nullable: z.boolean().optional().default(false),
1766
+ primaryKey: z.boolean().optional().default(false),
1767
+ foreignKey: z.object({
1768
+ table: z.string(),
1769
+ column: z.string(),
1770
+ }).optional(),
1771
+ description: z.string().optional(),
1772
+ });
1773
+
1774
+ export const ConstraintSchema = z.object({
1775
+ name: z.string(),
1776
+ type: z.enum(['unique', 'check', 'foreign_key', 'index']),
1777
+ columns: z.array(z.string()),
1778
+ expression: z.string().optional(),
1779
+ });
1780
+
1781
+ export const LogicalEntitySchema = z.object({
1782
+ id: z.string(),
1783
+ name: z.string(),
1784
+ description: z.string(),
1785
+ tableName: z.string(),
1786
+ columns: z.array(ColumnSchema).min(1),
1787
+ constraints: z.array(ConstraintSchema).optional(),
1788
+ relations: z.array(RelationSchema).optional(),
1789
+ });
1790
+
1791
+ // =============================================================================
1792
+ // Type Export
1793
+ // =============================================================================
1794
+
1795
+ export type Column = z.infer<typeof ColumnSchema>;
1796
+ export type Constraint = z.infer<typeof ConstraintSchema>;
1797
+ export type LogicalEntity = z.input<typeof LogicalEntitySchema>;
1798
+
1799
+ // =============================================================================
1800
+ // Model Class
1801
+ // =============================================================================
1802
+
1803
+ class LogicalDataModel extends Model<typeof LogicalEntitySchema> {
1804
+ readonly id = 'logical-data-model';
1805
+ readonly name = 'LogicalDataModel';
1806
+ readonly idPrefix = 'LDM';
1807
+ readonly schema = LogicalEntitySchema;
1808
+ readonly description = 'Defines logical data model entities (tables, columns, constraints)';
1809
+ protected modelLevel: ModelLevel = 'L2';
1810
+
1811
+ protected lintRules: LintRule<LogicalEntity>[] = [
1812
+ {
1813
+ id: 'lent-has-columns',
1814
+ severity: 'error',
1815
+ message: 'LogicalEntity must have at least one column',
1816
+ check: (spec) => !spec.columns || spec.columns.length === 0,
1817
+ },
1818
+ {
1819
+ id: 'lent-has-primary-key',
1820
+ severity: 'warning',
1821
+ message: 'LogicalEntity should have at least one primary key column',
1822
+ check: (spec) => {
1823
+ if (!spec.columns) return true;
1824
+ return !spec.columns.some(c => c.primaryKey);
1825
+ },
1826
+ },
1827
+ ];
1828
+
1829
+ protected exporters: Exporter<LogicalEntity>[] = [
1830
+ {
1831
+ format: 'markdown',
1832
+ single: (spec) => {
1833
+ const lines: string[] = [];
1834
+ lines.push(\`# \${spec.name}\`);
1835
+ lines.push('');
1836
+ lines.push(\`**ID**: \${spec.id} \`);
1837
+ lines.push(\`**Table**: \${spec.tableName}\`);
1838
+ lines.push('');
1839
+ lines.push(spec.description);
1840
+ lines.push('');
1841
+ lines.push('## Columns');
1842
+ lines.push('');
1843
+ lines.push('| Name | Type | Nullable | PK | Description |');
1844
+ lines.push('|------|------|----------|----|-------------|');
1845
+ for (const col of spec.columns) {
1846
+ lines.push(\`| \${col.name} | \${col.dataType} | \${col.nullable ? 'Yes' : 'No'} | \${col.primaryKey ? 'Yes' : ''} | \${col.description || ''} |\`);
1847
+ }
1848
+ return lines.join('\\n');
1849
+ },
1850
+ outputDir: 'data-model',
1851
+ filename: (spec) => spec.id,
1852
+ },
1853
+ ];
1854
+ }
1855
+
1856
+ export { LogicalDataModel };
1857
+ `;
1858
+ }
1859
+
1860
+ // src/scaffold/templates/acceptance-test.ts
1861
+ function generateAcceptanceTestModel(_params) {
1862
+ return `/**
1863
+ * AcceptanceTest Model Definition
1864
+ */
1865
+ import { z } from 'zod';
1866
+ import { Model, RelationSchema } from 'speckeeper';
1867
+ import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
1868
+
1869
+ // =============================================================================
1870
+ // Schema Definition
1871
+ // =============================================================================
1872
+
1873
+ export const TestStepSchema = z.object({
1874
+ stepNumber: z.number(),
1875
+ action: z.string(),
1876
+ expectedResult: z.string(),
1877
+ });
1878
+
1879
+ export const AcceptanceTestSchema = z.object({
1880
+ id: z.string(),
1881
+ name: z.string(),
1882
+ description: z.string(),
1883
+ targetId: z.string(),
1884
+ testType: z.enum(['e2e', 'manual', 'review']),
1885
+ steps: z.array(TestStepSchema).optional(),
1886
+ expectedResult: z.string(),
1887
+ relations: z.array(RelationSchema).optional(),
1888
+ });
1889
+
1890
+ // =============================================================================
1891
+ // Type Export
1892
+ // =============================================================================
1893
+
1894
+ export type TestStep = z.infer<typeof TestStepSchema>;
1895
+ export type AcceptanceTest = z.input<typeof AcceptanceTestSchema>;
1896
+
1897
+ // =============================================================================
1898
+ // Model Class
1899
+ // =============================================================================
1900
+
1901
+ class AcceptanceTestModel extends Model<typeof AcceptanceTestSchema> {
1902
+ readonly id = 'acceptance-test';
1903
+ readonly name = 'AcceptanceTest';
1904
+ readonly idPrefix = 'AT';
1905
+ readonly schema = AcceptanceTestSchema;
1906
+ readonly description = 'Defines acceptance test specifications';
1907
+ protected modelLevel: ModelLevel = 'L2';
1908
+
1909
+ protected lintRules: LintRule<AcceptanceTest>[] = [
1910
+ {
1911
+ id: 'at-has-target',
1912
+ severity: 'error',
1913
+ message: 'AcceptanceTest must reference a target requirement or use case',
1914
+ check: (spec) => !spec.targetId || spec.targetId.trim() === '',
1915
+ },
1916
+ ];
1917
+
1918
+ protected exporters: Exporter<AcceptanceTest>[] = [];
1919
+ }
1920
+
1921
+ export { AcceptanceTestModel };
1922
+ `;
1923
+ }
1924
+
1925
+ // src/scaffold/templates/data-test.ts
1926
+ function generateDataTestModel(_params) {
1927
+ return `/**
1928
+ * DataTest Model Definition
1929
+ */
1930
+ import { z } from 'zod';
1931
+ import { Model, RelationSchema } from 'speckeeper';
1932
+ import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
1933
+
1934
+ // =============================================================================
1935
+ // Schema Definition
1936
+ // =============================================================================
1937
+
1938
+ export const DataTestSchema = z.object({
1939
+ id: z.string(),
1940
+ name: z.string(),
1941
+ description: z.string(),
1942
+ targetEntityId: z.string(),
1943
+ ruleType: z.enum(['fk', 'unique', 'type', 'not-null']),
1944
+ condition: z.string(),
1945
+ relations: z.array(RelationSchema).optional(),
1946
+ });
1947
+
1948
+ // =============================================================================
1949
+ // Type Export
1950
+ // =============================================================================
1951
+
1952
+ export type DataTest = z.input<typeof DataTestSchema>;
1953
+
1954
+ // =============================================================================
1955
+ // Model Class
1956
+ // =============================================================================
1957
+
1958
+ class DataTestModel extends Model<typeof DataTestSchema> {
1959
+ readonly id = 'data-test';
1960
+ readonly name = 'DataTest';
1961
+ readonly idPrefix = 'DT';
1962
+ readonly schema = DataTestSchema;
1963
+ readonly description = 'Defines data integrity test specifications';
1964
+ protected modelLevel: ModelLevel = 'L2';
1965
+
1966
+ protected lintRules: LintRule<DataTest>[] = [
1967
+ {
1968
+ id: 'dt-has-target-entity',
1969
+ severity: 'error',
1970
+ message: 'DataTest must reference a target entity',
1971
+ check: (spec) => !spec.targetEntityId || spec.targetEntityId.trim() === '',
1972
+ },
1973
+ ];
1974
+
1975
+ protected exporters: Exporter<DataTest>[] = [];
1976
+ }
1977
+
1978
+ export { DataTestModel };
1979
+ `;
1980
+ }
1981
+
1982
+ // src/scaffold/templates/validation-constraint.ts
1983
+ function generateValidationConstraintModel(_params) {
1984
+ return `/**
1985
+ * ValidationConstraint Model Definition
1986
+ */
1987
+ import { z } from 'zod';
1988
+ import { Model, RelationSchema } from 'speckeeper';
1989
+ import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
1990
+
1991
+ // =============================================================================
1992
+ // Schema Definition
1993
+ // =============================================================================
1994
+
1995
+ export const ValidationConstraintSchema = z.object({
1996
+ id: z.string(),
1997
+ name: z.string(),
1998
+ description: z.string(),
1999
+ targetField: z.string(),
2000
+ constraintType: z.enum(['range', 'format', 'required', 'enum', 'custom']),
2001
+ rule: z.string(),
2002
+ errorMessage: z.string().optional(),
2003
+ relations: z.array(RelationSchema).optional(),
2004
+ });
2005
+
2006
+ // =============================================================================
2007
+ // Type Export
2008
+ // =============================================================================
2009
+
2010
+ export type ValidationConstraint = z.input<typeof ValidationConstraintSchema>;
2011
+
2012
+ // =============================================================================
2013
+ // Model Class
2014
+ // =============================================================================
2015
+
2016
+ class ValidationConstraintModel extends Model<typeof ValidationConstraintSchema> {
2017
+ readonly id = 'validation-constraint';
2018
+ readonly name = 'ValidationConstraint';
2019
+ readonly idPrefix = 'VC';
2020
+ readonly schema = ValidationConstraintSchema;
2021
+ readonly description = 'Defines validation constraints (input rules, boundary values, formats)';
2022
+ protected modelLevel: ModelLevel = 'L2';
2023
+
2024
+ protected lintRules: LintRule<ValidationConstraint>[] = [
2025
+ {
2026
+ id: 'vc-has-rule',
2027
+ severity: 'error',
2028
+ message: 'ValidationConstraint must have a rule definition',
2029
+ check: (spec) => !spec.rule || spec.rule.trim() === '',
2030
+ },
2031
+ ];
2032
+
2033
+ protected exporters: Exporter<ValidationConstraint>[] = [];
2034
+ }
2035
+
2036
+ export { ValidationConstraintModel };
2037
+ `;
2038
+ }
2039
+
2040
+ // src/scaffold/templates/base.ts
2041
+ function generateBaseModel(params) {
2042
+ const schemaName = `${params.modelName}Schema`;
2043
+ const className = `${params.modelName}Model`;
2044
+ return `/**
2045
+ * ${params.modelName} Model Definition
2046
+ *
2047
+ * Generated by speckeeper scaffold \u2014 customise as needed.
2048
+ */
2049
+ import { z } from 'zod';
2050
+ import { Model, RelationSchema } from 'speckeeper';
2051
+ import type { LintRule, Exporter, ModelLevel } from 'speckeeper';
2052
+
2053
+ // =============================================================================
2054
+ // Schema Definition
2055
+ // =============================================================================
2056
+
2057
+ export const ${schemaName} = z.object({
2058
+ id: z.string(),
2059
+ name: z.string().min(1),
2060
+ description: z.string(),
2061
+ relations: z.array(RelationSchema).optional(),
2062
+ });
2063
+
2064
+ // =============================================================================
2065
+ // Type Export
2066
+ // =============================================================================
2067
+
2068
+ export type ${params.modelName} = z.input<typeof ${schemaName}>;
2069
+
2070
+ // =============================================================================
2071
+ // Model Class
2072
+ // =============================================================================
2073
+
2074
+ class ${className} extends Model<typeof ${schemaName}> {
2075
+ readonly id = '${params.modelId}';
2076
+ readonly name = '${params.modelName}';
2077
+ readonly idPrefix = '${params.idPrefix}';
2078
+ readonly schema = ${schemaName};
2079
+ readonly description = '${params.description}';
2080
+ // TODO: adjust modelLevel
2081
+ protected modelLevel: ModelLevel = '${params.level}';
2082
+
2083
+ protected lintRules: LintRule<${params.modelName}>[] = [
2084
+ {
2085
+ id: '${params.modelId}-has-description',
2086
+ severity: 'warning',
2087
+ message: '${params.modelName} should have a description',
2088
+ check: (spec) => !spec.description || spec.description.trim() === '',
2089
+ },
2090
+ ];
2091
+
2092
+ protected exporters: Exporter<${params.modelName}>[] = [];
2093
+ }
2094
+
2095
+ export { ${className} };
2096
+ `;
2097
+ }
2098
+
2099
+ // src/scaffold/templates/checkers/ddl-checker.ts
2100
+ function generateDdlChecker(_params) {
2101
+ return `import { readFileSync, existsSync } from 'node:fs';
2102
+ import { resolve } from 'node:path';
2103
+ import type { ExternalChecker, CheckResult } from 'speckeeper';
2104
+ import type { LogicalEntity } from '../_models/logical-entity.ts';
2105
+
2106
+ /**
2107
+ * DDL Checker: verifies that logical entity definitions
2108
+ * have corresponding tables/columns in schema.sql.
2109
+ */
2110
+ export const ddlChecker: ExternalChecker<LogicalEntity> = {
2111
+ targetType: 'ddl',
2112
+
2113
+ sourcePath: () => resolve('..', 'db', 'schema.sql'),
2114
+
2115
+ check: (spec, _externalData): CheckResult => {
2116
+ const errors: CheckResult['errors'] = [];
2117
+ const warnings: CheckResult['warnings'] = [];
2118
+
2119
+ const schemaPath = resolve('..', 'db', 'schema.sql');
2120
+ if (!existsSync(schemaPath)) {
2121
+ warnings.push({
2122
+ message: \`schema.sql not found: \${schemaPath}\`,
2123
+ specId: spec.id,
2124
+ });
2125
+ return { success: true, errors, warnings };
2126
+ }
2127
+
2128
+ const _schemaSql = readFileSync(schemaPath, 'utf-8');
2129
+
2130
+ // TODO: implement table/column existence check
2131
+ warnings.push({
2132
+ message: \`DDL checker not fully implemented for \${spec.id} \u2014 add table/column verification logic\`,
2133
+ specId: spec.id,
2134
+ });
2135
+
2136
+ return {
2137
+ success: errors.length === 0,
2138
+ errors,
2139
+ warnings,
2140
+ };
2141
+ },
2142
+ };
2143
+ `;
2144
+ }
2145
+
2146
+ // src/scaffold/templates/checkers/openapi-checker.ts
2147
+ function generateOpenapiChecker(_params) {
2148
+ return `import { existsSync } from 'node:fs';
2149
+ import { resolve } from 'node:path';
2150
+ import type { ExternalChecker, CheckResult } from 'speckeeper';
2151
+ import type { UseCase } from '../_models/usecase.ts';
2152
+
2153
+ /**
2154
+ * OpenAPI Checker: verifies that use cases have corresponding
2155
+ * endpoint definitions in the OpenAPI spec.
2156
+ */
2157
+ export const openapiChecker: ExternalChecker<UseCase> = {
2158
+ targetType: 'openapi',
2159
+
2160
+ sourcePath: () => resolve('..', 'api', 'spec'),
2161
+
2162
+ check: (spec, _externalData): CheckResult => {
2163
+ const errors: CheckResult['errors'] = [];
2164
+ const warnings: CheckResult['warnings'] = [];
2165
+
2166
+ const specDir = resolve('..', 'api', 'spec');
2167
+ if (!existsSync(specDir)) {
2168
+ warnings.push({
2169
+ message: \`OpenAPI spec directory not found: \${specDir}\`,
2170
+ specId: spec.id,
2171
+ });
2172
+ return { success: true, errors, warnings };
2173
+ }
2174
+
2175
+ // TODO: implement endpoint existence check
2176
+ warnings.push({
2177
+ message: \`OpenAPI checker not fully implemented for \${spec.id} \u2014 add endpoint verification logic\`,
2178
+ specId: spec.id,
2179
+ });
2180
+
2181
+ return {
2182
+ success: errors.length === 0,
2183
+ errors,
2184
+ warnings,
2185
+ };
2186
+ },
2187
+ };
2188
+ `;
2189
+ }
2190
+
2191
+ // src/scaffold/templates/checkers/test-checker.ts
2192
+ function generateTestChecker(params) {
2193
+ return `import { existsSync, readFileSync } from 'node:fs';
2194
+ import { join } from 'node:path';
2195
+ import { glob } from 'glob';
2196
+ import type { ExternalChecker, CheckResult } from 'speckeeper';
2197
+ import type { ${params.sourceModelName} } from '../_models/${params.sourceModelFile}.ts';
2198
+
2199
+ /**
2200
+ * Test Checker: verifies that ${params.sourceModelName} items have
2201
+ * corresponding test code that exists and references the spec ID.
2202
+ *
2203
+ * Checks:
2204
+ * 1. Test file(s) exist at the expected path
2205
+ * 2. Test file content references the spec ID (in describe/it/test blocks or embedoc markers)
2206
+ */
2207
+ export const ${toCamelCase(params.checkerName)}: ExternalChecker<${params.sourceModelName}> = {
2208
+ targetType: '${params.targetType}',
2209
+
2210
+ sourcePath: () => '.',
2211
+
2212
+ check: (spec, _externalData): CheckResult => {
2213
+ const errors: CheckResult['errors'] = [];
2214
+ const warnings: CheckResult['warnings'] = [];
2215
+ const basePath = process.cwd();
2216
+
2217
+ // TODO: configure test file path pattern per spec
2218
+ // For now, search common test directories
2219
+ const testPatterns = [
2220
+ 'test/**/*.test.ts',
2221
+ 'test/**/*.spec.ts',
2222
+ 'tests/**/*.test.ts',
2223
+ 'tests/**/*.spec.ts',
2224
+ ];
2225
+
2226
+ let testFiles: string[] = [];
2227
+ for (const pattern of testPatterns) {
2228
+ testFiles = testFiles.concat(glob.sync(pattern, { cwd: basePath }));
2229
+ }
2230
+
2231
+ if (testFiles.length === 0) {
2232
+ warnings.push({
2233
+ message: \`No test files found for \${spec.id}\`,
2234
+ specId: spec.id,
2235
+ });
2236
+ return { success: true, errors, warnings };
2237
+ }
2238
+
2239
+ // Check if spec ID is referenced in any test file
2240
+ let specIdFound = false;
2241
+ for (const testFile of testFiles) {
2242
+ const fullPath = join(basePath, testFile);
2243
+ try {
2244
+ const content = readFileSync(fullPath, 'utf-8');
2245
+ const patterns = [
2246
+ new RegExp(\`describe\\\\s*\\\\(\\\\s*['\\\`"].*\${spec.id}\`, 'm'),
2247
+ new RegExp(\`it\\\\s*\\\\(\\\\s*['\\\`"].*\${spec.id}\`, 'm'),
2248
+ new RegExp(\`test\\\\s*\\\\(\\\\s*['\\\`"].*\${spec.id}\`, 'm'),
2249
+ new RegExp(\`@embedoc.*\${spec.id}\`, 'm'),
2250
+ ];
2251
+ if (patterns.some(p => p.test(content))) {
2252
+ specIdFound = true;
2253
+ break;
2254
+ }
2255
+ } catch {
2256
+ // skip unreadable files
2257
+ }
2258
+ }
2259
+
2260
+ if (!specIdFound) {
2261
+ warnings.push({
2262
+ 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\`,
2263
+ specId: spec.id,
2264
+ });
2265
+ }
2266
+
2267
+ return {
2268
+ success: errors.length === 0,
2269
+ errors,
2270
+ warnings,
2271
+ };
2272
+ },
2273
+ };
2274
+ `;
2275
+ }
2276
+ function toCamelCase(s) {
2277
+ return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2278
+ }
2279
+
2280
+ // src/scaffold/templates/checkers/base-checker.ts
2281
+ function generateBaseChecker(params) {
2282
+ return `import type { ExternalChecker, CheckResult } from 'speckeeper';
2283
+ import type { ${params.sourceModelName} } from '../_models/${params.sourceModelFile}.ts';
2284
+
2285
+ /**
2286
+ * ${params.checkerName}: verifies ${params.sourceModelName} against external ${params.targetType} artifacts.
2287
+ *
2288
+ * Generated by speckeeper scaffold \u2014 implement check logic.
2289
+ */
2290
+ export const ${toCamelCase2(params.checkerName)}: ExternalChecker<${params.sourceModelName}> = {
2291
+ targetType: '${params.targetType}',
2292
+
2293
+ sourcePath: () => '.',
2294
+
2295
+ check: (spec, _externalData): CheckResult => {
2296
+ const errors: CheckResult['errors'] = [];
2297
+ const warnings: CheckResult['warnings'] = [];
2298
+
2299
+ // TODO: implement check logic for ${params.targetType}
2300
+ warnings.push({
2301
+ message: \`${params.checkerName} not implemented for \${spec.id}\`,
2302
+ specId: spec.id,
2303
+ });
2304
+
2305
+ return {
2306
+ success: errors.length === 0,
2307
+ errors,
2308
+ warnings,
2309
+ };
2310
+ },
2311
+ };
2312
+ `;
2313
+ }
2314
+ function toCamelCase2(s) {
2315
+ return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2316
+ }
2317
+
2318
+ // src/scaffold/templates/index.ts
2319
+ var MODEL_TEMPLATE_FUNCTIONS = {
2320
+ requirement: generateRequirementModel,
2321
+ usecase: generateUseCaseModel,
2322
+ term: generateTermModel,
2323
+ entity: generateEntityModel,
2324
+ "logical-entity": generateLogicalEntityModel,
2325
+ "acceptance-test": generateAcceptanceTestModel,
2326
+ "data-test": generateDataTestModel,
2327
+ "validation-constraint": generateValidationConstraintModel,
2328
+ base: generateBaseModel
2329
+ };
2330
+ var CHECKER_TEMPLATE_FUNCTIONS = {
2331
+ "ddl-checker": generateDdlChecker,
2332
+ "openapi-checker": generateOpenapiChecker,
2333
+ "test-checker": generateTestChecker,
2334
+ "base-checker": generateBaseChecker
2335
+ };
2336
+
2337
+ // src/scaffold/model-generator.ts
2338
+ function generateModelFile(node, _incomingEdges, _outgoingEdges) {
2339
+ const templateInfo = resolveModelTemplate(node.id);
2340
+ const templateFn = MODEL_TEMPLATE_FUNCTIONS[templateInfo.templateName];
2341
+ if (!templateFn) {
2342
+ const baseFn = MODEL_TEMPLATE_FUNCTIONS["base"];
2343
+ return {
2344
+ relativePath: `_models/${templateInfo.fileName}.ts`,
2345
+ content: baseFn({
2346
+ modelId: templateInfo.fileName,
2347
+ modelName: templateInfo.modelName,
2348
+ idPrefix: templateInfo.defaultIdPrefix,
2349
+ level: templateInfo.defaultLevel,
2350
+ description: node.label ?? node.id
2351
+ })
2352
+ };
2353
+ }
2354
+ const content = templateFn({
2355
+ modelId: templateInfo.fileName,
2356
+ modelName: templateInfo.modelName,
2357
+ idPrefix: templateInfo.defaultIdPrefix,
2358
+ level: templateInfo.defaultLevel,
2359
+ description: node.label ?? node.id
2360
+ });
2361
+ return {
2362
+ relativePath: `_models/${templateInfo.fileName}.ts`,
2363
+ content
2364
+ };
2365
+ }
2366
+ function generateSpecDataFile(node) {
2367
+ const templateInfo = resolveModelTemplate(node.id);
2368
+ const className = `${templateInfo.modelName}Model`;
2369
+ const typeName = templateInfo.modelName;
2370
+ const varName = toCamelCase3(templateInfo.modelName) + "s";
2371
+ const fileName = templateInfo.fileName;
2372
+ const content = `/**
2373
+ * ${templateInfo.modelName} Specifications
2374
+ *
2375
+ * Generated by speckeeper scaffold \u2014 add your specs below.
2376
+ */
2377
+ import type { ${typeName} } from './_models/${fileName}';
2378
+ import { ${className} } from './_models/${fileName}';
2379
+
2380
+ export const ${varName}: ${typeName}[] = [
2381
+ // { id: '${templateInfo.defaultIdPrefix}-001', name: '...', description: '...' },
2382
+ ];
2383
+
2384
+ ${className}.instance.register(${varName});
2385
+ `;
2386
+ return {
2387
+ relativePath: `${fileName}.ts`,
2388
+ content
2389
+ };
2390
+ }
2391
+ function toCamelCase3(pascalCase) {
2392
+ return pascalCase.charAt(0).toLowerCase() + pascalCase.slice(1);
2393
+ }
2394
+ function generateAllModelFiles(speckeeperNodes, resolvedEdges) {
2395
+ const files = [];
2396
+ const generated = /* @__PURE__ */ new Set();
2397
+ for (const node of speckeeperNodes) {
2398
+ const templateInfo = resolveModelTemplate(node.id);
2399
+ const key = templateInfo.templateName === "base" ? `base:${node.id}` : templateInfo.templateName;
2400
+ if (generated.has(key)) continue;
2401
+ generated.add(key);
2402
+ resolvedEdges.filter((e) => e.targetId === node.id);
2403
+ resolvedEdges.filter((e) => e.sourceId === node.id);
2404
+ files.push(generateModelFile(node));
2405
+ files.push(generateSpecDataFile(node));
2406
+ }
2407
+ return files;
2408
+ }
2409
+
2410
+ // src/scaffold/checker-generator.ts
2411
+ function findCheckerEdges(resolvedEdges, nodes, speckeeperClassName) {
2412
+ const isSpk = (id) => nodes.get(id)?.classes.includes(speckeeperClassName) ?? false;
2413
+ return resolvedEdges.filter((edge) => {
2414
+ if (!isCheckEdge(edge.vocabulary)) return false;
2415
+ if (!isSpk(edge.sourceId)) return false;
2416
+ if (isSpk(edge.targetId)) return false;
2417
+ return true;
2418
+ });
2419
+ }
2420
+ function generateCheckerFile(edge, _nodes) {
2421
+ const checkerInfo = resolveCheckerTemplate(edge.targetId);
2422
+ const sourceTemplateInfo = resolveModelTemplate(edge.sourceId);
2423
+ if (checkerInfo) {
2424
+ const templateFn = CHECKER_TEMPLATE_FUNCTIONS[checkerInfo.templateName];
2425
+ if (templateFn) {
2426
+ return {
2427
+ relativePath: `_checkers/${checkerInfo.fileName}.ts`,
2428
+ content: templateFn({
2429
+ checkerName: checkerInfo.fileName,
2430
+ targetType: checkerInfo.targetType,
2431
+ sourceModelName: sourceTemplateInfo.modelName,
2432
+ sourceModelFile: sourceTemplateInfo.fileName,
2433
+ description: `Checks ${sourceTemplateInfo.modelName} against ${edge.targetId}`
2434
+ })
2435
+ };
2436
+ }
2437
+ }
2438
+ const baseFn = CHECKER_TEMPLATE_FUNCTIONS["base-checker"];
2439
+ const targetId = edge.targetId.toLowerCase();
2440
+ const fileName = `${targetId}-checker`;
2441
+ return {
2442
+ relativePath: `_checkers/${fileName}.ts`,
2443
+ content: baseFn({
2444
+ checkerName: fileName,
2445
+ targetType: targetId,
2446
+ sourceModelName: sourceTemplateInfo.modelName,
2447
+ sourceModelFile: sourceTemplateInfo.fileName,
2448
+ description: `Checks ${sourceTemplateInfo.modelName} against ${edge.targetId}`
2449
+ })
2450
+ };
2451
+ }
2452
+ function generateAllCheckerFiles(resolvedEdges, nodes, speckeeperClassName) {
2453
+ const checkerEdges = findCheckerEdges(resolvedEdges, nodes, speckeeperClassName);
2454
+ const files = [];
2455
+ const generated = /* @__PURE__ */ new Set();
2456
+ for (const edge of checkerEdges) {
2457
+ if (generated.has(edge.targetId)) continue;
2458
+ generated.add(edge.targetId);
2459
+ files.push(generateCheckerFile(edge));
2460
+ }
2461
+ return files;
2462
+ }
2463
+
2464
+ // src/scaffold/index-generator.ts
2465
+ function extractExportedClassNames(content) {
2466
+ const match = content.match(/export\s*\{([^}]+)\}\s*;?\s*$/m);
2467
+ if (!match) return [];
2468
+ return match[1].split(",").map((s) => s.trim()).filter(Boolean);
2469
+ }
2470
+ function generateModelsIndex(modelFiles) {
2471
+ const entries = [];
2472
+ for (const file of modelFiles) {
2473
+ const fileName = file.relativePath.replace(/^_models\//, "").replace(/\.ts$/, "");
2474
+ const classNames = extractExportedClassNames(file.content);
2475
+ if (classNames.length > 0) {
2476
+ entries.push({ fileName, classNames });
2477
+ }
2478
+ }
2479
+ entries.sort((a, b) => a.fileName.localeCompare(b.fileName));
2480
+ const lines = [];
2481
+ lines.push("/**");
2482
+ lines.push(" * Model index \u2014 generated by speckeeper scaffold");
2483
+ lines.push(" */");
2484
+ lines.push("");
2485
+ for (const entry of entries) {
2486
+ lines.push(`export { ${entry.classNames.join(", ")} } from './${entry.fileName}.js';`);
2487
+ }
2488
+ lines.push("");
2489
+ const modelInstanceNames = entries.flatMap(
2490
+ (e) => e.classNames.filter((n) => n.endsWith("Model"))
2491
+ );
2492
+ if (modelInstanceNames.length > 0) {
2493
+ lines.push("import { Model } from 'speckeeper';");
2494
+ lines.push("import type { ZodType } from 'zod';");
2495
+ lines.push("");
2496
+ for (const entry of entries) {
2497
+ const models = entry.classNames.filter((n) => n.endsWith("Model"));
2498
+ if (models.length > 0) {
2499
+ lines.push(`import { ${models.join(", ")} } from './${entry.fileName}.js';`);
2500
+ }
2501
+ }
2502
+ lines.push("");
2503
+ lines.push("// eslint-disable-next-line @typescript-eslint/no-explicit-any");
2504
+ lines.push("export const allModels: Model<ZodType>[] = [");
2505
+ for (const name of modelInstanceNames) {
2506
+ lines.push(` ${name}.instance as Model<ZodType>,`);
2507
+ }
2508
+ lines.push("];");
2509
+ }
2510
+ lines.push("");
2511
+ return {
2512
+ relativePath: "_models/index.ts",
2513
+ content: lines.join("\n")
2514
+ };
2515
+ }
2516
+
2517
+ // src/cli/scaffold.ts
2518
+ var SPECKEEPER_CLASS = "speckeeper";
2519
+ async function scaffoldCommand(options) {
2520
+ console.log(chalk4.blue("speckeeper scaffold"));
2521
+ console.log("");
2522
+ const sourcePath = resolve(options.source);
2523
+ if (!existsSync(sourcePath)) {
2524
+ console.error(chalk4.red(` Error: Source file not found: ${sourcePath}`));
2525
+ process.exit(1);
2526
+ }
2527
+ const markdown = readFileSync(sourcePath, "utf-8");
2528
+ const flowchart = parseMarkdownFlowchart(markdown);
2529
+ if (!flowchart) {
2530
+ console.error(chalk4.red(" Error: No mermaid flowchart found in the source file"));
2531
+ process.exit(1);
2532
+ }
2533
+ console.log(chalk4.gray(` Parsed: ${flowchart.nodes.size} nodes, ${flowchart.edges.length} edges`));
2534
+ const speckeeperNodes = [];
2535
+ for (const node of flowchart.nodes.values()) {
2536
+ if (node.classes.includes(SPECKEEPER_CLASS)) {
2537
+ speckeeperNodes.push(node);
2538
+ }
2539
+ }
2540
+ if (speckeeperNodes.length === 0) {
2541
+ console.error(chalk4.red(` Error: No nodes with class "${SPECKEEPER_CLASS}" found`));
2542
+ console.log(chalk4.gray(" Add `classDef speckeeper ...` and `class NODE1,NODE2 speckeeper` to your flowchart"));
2543
+ process.exit(1);
2544
+ }
2545
+ console.log(chalk4.gray(` speckeeper-managed nodes: ${speckeeperNodes.map((n) => n.id).join(", ")}`));
2546
+ const { resolved, diagnostics } = resolveEdges(
2547
+ flowchart.edges,
2548
+ flowchart.nodes,
2549
+ SPECKEEPER_CLASS
2550
+ );
2551
+ printDiagnostics(diagnostics);
2552
+ const outputDir = options.output ? resolve(options.output) : join(process.cwd(), "design");
2553
+ const modelFiles = generateAllModelFiles(speckeeperNodes, resolved);
2554
+ const checkerFiles = generateAllCheckerFiles(resolved, flowchart.nodes, SPECKEEPER_CLASS);
2555
+ const indexFile = generateModelsIndex(modelFiles);
2556
+ const allFiles = [...modelFiles, indexFile, ...checkerFiles];
2557
+ console.log("");
2558
+ console.log(chalk4.cyan(` Files to generate (${allFiles.length}):`));
2559
+ for (const file of allFiles) {
2560
+ console.log(chalk4.gray(` ${file.relativePath}`));
2561
+ }
2562
+ if (options.dryRun) {
2563
+ console.log("");
2564
+ console.log(chalk4.yellow(" Dry run \u2014 no files written"));
2565
+ console.log("");
2566
+ for (const file of allFiles) {
2567
+ console.log(chalk4.cyan(` === ${file.relativePath} ===`));
2568
+ console.log(file.content);
2569
+ }
2570
+ return;
2571
+ }
2572
+ console.log("");
2573
+ let created = 0;
2574
+ let skipped = 0;
2575
+ for (const file of allFiles) {
2576
+ const fullPath = join(outputDir, file.relativePath);
2577
+ const dir = dirname(fullPath);
2578
+ if (!existsSync(dir)) {
2579
+ mkdirSync(dir, { recursive: true });
2580
+ }
2581
+ if (existsSync(fullPath) && !options.force) {
2582
+ console.log(chalk4.yellow(` Skipped: ${file.relativePath} (already exists, use --force to overwrite)`));
2583
+ skipped++;
2584
+ continue;
2585
+ }
2586
+ writeFileSync(fullPath, file.content);
2587
+ console.log(chalk4.green(` Created: ${file.relativePath}`));
2588
+ created++;
2589
+ }
2590
+ console.log("");
2591
+ console.log(chalk4.cyan(` Done: ${created} created, ${skipped} skipped`));
2592
+ }
2593
+ function printDiagnostics(diagnostics) {
2594
+ if (diagnostics.length === 0) return;
2595
+ console.log("");
2596
+ console.log(chalk4.cyan(" Diagnostics:"));
2597
+ for (const diag of diagnostics) {
2598
+ const prefix = diag.severity === "error" ? chalk4.red(" ERROR") : diag.severity === "warning" ? chalk4.yellow(" WARN ") : chalk4.gray(" INFO ");
2599
+ const ctx = diag.context ? chalk4.gray(` [${diag.context}]`) : "";
2600
+ console.log(`${prefix} ${diag.message}${ctx}`);
2601
+ }
2602
+ }
2603
+
1098
2604
  // src/cli/index.ts
1099
2605
  var program = new Command();
1100
2606
  program.name("speckeeper").description("Requirements and design management framework with TypeScript DSL").version("0.1.0");
@@ -1105,6 +2611,7 @@ program.command("check").description("Check external SSOT conformance (including
1105
2611
  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
2612
  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
2613
  program.command("init").description("Initialize a new speckeeper project with starter templates").option("-f, --force", "Overwrite existing files").action(runInit);
2614
+ 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
2615
  program.parse();
1109
2616
  //# sourceMappingURL=cli.js.map
1110
2617
  //# sourceMappingURL=cli.js.map