speckeeper 0.2.1 → 0.4.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,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import chalk4 from 'chalk';
3
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
4
4
  import { dirname, join, resolve } from 'path';
5
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import chalk4 from 'chalk';
6
7
  import { parse } from 'yaml';
7
8
  import 'crypto';
8
9
  import 'glob';
9
10
  import { z } from 'zod';
10
- import { fileURLToPath } from 'url';
11
11
 
12
12
  var defaultConfig = {
13
13
  srcDir: "src",
@@ -123,32 +123,36 @@ 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
126
  var modelRegistry = /* @__PURE__ */ new Map();
131
- function registerModel(model) {
132
- modelRegistry.set(model.id, model);
133
- }
134
127
  function getAllModels() {
135
128
  return Array.from(modelRegistry.values());
136
129
  }
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);
130
+ function buildRegistryFromConfig(specs) {
131
+ const registry = {};
132
+ if (!specs) return registry;
133
+ for (const entry of specs) {
134
+ if (!registry[entry.model.id]) {
135
+ registry[entry.model.id] = /* @__PURE__ */ new Map();
136
+ }
137
+ const map = registry[entry.model.id];
138
+ for (const spec of entry.data) {
139
+ map.set(spec.id, spec);
141
140
  }
142
141
  }
142
+ return registry;
143
143
  }
144
- function getSpecs(modelId) {
145
- const map = specStore.get(modelId);
146
- if (!map) return [];
147
- return Array.from(map.values());
144
+ function getSpecsFromConfig(specs, modelId) {
145
+ if (!specs) return [];
146
+ return specs.filter((e) => e.model.id === modelId).flatMap((e) => e.data);
148
147
  }
149
- function findModelTypeBySpecId(specId) {
150
- for (const [modelId, map] of specStore) {
151
- if (map.has(specId)) return modelId;
148
+ function findModelTypeFromConfig(specs, specId) {
149
+ if (!specs) return null;
150
+ for (const entry of specs) {
151
+ for (const spec of entry.data) {
152
+ if (spec.id === specId) {
153
+ return entry.model.id;
154
+ }
155
+ }
152
156
  }
153
157
  return null;
154
158
  }
@@ -164,13 +168,11 @@ async function buildCommand(options) {
164
168
  console.log(chalk4.gray(` Specs: ${config.specsDir}/`));
165
169
  console.log("");
166
170
  try {
167
- console.log(chalk4.blue(" Loading models..."));
168
- registerModelsFromConfig(config.models || []);
169
- console.log("");
171
+ const models = config.models || [];
172
+ const specs = config.specs;
170
173
  ensureDir(join(cwd, config.docsDir));
171
174
  ensureDir(join(cwd, config.specsDir));
172
175
  const files = [];
173
- const models = getAllModels();
174
176
  if (models.length === 0) {
175
177
  console.log(chalk4.yellow(" No models registered. Add models to speckeeper.config.ts."));
176
178
  return;
@@ -184,8 +186,8 @@ async function buildCommand(options) {
184
186
  }
185
187
  continue;
186
188
  }
187
- const specs = getSpecs(model.id);
188
- if (specs.length === 0) {
189
+ const modelSpecs = getSpecsFromConfig(specs, model.id);
190
+ if (modelSpecs.length === 0) {
189
191
  if (options.verbose) {
190
192
  console.log(chalk4.gray(` ${model.name}: no specs found`));
191
193
  }
@@ -195,7 +197,7 @@ async function buildCommand(options) {
195
197
  const outputDir = exporter.outputDir ? join(cwd, config.docsDir, exporter.outputDir) : join(cwd, config.docsDir);
196
198
  ensureDir(outputDir);
197
199
  if (exporter.single) {
198
- for (const spec of specs) {
200
+ for (const spec of modelSpecs) {
199
201
  const content = exporter.single(spec);
200
202
  const filename = model.getFilename(spec, exporter.format) || spec.id;
201
203
  files.push({
@@ -205,7 +207,7 @@ async function buildCommand(options) {
205
207
  }
206
208
  }
207
209
  if (exporter.index) {
208
- const indexContent = exporter.index(specs);
210
+ const indexContent = exporter.index(modelSpecs);
209
211
  files.push({
210
212
  path: join(outputDir, "index.md"),
211
213
  content: indexContent
@@ -241,13 +243,12 @@ async function lintCommand(options) {
241
243
  }
242
244
  console.log("");
243
245
  try {
244
- console.log(chalk4.blue(" Loading models..."));
245
- registerModelsFromConfig(config.models || []);
246
- const models = getAllModels();
246
+ const models = config.models || [];
247
+ const specs = config.specs;
247
248
  console.log(chalk4.gray(` Loaded: ${models.length} models`));
248
249
  console.log("");
249
250
  console.log(chalk4.blue(" Running lint checks..."));
250
- const result = runModelLint(options);
251
+ const result = runModelLint(models, specs, options);
251
252
  console.log("");
252
253
  outputLintResults(result, options);
253
254
  if (result.errors > 0) {
@@ -258,13 +259,12 @@ async function lintCommand(options) {
258
259
  process.exit(1);
259
260
  }
260
261
  }
261
- function runModelLint(options) {
262
+ function runModelLint(models, specs, options) {
262
263
  const issues = [];
263
- const models = getAllModels();
264
264
  for (const model of models) {
265
- const specs = getSpecs(model.id);
266
- if (specs.length === 0) continue;
267
- const lintResults = model.lintAll(specs);
265
+ const modelSpecs = getSpecsFromConfig(specs, model.id);
266
+ if (modelSpecs.length === 0) continue;
267
+ const lintResults = model.lintAll(modelSpecs);
268
268
  for (const result of lintResults) {
269
269
  issues.push({
270
270
  rule: result.ruleId,
@@ -323,18 +323,17 @@ async function driftCommand(options) {
323
323
  console.log(chalk4.gray(` Docs: ${config.docsDir}/`));
324
324
  console.log("");
325
325
  try {
326
- console.log(chalk4.blue(" Loading models..."));
327
- registerModelsFromConfig(config.models || []);
326
+ const models = config.models || [];
327
+ const specs = config.specs;
328
328
  const results = [];
329
- const models = getAllModels();
330
329
  for (const model of models) {
331
- const specs = getSpecs(model.id);
332
- if (specs.length === 0) continue;
330
+ const modelSpecs = getSpecsFromConfig(specs, model.id);
331
+ if (modelSpecs.length === 0) continue;
333
332
  for (const exporter of model.getExporters()) {
334
333
  if (exporter.format !== "markdown") continue;
335
334
  const outputDir = exporter.outputDir ? join(cwd, config.docsDir, exporter.outputDir) : join(cwd, config.docsDir);
336
335
  if (exporter.single) {
337
- for (const spec of specs) {
336
+ for (const spec of modelSpecs) {
338
337
  const filename = model.getFilename(spec, "markdown") || spec.id;
339
338
  const filePath = join(outputDir, `${filename}.md`);
340
339
  if (!existsSync(filePath)) {
@@ -355,7 +354,7 @@ async function driftCommand(options) {
355
354
  if (!existsSync(indexPath)) {
356
355
  results.push({ file: indexPath, status: "missing" });
357
356
  } else {
358
- const expected = exporter.index(specs);
357
+ const expected = exporter.index(modelSpecs);
359
358
  const actual = readFileSync(indexPath, "utf-8");
360
359
  if (normalizeContent(expected) !== normalizeContent(actual)) {
361
360
  results.push({ file: indexPath, status: "drifted" });
@@ -414,17 +413,16 @@ async function checkCommand(type, options) {
414
413
  console.log(chalk4.gray(` Type: ${checkType}`));
415
414
  console.log("");
416
415
  try {
417
- console.log(chalk4.blue(" Loading models..."));
418
- registerModelsFromConfig(config.models || []);
419
- const results = [];
420
- const models = getAllModels();
416
+ const models = config.models || [];
417
+ const specs = config.specs;
421
418
  if (options.verbose) {
422
419
  console.log(chalk4.gray(` Registered models: ${models.map((m) => m.id).join(", ")}`));
423
420
  }
421
+ const results = [];
424
422
  for (const model of models) {
425
- const specs = getSpecs(model.id);
426
- if (specs.length === 0) continue;
427
- for (const spec of specs) {
423
+ const modelSpecs = getSpecsFromConfig(specs, model.id);
424
+ if (modelSpecs.length === 0) continue;
425
+ for (const spec of modelSpecs) {
428
426
  const sourcePath = model.getExternalSourcePath(spec);
429
427
  if (!sourcePath) continue;
430
428
  const fullPath = join(cwd, sourcePath);
@@ -458,7 +456,7 @@ async function checkCommand(type, options) {
458
456
  if (options.coverage) {
459
457
  console.log("");
460
458
  console.log(chalk4.blue(" Coverage checks..."));
461
- const coverageResults = runAllCoverageChecks();
459
+ const coverageResults = runAllCoverageChecks(models, specs);
462
460
  if (coverageResults.length > 0) {
463
461
  outputAllCoverageResults(coverageResults);
464
462
  for (const check of coverageResults) {
@@ -466,7 +464,6 @@ async function checkCommand(type, options) {
466
464
  results.push({
467
465
  type: `coverage-${check.modelId}`,
468
466
  success: check.result.coveragePercent >= 80,
469
- // fail if below 80%
470
467
  issues: check.result.uncoveredItems.slice(0, 10).map((item) => ({
471
468
  severity: "warning",
472
469
  message: `[${check.modelName}\u2192${check.targetModel}] '${item.id}'${item.sourceId ? ` (${item.sourceId})` : ""} not covered`,
@@ -528,15 +525,14 @@ function outputCheckResults(results) {
528
525
  console.log("");
529
526
  console.log(chalk4.gray(` Summary: ${totalErrors} errors, ${totalWarnings} warnings`));
530
527
  }
531
- function runAllCoverageChecks() {
532
- const models = getAllModels();
528
+ function runAllCoverageChecks(models, specs) {
533
529
  const results = [];
534
- const registry = Object.fromEntries(getSpecStore());
530
+ const registry = buildRegistryFromConfig(specs);
535
531
  for (const model of models) {
536
532
  const checker = model.getCoverageChecker();
537
533
  if (checker) {
538
- const specs = getSpecs(model.id);
539
- const result = model.checkCoverage(specs, registry);
534
+ const modelSpecs = getSpecsFromConfig(specs, model.id);
535
+ const result = model.checkCoverage(modelSpecs, registry);
540
536
  if (result) {
541
537
  results.push({
542
538
  modelId: model.id,
@@ -652,28 +648,27 @@ async function impactCommand(targetId, options) {
652
648
  console.log(chalk4.gray(` Depth: ${maxDepth}`));
653
649
  console.log("");
654
650
  try {
655
- console.log(chalk4.blue(" Loading models..."));
656
- registerModelsFromConfig(config.models || []);
657
- const store = getSpecStore();
658
- const targetType = findModelTypeBySpecId(targetId);
651
+ const specs = config.specs;
652
+ const registry = buildRegistryFromConfig(specs);
653
+ const targetType = findModelTypeFromConfig(specs, targetId);
659
654
  if (!targetType) {
660
655
  console.error(chalk4.red(` Error: Target '${targetId}' not found`));
661
656
  process.exit(1);
662
657
  }
663
658
  console.log(chalk4.blue(" Analyzing impact..."));
664
- const result = analyzeImpact(store, targetId, targetType, maxDepth);
659
+ const result = analyzeImpact(registry, targetId, targetType, maxDepth);
665
660
  outputImpactResults(result, options);
666
661
  } catch (error) {
667
662
  console.error(chalk4.red("Impact analysis failed:"), error);
668
663
  process.exit(1);
669
664
  }
670
665
  }
671
- function analyzeImpact(store, targetId, targetType, maxDepth) {
666
+ function analyzeImpact(registry, targetId, targetType, maxDepth) {
672
667
  const impactedNodes = [];
673
668
  const visited = /* @__PURE__ */ new Set([targetId]);
674
669
  function findReferences(id, depth) {
675
670
  if (depth > maxDepth) return;
676
- for (const [type, map] of store) {
671
+ for (const [type, map] of Object.entries(registry)) {
677
672
  for (const [itemId, item] of map) {
678
673
  if (visited.has(itemId)) continue;
679
674
  const itemStr = JSON.stringify(item);
@@ -772,14 +767,14 @@ async function runInit(options = {}) {
772
767
  console.log(" Next steps:");
773
768
  if (packageJsonCreated) {
774
769
  console.log(chalk4.gray(" 1. Run `npm install` to install dependencies"));
775
- console.log(chalk4.gray(" 2. Edit design/_models/ to customize your models"));
776
- console.log(chalk4.gray(" 3. Add specifications in design/"));
770
+ console.log(chalk4.gray(" 2. Add spec data in design/*.ts using defineSpecs()"));
771
+ console.log(chalk4.gray(" 3. Import new spec files in design/index.ts"));
777
772
  console.log(chalk4.gray(" 4. Run `npx speckeeper lint` to validate"));
778
773
  } else {
779
774
  console.log(chalk4.gray(" 1. Add speckeeper and zod to your package.json dependencies"));
780
775
  console.log(chalk4.gray(' 2. Ensure "type": "module" is set in package.json'));
781
- console.log(chalk4.gray(" 3. Edit design/_models/ to customize your models"));
782
- console.log(chalk4.gray(" 4. Add specifications in design/"));
776
+ console.log(chalk4.gray(" 3. Add spec data in design/*.ts using defineSpecs()"));
777
+ console.log(chalk4.gray(" 4. Import new spec files in design/index.ts"));
783
778
  console.log(chalk4.gray(" 5. Run `npx speckeeper lint` to validate"));
784
779
  }
785
780
  }
@@ -2271,14 +2266,17 @@ function generateSpecDataFile(node) {
2271
2266
  *
2272
2267
  * Generated by speckeeper scaffold \u2014 add your specs below.
2273
2268
  */
2269
+ import { defineSpecs } from 'speckeeper';
2274
2270
  import type { ${typeName} } from './_models/${fileName}';
2275
2271
  import { ${className} } from './_models/${fileName}';
2276
2272
 
2277
- export const ${varName}: ${typeName}[] = [
2273
+ const ${varName}: ${typeName}[] = [
2278
2274
  // { id: '${templateInfo.defaultIdPrefix}-001', name: '...', description: '...' },
2279
2275
  ];
2280
2276
 
2281
- ${className}.instance.register(${varName});
2277
+ export default defineSpecs(
2278
+ [${className}.instance, ${varName}],
2279
+ );
2282
2280
  `;
2283
2281
  return {
2284
2282
  relativePath: `${fileName}.ts`,
@@ -2288,6 +2286,34 @@ ${className}.instance.register(${varName});
2288
2286
  function toCamelCase3(pascalCase) {
2289
2287
  return pascalCase.charAt(0).toLowerCase() + pascalCase.slice(1);
2290
2288
  }
2289
+ function generateDesignIndex(speckeeperNodes) {
2290
+ const generated = /* @__PURE__ */ new Set();
2291
+ const specFiles = [];
2292
+ for (const node of speckeeperNodes) {
2293
+ const templateInfo = resolveModelTemplate(node.id);
2294
+ const key = templateInfo.templateName === "base" ? `base:${node.id}` : templateInfo.templateName;
2295
+ if (generated.has(key)) continue;
2296
+ generated.add(key);
2297
+ specFiles.push({
2298
+ varName: toCamelCase3(templateInfo.modelName),
2299
+ fileName: templateInfo.fileName
2300
+ });
2301
+ }
2302
+ const imports = specFiles.map((f) => `import ${f.varName} from './${f.fileName}';`).join("\n");
2303
+ const args = specFiles.map((f) => f.varName).join(", ");
2304
+ const content = `/**
2305
+ * Design entry point \u2014 generated by speckeeper scaffold
2306
+ */
2307
+ import { mergeSpecs } from 'speckeeper';
2308
+ ${imports}
2309
+
2310
+ export default mergeSpecs(${args});
2311
+ `;
2312
+ return {
2313
+ relativePath: "index.ts",
2314
+ content
2315
+ };
2316
+ }
2291
2317
  function generateAllModelFiles(speckeeperNodes, resolvedEdges) {
2292
2318
  const files = [];
2293
2319
  const generated = /* @__PURE__ */ new Set();
@@ -2301,6 +2327,7 @@ function generateAllModelFiles(speckeeperNodes, resolvedEdges) {
2301
2327
  files.push(generateModelFile(node));
2302
2328
  files.push(generateSpecDataFile(node));
2303
2329
  }
2330
+ files.push(generateDesignIndex(speckeeperNodes));
2304
2331
  return files;
2305
2332
  }
2306
2333
 
@@ -2499,8 +2526,17 @@ function printDiagnostics(diagnostics) {
2499
2526
  }
2500
2527
 
2501
2528
  // src/cli/index.ts
2529
+ function getVersion() {
2530
+ try {
2531
+ const __dirname2 = dirname(fileURLToPath(import.meta.url));
2532
+ const pkg = JSON.parse(readFileSync(join(__dirname2, "..", "package.json"), "utf-8"));
2533
+ return pkg.version;
2534
+ } catch {
2535
+ return "0.0.0";
2536
+ }
2537
+ }
2502
2538
  var program = new Command();
2503
- program.name("speckeeper").description("Requirements and design management framework with TypeScript DSL").version("0.1.0");
2539
+ program.name("speckeeper").description("Requirements and design management framework with TypeScript DSL").version(getVersion());
2504
2540
  program.command("build").description("Generate docs/ and specs/ from TypeScript models").option("-c, --config <path>", "Path to config file").option("-o, --output <path>", "Base output directory path", ".").option("-f, --format <format>", "Output format: markdown, json, both", "both").option("-w, --watch", "Watch for changes and auto-regenerate").option("-v, --verbose", "Show detailed output").action(buildCommand);
2505
2541
  program.command("lint").description("Check design integrity (ID duplicates, references, layer violations, etc.)").option("-c, --config <path>", "Path to config file").option("-p, --phase <phase>", "Phase gate to check against (REQ, HLD, LLD, OPS)").option("-s, --strict", "Treat warnings as errors").option("--fix", "Attempt to fix auto-fixable issues").option("-f, --format <format>", "Output format: text, json, github", "text").action(lintCommand);
2506
2542
  program.command("drift").description("Check if generated files have been manually edited").option("-c, --config <path>", "Path to config file").option("-u, --update", "Auto-update if differences are found").option("-f, --format <format>", "Output format: text, json, diff", "text").action(driftCommand);