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 +59 -0
- package/dist/cli.js +116 -14
- package/dist/cli.js.map +1 -1
- package/dist/{config-api-BDl4otlv.d.ts → config-api-CLVjdgIP.d.ts} +27 -1
- package/dist/dsl/index.d.ts +15 -3
- package/dist/dsl/index.js +13 -4
- package/dist/dsl/index.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,
|
|
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(
|
|
797
|
+
const existing = result.get(originalSpecId);
|
|
789
798
|
if (existing) {
|
|
790
799
|
existing.push(globalMatch);
|
|
791
800
|
} else {
|
|
792
|
-
result.set(
|
|
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(
|
|
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 (!
|
|
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.
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
}
|
|
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")) {
|