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/README.md +36 -21
- package/dist/cli.js +1806 -299
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +36 -31
- package/dist/index.js +46 -164
- package/dist/index.js.map +1 -1
- package/dist/templates/init/design/requirements.ts +6 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import chalk4 from 'chalk';
|
|
4
|
-
import { dirname,
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
811
|
-
const result = model.checkCoverage(specs,
|
|
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
|