speckeeper 0.9.1 → 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
@@ -348,6 +348,43 @@ speckeeper check
348
348
  ✓ All checks passed
349
349
  ```
350
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
+
351
388
  ## Model Levels & Traceability
352
389
 
353
390
  speckeeper organizes models by abstraction level:
package/dist/cli.js CHANGED
@@ -939,6 +939,8 @@ async function checkCommand(type, options) {
939
939
  console.log(chalk4.gray(` Sources: ${sources.length} configured`));
940
940
  }
941
941
  const results = [];
942
+ const transitiveRelations = config.coverage?.transitiveRelations ?? [];
943
+ let transitiveCoverageData;
942
944
  const allSpecIds = [];
943
945
  const specIdToModel = /* @__PURE__ */ new Map();
944
946
  const lookupKeyMap = /* @__PURE__ */ new Map();
@@ -1010,9 +1012,20 @@ async function checkCommand(type, options) {
1010
1012
  }
1011
1013
  }
1012
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
+ }
1013
1026
  if (options.verbose) {
1014
1027
  for (const specId of allSpecIds) {
1015
- if (!matches.has(specId)) {
1028
+ if (!coveredSet.has(specId)) {
1016
1029
  const entry = specIdToModel.get(specId);
1017
1030
  results.push({
1018
1031
  type: entry?.modelId ?? "unknown",
@@ -1048,17 +1061,47 @@ async function checkCommand(type, options) {
1048
1061
  });
1049
1062
  }
1050
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;
1051
1072
  console.log("");
1052
- 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"));
1053
- const allPassed = coverageResults.every((c) => c.result.coveragePercent >= 80);
1054
- if (allPassed) {
1055
- console.log(chalk4.green(" \u2713 All coverage checks passed (\u226580%)"));
1056
- } else {
1057
- const failed = coverageResults.filter((c) => c.result.coveragePercent < 80);
1058
- 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
+ }
1059
1092
  }
1060
- } 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) {
1061
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%`));
1062
1105
  }
1063
1106
  }
1064
1107
  const hasErrors = results.some((r) => !r.success);
@@ -1070,6 +1113,38 @@ async function checkCommand(type, options) {
1070
1113
  process.exit(1);
1071
1114
  }
1072
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
+ }
1073
1148
  function loadExternalData(filePath) {
1074
1149
  const content = readFileSync(filePath, "utf-8");
1075
1150
  if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {