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 +37 -0
- package/dist/cli.js +84 -9
- package/dist/cli.js.map +1 -1
- package/dist/{config-api-CfxXt9Zt.d.ts → config-api-CLVjdgIP.d.ts} +5 -0
- package/dist/dsl/index.d.ts +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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 (!
|
|
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.
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
}
|
|
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")) {
|