speckeeper 0.2.0 → 0.3.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/README.md CHANGED
@@ -69,21 +69,23 @@ npx speckeeper scaffold --source requirements.md
69
69
 
70
70
  This generates:
71
71
  - `design/_models/` — Model classes with Zod schemas, lint rules, and exporters derived from your flowchart
72
+ - `design/*.ts` — Spec data files using `defineSpecs()` for each model
73
+ - `design/index.ts` — Entry point that aggregates all spec modules via `mergeSpecs()`
72
74
  - `design/_checkers/` — External checker skeletons for `implements` edges (e.g. OpenAPI, DDL)
73
- - `design/_models/index.ts` — Re-exports and `allModels` array
74
- - `speckeeper.config.ts` — Configuration wired to the generated models
75
75
 
76
76
  See [Scaffold Mermaid Specification](./docs/scaffold-mermaid-spec.md) for the full input format and built-in node mappings.
77
77
 
78
78
  ### 3. Fill in your specifications
79
79
 
80
- Edit files in `design/` to add your actual specification data:
80
+ Edit spec data files in `design/` to add your actual specification data. Each file uses `defineSpecs()` to pair Model instances with data:
81
81
 
82
82
  ```typescript
83
83
  // design/requirements.ts
84
- import type { Requirement } from 'speckeeper';
84
+ import { defineSpecs } from 'speckeeper';
85
+ import type { Requirement } from './_models/requirement';
86
+ import { FunctionalRequirementModel } from './_models/requirement';
85
87
 
86
- export const requirements: Requirement[] = [
88
+ const requirements: Requirement[] = [
87
89
  {
88
90
  id: 'FR-001',
89
91
  name: 'User Authentication',
@@ -96,8 +98,14 @@ export const requirements: Requirement[] = [
96
98
  ],
97
99
  },
98
100
  ];
101
+
102
+ export default defineSpecs(
103
+ [FunctionalRequirementModel.instance, requirements],
104
+ );
99
105
  ```
100
106
 
107
+ `design/index.ts` aggregates all spec files, and `speckeeper.config.ts` imports the result — no manual wiring needed beyond adding your spec file to `design/index.ts`.
108
+
101
109
  ### 4. Run validation
102
110
 
103
111
  ```bash
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, relative, join, 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';
@@ -127,9 +127,6 @@ var specStore = /* @__PURE__ */ new Map();
127
127
  function getSpecStore() {
128
128
  return specStore;
129
129
  }
130
- function resetSpecStore() {
131
- specStore.clear();
132
- }
133
130
  var modelRegistry = /* @__PURE__ */ new Map();
134
131
  function registerModel(model) {
135
132
  modelRegistry.set(model.id, model);
@@ -137,103 +134,30 @@ function registerModel(model) {
137
134
  function getAllModels() {
138
135
  return Array.from(modelRegistry.values());
139
136
  }
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 });
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);
219
141
  }
220
142
  }
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
143
  }
232
- function getSpecsFromRegistry(registry, modelId) {
233
- const map = registry[modelId];
144
+ function getSpecs(modelId) {
145
+ const map = specStore.get(modelId);
234
146
  if (!map) return [];
235
147
  return Array.from(map.values());
236
148
  }
149
+ function findModelTypeBySpecId(specId) {
150
+ for (const [modelId, map] of specStore) {
151
+ if (map.has(specId)) return modelId;
152
+ }
153
+ return null;
154
+ }
155
+ function registerSpecsFromConfig(specs) {
156
+ if (!specs) return;
157
+ for (const entry of specs) {
158
+ entry.model.register(entry.data);
159
+ }
160
+ }
237
161
 
238
162
  // src/cli/build.ts
239
163
  async function buildCommand(options) {
@@ -247,14 +171,8 @@ async function buildCommand(options) {
247
171
  console.log("");
248
172
  try {
249
173
  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`));
174
+ registerModelsFromConfig(config.models || []);
175
+ registerSpecsFromConfig(config.specs);
258
176
  console.log("");
259
177
  ensureDir(join(cwd, config.docsDir));
260
178
  ensureDir(join(cwd, config.specsDir));
@@ -273,7 +191,7 @@ async function buildCommand(options) {
273
191
  }
274
192
  continue;
275
193
  }
276
- const specs = getSpecsFromRegistry(registry, model.id);
194
+ const specs = getSpecs(model.id);
277
195
  if (specs.length === 0) {
278
196
  if (options.verbose) {
279
197
  console.log(chalk4.gray(` ${model.name}: no specs found`));
@@ -323,7 +241,6 @@ async function buildCommand(options) {
323
241
  async function lintCommand(options) {
324
242
  console.log(chalk4.blue("speckeeper lint"));
325
243
  console.log("");
326
- const cwd = process.cwd();
327
244
  const config = await loadConfig(options.config);
328
245
  console.log(chalk4.gray(` Design: ${config.designDir || "design"}/`));
329
246
  if (options.phase) {
@@ -332,17 +249,13 @@ async function lintCommand(options) {
332
249
  console.log("");
333
250
  try {
334
251
  console.log(chalk4.blue(" Loading models..."));
335
- const { registry, loadedFiles, errors: loadErrors } = await loadAllModels(config, cwd);
336
- if (loadErrors.length > 0) {
337
- console.log(chalk4.yellow(` \u26A0 ${loadErrors.length} file(s) failed to load`));
338
- for (const { file, error } of loadErrors) {
339
- console.log(chalk4.yellow(` - ${relative(cwd, file)}: ${error}`));
340
- }
341
- }
342
- console.log(chalk4.gray(` Loaded: ${loadedFiles.length} files`));
252
+ registerModelsFromConfig(config.models || []);
253
+ registerSpecsFromConfig(config.specs);
254
+ const models = getAllModels();
255
+ console.log(chalk4.gray(` Loaded: ${models.length} models`));
343
256
  console.log("");
344
257
  console.log(chalk4.blue(" Running lint checks..."));
345
- const result = runModelLint(registry, options);
258
+ const result = runModelLint(options);
346
259
  console.log("");
347
260
  outputLintResults(result, options);
348
261
  if (result.errors > 0) {
@@ -353,11 +266,11 @@ async function lintCommand(options) {
353
266
  process.exit(1);
354
267
  }
355
268
  }
356
- function runModelLint(registry, options) {
269
+ function runModelLint(options) {
357
270
  const issues = [];
358
271
  const models = getAllModels();
359
272
  for (const model of models) {
360
- const specs = getSpecsFromRegistry(registry, model.id);
273
+ const specs = getSpecs(model.id);
361
274
  if (specs.length === 0) continue;
362
275
  const lintResults = model.lintAll(specs);
363
276
  for (const result of lintResults) {
@@ -419,11 +332,12 @@ async function driftCommand(options) {
419
332
  console.log("");
420
333
  try {
421
334
  console.log(chalk4.blue(" Loading models..."));
422
- const { registry } = await loadAllModels(config, cwd);
335
+ registerModelsFromConfig(config.models || []);
336
+ registerSpecsFromConfig(config.specs);
423
337
  const results = [];
424
338
  const models = getAllModels();
425
339
  for (const model of models) {
426
- const specs = getSpecsFromRegistry(registry, model.id);
340
+ const specs = getSpecs(model.id);
427
341
  if (specs.length === 0) continue;
428
342
  for (const exporter of model.getExporters()) {
429
343
  if (exporter.format !== "markdown") continue;
@@ -510,14 +424,15 @@ async function checkCommand(type, options) {
510
424
  console.log("");
511
425
  try {
512
426
  console.log(chalk4.blue(" Loading models..."));
513
- const { registry } = await loadAllModels(config, cwd);
427
+ registerModelsFromConfig(config.models || []);
428
+ registerSpecsFromConfig(config.specs);
514
429
  const results = [];
515
430
  const models = getAllModels();
516
431
  if (options.verbose) {
517
432
  console.log(chalk4.gray(` Registered models: ${models.map((m) => m.id).join(", ")}`));
518
433
  }
519
434
  for (const model of models) {
520
- const specs = getSpecsFromRegistry(registry, model.id);
435
+ const specs = getSpecs(model.id);
521
436
  if (specs.length === 0) continue;
522
437
  for (const spec of specs) {
523
438
  const sourcePath = model.getExternalSourcePath(spec);
@@ -553,7 +468,7 @@ async function checkCommand(type, options) {
553
468
  if (options.coverage) {
554
469
  console.log("");
555
470
  console.log(chalk4.blue(" Coverage checks..."));
556
- const coverageResults = runAllCoverageChecks(registry);
471
+ const coverageResults = runAllCoverageChecks();
557
472
  if (coverageResults.length > 0) {
558
473
  outputAllCoverageResults(coverageResults);
559
474
  for (const check of coverageResults) {
@@ -623,13 +538,14 @@ function outputCheckResults(results) {
623
538
  console.log("");
624
539
  console.log(chalk4.gray(` Summary: ${totalErrors} errors, ${totalWarnings} warnings`));
625
540
  }
626
- function runAllCoverageChecks(registry) {
541
+ function runAllCoverageChecks() {
627
542
  const models = getAllModels();
628
543
  const results = [];
544
+ const registry = Object.fromEntries(getSpecStore());
629
545
  for (const model of models) {
630
546
  const checker = model.getCoverageChecker();
631
547
  if (checker) {
632
- const specs = getSpecsFromRegistry(registry, model.id);
548
+ const specs = getSpecs(model.id);
633
549
  const result = model.checkCoverage(specs, registry);
634
550
  if (result) {
635
551
  results.push({
@@ -740,7 +656,6 @@ async function impactCommand(targetId, options) {
740
656
  console.log(chalk4.gray(" Usage: speckeeper impact <id>"));
741
657
  process.exit(1);
742
658
  }
743
- const cwd = process.cwd();
744
659
  const config = await loadConfig(options.config);
745
660
  const maxDepth = options.depth ? parseInt(options.depth, 10) : 3;
746
661
  console.log(chalk4.gray(` Target: ${targetId}`));
@@ -748,35 +663,28 @@ async function impactCommand(targetId, options) {
748
663
  console.log("");
749
664
  try {
750
665
  console.log(chalk4.blue(" Loading models..."));
751
- const { registry } = await loadAllModels(config, cwd);
752
- const target = findById(registry, targetId);
753
- if (!target) {
666
+ registerModelsFromConfig(config.models || []);
667
+ registerSpecsFromConfig(config.specs);
668
+ const store = getSpecStore();
669
+ const targetType = findModelTypeBySpecId(targetId);
670
+ if (!targetType) {
754
671
  console.error(chalk4.red(` Error: Target '${targetId}' not found`));
755
672
  process.exit(1);
756
673
  }
757
674
  console.log(chalk4.blue(" Analyzing impact..."));
758
- const result = analyzeImpact(registry, targetId, target.type, maxDepth);
675
+ const result = analyzeImpact(store, targetId, targetType, maxDepth);
759
676
  outputImpactResults(result, options);
760
677
  } catch (error) {
761
678
  console.error(chalk4.red("Impact analysis failed:"), error);
762
679
  process.exit(1);
763
680
  }
764
681
  }
765
- function findById(registry, id) {
766
- for (const [type, map] of Object.entries(registry)) {
767
- if (map instanceof Map && map.has(id)) {
768
- return { type, data: map.get(id) };
769
- }
770
- }
771
- return null;
772
- }
773
- function analyzeImpact(registry, targetId, targetType, maxDepth) {
682
+ function analyzeImpact(store, targetId, targetType, maxDepth) {
774
683
  const impactedNodes = [];
775
684
  const visited = /* @__PURE__ */ new Set([targetId]);
776
685
  function findReferences(id, depth) {
777
686
  if (depth > maxDepth) return;
778
- for (const [type, map] of Object.entries(registry)) {
779
- if (!(map instanceof Map)) continue;
687
+ for (const [type, map] of store) {
780
688
  for (const [itemId, item] of map) {
781
689
  if (visited.has(itemId)) continue;
782
690
  const itemStr = JSON.stringify(item);
@@ -875,14 +783,14 @@ async function runInit(options = {}) {
875
783
  console.log(" Next steps:");
876
784
  if (packageJsonCreated) {
877
785
  console.log(chalk4.gray(" 1. Run `npm install` to install dependencies"));
878
- console.log(chalk4.gray(" 2. Edit design/_models/ to customize your models"));
879
- console.log(chalk4.gray(" 3. Add specifications in design/"));
786
+ console.log(chalk4.gray(" 2. Add spec data in design/*.ts using defineSpecs()"));
787
+ console.log(chalk4.gray(" 3. Import new spec files in design/index.ts"));
880
788
  console.log(chalk4.gray(" 4. Run `npx speckeeper lint` to validate"));
881
789
  } else {
882
790
  console.log(chalk4.gray(" 1. Add speckeeper and zod to your package.json dependencies"));
883
791
  console.log(chalk4.gray(' 2. Ensure "type": "module" is set in package.json'));
884
- console.log(chalk4.gray(" 3. Edit design/_models/ to customize your models"));
885
- console.log(chalk4.gray(" 4. Add specifications in design/"));
792
+ console.log(chalk4.gray(" 3. Add spec data in design/*.ts using defineSpecs()"));
793
+ console.log(chalk4.gray(" 4. Import new spec files in design/index.ts"));
886
794
  console.log(chalk4.gray(" 5. Run `npx speckeeper lint` to validate"));
887
795
  }
888
796
  }
@@ -2374,14 +2282,17 @@ function generateSpecDataFile(node) {
2374
2282
  *
2375
2283
  * Generated by speckeeper scaffold \u2014 add your specs below.
2376
2284
  */
2285
+ import { defineSpecs } from 'speckeeper';
2377
2286
  import type { ${typeName} } from './_models/${fileName}';
2378
2287
  import { ${className} } from './_models/${fileName}';
2379
2288
 
2380
- export const ${varName}: ${typeName}[] = [
2289
+ const ${varName}: ${typeName}[] = [
2381
2290
  // { id: '${templateInfo.defaultIdPrefix}-001', name: '...', description: '...' },
2382
2291
  ];
2383
2292
 
2384
- ${className}.instance.register(${varName});
2293
+ export default defineSpecs(
2294
+ [${className}.instance, ${varName}],
2295
+ );
2385
2296
  `;
2386
2297
  return {
2387
2298
  relativePath: `${fileName}.ts`,
@@ -2391,6 +2302,34 @@ ${className}.instance.register(${varName});
2391
2302
  function toCamelCase3(pascalCase) {
2392
2303
  return pascalCase.charAt(0).toLowerCase() + pascalCase.slice(1);
2393
2304
  }
2305
+ function generateDesignIndex(speckeeperNodes) {
2306
+ const generated = /* @__PURE__ */ new Set();
2307
+ const specFiles = [];
2308
+ for (const node of speckeeperNodes) {
2309
+ const templateInfo = resolveModelTemplate(node.id);
2310
+ const key = templateInfo.templateName === "base" ? `base:${node.id}` : templateInfo.templateName;
2311
+ if (generated.has(key)) continue;
2312
+ generated.add(key);
2313
+ specFiles.push({
2314
+ varName: toCamelCase3(templateInfo.modelName),
2315
+ fileName: templateInfo.fileName
2316
+ });
2317
+ }
2318
+ const imports = specFiles.map((f) => `import ${f.varName} from './${f.fileName}';`).join("\n");
2319
+ const args = specFiles.map((f) => f.varName).join(", ");
2320
+ const content = `/**
2321
+ * Design entry point \u2014 generated by speckeeper scaffold
2322
+ */
2323
+ import { mergeSpecs } from 'speckeeper';
2324
+ ${imports}
2325
+
2326
+ export default mergeSpecs(${args});
2327
+ `;
2328
+ return {
2329
+ relativePath: "index.ts",
2330
+ content
2331
+ };
2332
+ }
2394
2333
  function generateAllModelFiles(speckeeperNodes, resolvedEdges) {
2395
2334
  const files = [];
2396
2335
  const generated = /* @__PURE__ */ new Set();
@@ -2404,6 +2343,7 @@ function generateAllModelFiles(speckeeperNodes, resolvedEdges) {
2404
2343
  files.push(generateModelFile(node));
2405
2344
  files.push(generateSpecDataFile(node));
2406
2345
  }
2346
+ files.push(generateDesignIndex(speckeeperNodes));
2407
2347
  return files;
2408
2348
  }
2409
2349