speckeeper 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -290,6 +290,28 @@ class EntityModel extends Model<typeof EntitySchema> {
290
290
 
291
291
  Without `deepValidation`, speckeeper still performs existence checks for all spec IDs across all configured sources.
292
292
 
293
+ #### Lookup keys (when spec ID differs from external identifier)
294
+
295
+ By default, the global scanner searches for each spec's `id` in external sources. When the external identifier differs — for example, entity ID `"user"` vs DDL table name `"users"` — define `lookupKeys` on the model to map per source type:
296
+
297
+ ```typescript
298
+ class EntityModel extends Model<typeof EntitySchema> {
299
+ readonly id = 'entity';
300
+ readonly name = 'Entity';
301
+ readonly idPrefix = 'ENT';
302
+ readonly schema = EntitySchema;
303
+
304
+ protected lookupKeys: LookupKeyConfig<Entity> = {
305
+ ddl: (spec) => spec.tableName,
306
+ openapi: (spec) => spec.schemaName ?? spec.id,
307
+ };
308
+ }
309
+ ```
310
+
311
+ With this configuration, when scanning DDL sources the scanner searches for `spec.tableName` instead of `spec.id`. If a match is found, the result is mapped back to the original spec ID for reporting and deep validation.
312
+
313
+ `lookupKeys` is optional per source type — any source type not listed falls back to `spec.id`.
314
+
293
315
  #### Custom scanners
294
316
 
295
317
  For file formats not covered by the built-in scanners, provide a custom `SourceScanner` plugin:
@@ -326,6 +348,43 @@ speckeeper check
326
348
  ✓ All checks passed
327
349
  ```
328
350
 
351
+ #### Transitive coverage
352
+
353
+ Narrative-level specs (e.g. UseCases) rarely have direct `@verifies UC-001` annotations in code. Instead, they are verified indirectly through a chain: UseCase is satisfied by Requirements, and those Requirements are verified by tests.
354
+
355
+ Configure `coverage.transitiveRelations` to enable automatic transitive coverage:
356
+
357
+ ```typescript
358
+ // speckeeper.config.ts
359
+ export default defineConfig({
360
+ sources: [/* ... */],
361
+ coverage: {
362
+ transitiveRelations: ['satisfies'],
363
+ },
364
+ });
365
+ ```
366
+
367
+ When `speckeeper check --coverage` runs:
368
+
369
+ 1. The global scan determines which specs are **directly covered** (found in external sources)
370
+ 2. For each transitive relation type, the framework walks the relation graph
371
+ 3. A spec is **transitively covered** if ALL specs that relate to it via a transitive relation are themselves covered (directly or transitively)
372
+
373
+ No per-model code is needed. Coverage is computed purely from relation data and config.
374
+
375
+ ```bash
376
+ $ npx speckeeper check test --coverage
377
+
378
+ Transitive coverage (via satisfies)
379
+ ─────────────────────────────────────
380
+ Total: 12
381
+ Covered: 11 (8 direct + 3 transitive)
382
+ Uncovered: 1
383
+ Coverage: 92%
384
+ ```
385
+
386
+ Multi-level chains are supported. For example, with `transitiveRelations: ['satisfies', 'verifies']`, if TEST-001 verifies FR-001, and FR-001 satisfies UC-001, then UC-001 is transitively covered when TEST-001 is directly matched.
387
+
329
388
  ## Model Levels & Traceability
330
389
 
331
390
  speckeeper organizes models by abstraction level:
package/dist/cli.js CHANGED
@@ -727,7 +727,7 @@ function loadFileContent(filePath, sourceType) {
727
727
  }
728
728
  return content;
729
729
  }
730
- function runGlobalScan(sources, specIds, basePath) {
730
+ function runGlobalScan(sources, specIds, basePath, lookupKeyMap) {
731
731
  const cwd = basePath ?? process.cwd();
732
732
  const result = /* @__PURE__ */ new Map();
733
733
  const warnings = [];
@@ -740,6 +740,13 @@ function runGlobalScan(sources, specIds, basePath) {
740
740
  });
741
741
  continue;
742
742
  }
743
+ const keyToSpecId = /* @__PURE__ */ new Map();
744
+ for (const specId of specIds) {
745
+ const overrides = lookupKeyMap?.get(specId);
746
+ const key = overrides?.[source.type] ?? specId;
747
+ keyToSpecId.set(key, specId);
748
+ }
749
+ const keysToSearch = Array.from(keyToSpecId.keys());
743
750
  const allFiles = /* @__PURE__ */ new Set();
744
751
  for (const pattern of source.paths) {
745
752
  const found = glob.sync(pattern, {
@@ -768,7 +775,7 @@ function runGlobalScan(sources, specIds, basePath) {
768
775
  if (content == null) continue;
769
776
  let matches;
770
777
  try {
771
- matches = scanner.findSpecIds(content, specIds, relPath);
778
+ matches = scanner.findSpecIds(content, keysToSearch, relPath);
772
779
  } catch (e) {
773
780
  const msg = e instanceof Error ? e.message : String(e);
774
781
  warnings.push({
@@ -779,17 +786,19 @@ function runGlobalScan(sources, specIds, basePath) {
779
786
  continue;
780
787
  }
781
788
  for (const match of matches) {
789
+ const originalSpecId = keyToSpecId.get(match.specId) ?? match.specId;
782
790
  const globalMatch = {
783
791
  ...match,
792
+ specId: originalSpecId,
784
793
  sourceType: source.type,
785
794
  relation: source.relation,
786
795
  filePath: relPath
787
796
  };
788
- const existing = result.get(match.specId);
797
+ const existing = result.get(originalSpecId);
789
798
  if (existing) {
790
799
  existing.push(globalMatch);
791
800
  } else {
792
- result.set(match.specId, [globalMatch]);
801
+ result.set(originalSpecId, [globalMatch]);
793
802
  }
794
803
  }
795
804
  }
@@ -930,19 +939,39 @@ async function checkCommand(type, options) {
930
939
  console.log(chalk4.gray(` Sources: ${sources.length} configured`));
931
940
  }
932
941
  const results = [];
942
+ const transitiveRelations = config.coverage?.transitiveRelations ?? [];
943
+ let transitiveCoverageData;
933
944
  const allSpecIds = [];
934
945
  const specIdToModel = /* @__PURE__ */ new Map();
946
+ const lookupKeyMap = /* @__PURE__ */ new Map();
935
947
  for (const model of models) {
936
948
  const modelSpecs = getSpecsFromConfig(specs, model.id);
949
+ const modelLookupKeys = model.getLookupKeys?.();
937
950
  for (const spec of modelSpecs) {
938
951
  const specId = spec.id;
939
952
  allSpecIds.push(specId);
940
953
  specIdToModel.set(specId, { modelId: model.id, spec });
954
+ if (modelLookupKeys) {
955
+ const overrides = {};
956
+ for (const [sourceType, mapper] of Object.entries(modelLookupKeys)) {
957
+ if (typeof mapper === "function") {
958
+ overrides[sourceType] = mapper(spec);
959
+ }
960
+ }
961
+ if (Object.keys(overrides).length > 0) {
962
+ lookupKeyMap.set(specId, overrides);
963
+ }
964
+ }
941
965
  }
942
966
  }
943
967
  if (sources.length > 0 && allSpecIds.length > 0) {
944
968
  const filteredSources = checkType === "all" ? sources : sources.filter((s) => s.type === checkType);
945
- const { matches, warnings: scanWarnings } = runGlobalScan(filteredSources, allSpecIds, cwd);
969
+ const { matches, warnings: scanWarnings } = runGlobalScan(
970
+ filteredSources,
971
+ allSpecIds,
972
+ cwd,
973
+ lookupKeyMap.size > 0 ? lookupKeyMap : void 0
974
+ );
946
975
  for (const sw of scanWarnings) {
947
976
  results.push({
948
977
  type: sw.sourceType,
@@ -983,9 +1012,20 @@ async function checkCommand(type, options) {
983
1012
  }
984
1013
  }
985
1014
  }
1015
+ const directlyCovered = new Set(matches.keys());
1016
+ let coveredSet = directlyCovered;
1017
+ if (transitiveRelations.length > 0) {
1018
+ const allSpecsWithRelations = allSpecIds.map((id) => {
1019
+ const entry = specIdToModel.get(id);
1020
+ const spec = entry?.spec;
1021
+ return { id, relations: spec?.relations };
1022
+ });
1023
+ transitiveCoverageData = computeTransitiveCoverage(directlyCovered, allSpecsWithRelations, transitiveRelations);
1024
+ coveredSet = transitiveCoverageData.coveredSet;
1025
+ }
986
1026
  if (options.verbose) {
987
1027
  for (const specId of allSpecIds) {
988
- if (!matches.has(specId)) {
1028
+ if (!coveredSet.has(specId)) {
989
1029
  const entry = specIdToModel.get(specId);
990
1030
  results.push({
991
1031
  type: entry?.modelId ?? "unknown",
@@ -1021,17 +1061,47 @@ async function checkCommand(type, options) {
1021
1061
  });
1022
1062
  }
1023
1063
  }
1064
+ }
1065
+ if (transitiveCoverageData && transitiveRelations.length > 0) {
1066
+ const total = allSpecIds.length;
1067
+ const directCount = transitiveCoverageData.directCount;
1068
+ const transitiveCount = transitiveCoverageData.transitiveCount;
1069
+ const covered = directCount + transitiveCount;
1070
+ const uncovered = total - covered;
1071
+ const coveragePercent = total > 0 ? Math.round(covered / total * 100) : 100;
1024
1072
  console.log("");
1025
- console.log(chalk4.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1026
- const allPassed = coverageResults.every((c) => c.result.coveragePercent >= 80);
1027
- if (allPassed) {
1028
- console.log(chalk4.green(" \u2713 All coverage checks passed (\u226580%)"));
1029
- } else {
1030
- const failed = coverageResults.filter((c) => c.result.coveragePercent < 80);
1031
- console.log(chalk4.yellow(` \u26A0 ${failed.length} coverage check(s) below 80%`));
1073
+ console.log(chalk4.blue(` Transitive coverage (via ${transitiveRelations.join(", ")})`));
1074
+ console.log(chalk4.gray(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`));
1075
+ console.log(chalk4.gray(` Total: ${total}`));
1076
+ console.log(chalk4.green(` Covered: ${covered} (${directCount} direct + ${transitiveCount} transitive)`));
1077
+ console.log(chalk4.yellow(` Uncovered: ${uncovered}`));
1078
+ const color = coveragePercent >= 80 ? chalk4.green : coveragePercent >= 50 ? chalk4.yellow : chalk4.red;
1079
+ console.log(color(` Coverage: ${coveragePercent}%`));
1080
+ if (uncovered > 0) {
1081
+ const uncoveredIds = allSpecIds.filter((id) => !transitiveCoverageData.coveredSet.has(id));
1082
+ const display = uncoveredIds.slice(0, 10);
1083
+ console.log("");
1084
+ console.log(chalk4.yellow(" Uncovered items:"));
1085
+ for (const id of display) {
1086
+ const entry = specIdToModel.get(id);
1087
+ console.log(chalk4.yellow(` - ${id} (${entry?.modelId ?? "unknown"})`));
1088
+ }
1089
+ if (uncoveredIds.length > 10) {
1090
+ console.log(chalk4.yellow(` ... and ${uncoveredIds.length - 10} more`));
1091
+ }
1032
1092
  }
1033
- } else {
1093
+ }
1094
+ console.log("");
1095
+ console.log(chalk4.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1096
+ const allCoverageResults = [...coverageResults];
1097
+ const allPassed = allCoverageResults.every((c) => c.result.coveragePercent >= 80);
1098
+ if (coverageResults.length === 0 && !transitiveCoverageData) {
1034
1099
  console.log(chalk4.gray(" No coverage checker found"));
1100
+ } else if (allPassed) {
1101
+ console.log(chalk4.green(" \u2713 All coverage checks passed (\u226580%)"));
1102
+ } else {
1103
+ const failed = allCoverageResults.filter((c) => c.result.coveragePercent < 80);
1104
+ console.log(chalk4.yellow(` \u26A0 ${failed.length} coverage check(s) below 80%`));
1035
1105
  }
1036
1106
  }
1037
1107
  const hasErrors = results.some((r) => !r.success);
@@ -1043,6 +1113,38 @@ async function checkCommand(type, options) {
1043
1113
  process.exit(1);
1044
1114
  }
1045
1115
  }
1116
+ function computeTransitiveCoverage(directlyCovered, allSpecs, transitiveRelations) {
1117
+ if (transitiveRelations.length === 0) {
1118
+ return { coveredSet: new Set(directlyCovered), directCount: directlyCovered.size, transitiveCount: 0 };
1119
+ }
1120
+ const reverseRelations = /* @__PURE__ */ new Map();
1121
+ for (const spec of allSpecs) {
1122
+ if (!spec.relations) continue;
1123
+ for (const rel of spec.relations) {
1124
+ if (!transitiveRelations.includes(rel.type)) continue;
1125
+ const existing = reverseRelations.get(rel.target) ?? [];
1126
+ existing.push(spec.id);
1127
+ reverseRelations.set(rel.target, existing);
1128
+ }
1129
+ }
1130
+ const coveredSet = new Set(directlyCovered);
1131
+ let changed = true;
1132
+ while (changed) {
1133
+ changed = false;
1134
+ for (const [targetId, sourceIds] of reverseRelations) {
1135
+ if (coveredSet.has(targetId)) continue;
1136
+ if (sourceIds.length > 0 && sourceIds.every((id) => coveredSet.has(id))) {
1137
+ coveredSet.add(targetId);
1138
+ changed = true;
1139
+ }
1140
+ }
1141
+ }
1142
+ return {
1143
+ coveredSet,
1144
+ directCount: directlyCovered.size,
1145
+ transitiveCount: coveredSet.size - directlyCovered.size
1146
+ };
1147
+ }
1046
1148
  function loadExternalData(filePath) {
1047
1149
  const content = readFileSync(filePath, "utf-8");
1048
1150
  if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {