artifact-graph 0.5.0 → 0.6.1
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/CHANGELOG.md +55 -0
- package/dist/cli.js +803 -42
- package/dist/index.cjs +676 -41
- package/dist/index.d.cts +121 -3
- package/dist/index.d.ts +121 -3
- package/dist/index.js +668 -35
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from "url";
|
|
|
11
11
|
import Database from "better-sqlite3";
|
|
12
12
|
import matter from "gray-matter";
|
|
13
13
|
import yaml from "js-yaml";
|
|
14
|
-
import { accessSync, constants as fsConstants, statSync } from "fs";
|
|
14
|
+
import { accessSync, constants as fsConstants, existsSync as existsSync2, statSync } from "fs";
|
|
15
15
|
import { mkdir as mkdir4, readFile as readFile2, readdir, writeFile as writeFile3 } from "fs/promises";
|
|
16
16
|
import { basename as basename2, dirname as dirname3, extname, isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve as resolve3 } from "path";
|
|
17
17
|
|
|
@@ -243,6 +243,45 @@ function validatePacket(packet, schema) {
|
|
|
243
243
|
return { ok: !hasError, issues };
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// src/glob-matcher.ts
|
|
247
|
+
function matchesRunnerGlob(filePath, pattern) {
|
|
248
|
+
const normalizedPath = normalizeGlobValue(filePath);
|
|
249
|
+
const normalizedPattern = normalizeGlobValue(pattern);
|
|
250
|
+
let expression = "^";
|
|
251
|
+
for (let index = 0; index < normalizedPattern.length; index += 1) {
|
|
252
|
+
const character = normalizedPattern[index];
|
|
253
|
+
if (character === "*") {
|
|
254
|
+
if (normalizedPattern[index + 1] === "*") {
|
|
255
|
+
while (normalizedPattern[index + 1] === "*") {
|
|
256
|
+
index += 1;
|
|
257
|
+
}
|
|
258
|
+
if (normalizedPattern[index + 1] === "/") {
|
|
259
|
+
index += 1;
|
|
260
|
+
expression += "(?:[^/]+/)*";
|
|
261
|
+
} else {
|
|
262
|
+
expression += ".*";
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
expression += "[^/]*";
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (character === "?") {
|
|
270
|
+
expression += "[^/]";
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
expression += escapeRegexCharacter(character);
|
|
274
|
+
}
|
|
275
|
+
expression += "$";
|
|
276
|
+
return new RegExp(expression).test(normalizedPath);
|
|
277
|
+
}
|
|
278
|
+
function normalizeGlobValue(value) {
|
|
279
|
+
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
280
|
+
}
|
|
281
|
+
function escapeRegexCharacter(character) {
|
|
282
|
+
return "\\^$+?.()|{}[]".includes(character) ? String.fromCharCode(92) + character : character;
|
|
283
|
+
}
|
|
284
|
+
|
|
246
285
|
// src/target-selector.ts
|
|
247
286
|
function parseTargetSelector(value) {
|
|
248
287
|
const separator = value.indexOf(":");
|
|
@@ -1260,6 +1299,7 @@ function validatePacketPrompt(prompt) {
|
|
|
1260
1299
|
|
|
1261
1300
|
// src/versioned-traceability.ts
|
|
1262
1301
|
import { createHash } from "crypto";
|
|
1302
|
+
import { existsSync } from "fs";
|
|
1263
1303
|
import { mkdir as mkdir2, readFile, writeFile as writeFile2 } from "fs/promises";
|
|
1264
1304
|
import { dirname, join as join2, relative } from "path";
|
|
1265
1305
|
var VERSION_LOCK_PATH = "artifacts/traceability-version-lock.json";
|
|
@@ -1300,13 +1340,15 @@ async function buildVersionIndex(root, graph) {
|
|
|
1300
1340
|
edges: sortBy(edges, (edge2) => `${edge2.from} ${edge2.to} ${edge2.kind} ${edge2.sourcePath} ${edge2.sourceLine}`)
|
|
1301
1341
|
};
|
|
1302
1342
|
}
|
|
1303
|
-
async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph) {
|
|
1343
|
+
async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph, config) {
|
|
1304
1344
|
const index = await buildVersionIndex(root, graph);
|
|
1345
|
+
const schema = config ?? await loadConfig(root);
|
|
1305
1346
|
const safeLockPath = normalizeRelativePath(root, lockPath);
|
|
1306
1347
|
const lock = await readVersionLock(root, safeLockPath);
|
|
1307
1348
|
const nodeByArtifact = new Map(index.nodes.map((node) => [`${node.type}:${node.id}`, node]));
|
|
1308
1349
|
const nodeByPath = new Map(index.nodes.map((node) => [node.path, node]));
|
|
1309
1350
|
const currentEdges = implementationEdges(index);
|
|
1351
|
+
const lockableEdges = await lockableImplementationEdges(root, index, schema);
|
|
1310
1352
|
const currentEdgeIds = new Set(currentEdges.map((edge2) => edge2.edgeId));
|
|
1311
1353
|
const issues = [];
|
|
1312
1354
|
let fresh = 0;
|
|
@@ -1394,8 +1436,29 @@ async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph) {
|
|
|
1394
1436
|
issues.push(...entryIssues);
|
|
1395
1437
|
}
|
|
1396
1438
|
}
|
|
1439
|
+
const livenessCache = /* @__PURE__ */ new Map();
|
|
1440
|
+
for (const entry of lock.locks) {
|
|
1441
|
+
if (entry.kind !== "verifies") continue;
|
|
1442
|
+
const sourcePath = entry.source.path;
|
|
1443
|
+
const fullSourcePath = join2(root, sourcePath);
|
|
1444
|
+
if (!existsSync(fullSourcePath)) continue;
|
|
1445
|
+
let liveness = livenessCache.get(sourcePath);
|
|
1446
|
+
if (liveness === void 0) {
|
|
1447
|
+
liveness = await getTestFileRunnerLiveness(root, sourcePath, schema);
|
|
1448
|
+
livenessCache.set(sourcePath, liveness);
|
|
1449
|
+
}
|
|
1450
|
+
if (liveness === "inactive") {
|
|
1451
|
+
issues.push({
|
|
1452
|
+
status: "orphan_lock",
|
|
1453
|
+
edgeId: entry.edgeId,
|
|
1454
|
+
message: `Liveness: ${sourcePath} is not active in any configured runner \u2014 locked verifies edges may reference dead tests`,
|
|
1455
|
+
artifact: entry.artifact,
|
|
1456
|
+
source: entry.source
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1397
1460
|
const reportedMissingLocks = /* @__PURE__ */ new Set();
|
|
1398
|
-
for (const edge2 of
|
|
1461
|
+
for (const edge2 of lockableEdges) {
|
|
1399
1462
|
const edgeId = edge2.edgeId;
|
|
1400
1463
|
if (!lock.locks.some((entry) => entry.edgeId === edgeId)) {
|
|
1401
1464
|
if (reportedMissingLocks.has(edgeId)) {
|
|
@@ -1477,6 +1540,7 @@ async function updateVersionLock(root, options) {
|
|
|
1477
1540
|
}
|
|
1478
1541
|
async function bootstrapVersionLock(root, options = {}) {
|
|
1479
1542
|
const index = await buildVersionIndex(root);
|
|
1543
|
+
const config = await loadConfig(root);
|
|
1480
1544
|
const lockPath = normalizeRelativePath(root, options.lockPath ?? VERSION_LOCK_PATH);
|
|
1481
1545
|
if (!options.force) {
|
|
1482
1546
|
const existing = await readVersionLock(root, lockPath);
|
|
@@ -1486,7 +1550,7 @@ async function bootstrapVersionLock(root, options = {}) {
|
|
|
1486
1550
|
}
|
|
1487
1551
|
const nodeByUid = new Map(index.nodes.map((node) => [node.uid, node]));
|
|
1488
1552
|
const entries = /* @__PURE__ */ new Map();
|
|
1489
|
-
for (const edge2 of
|
|
1553
|
+
for (const edge2 of await lockableImplementationEdges(root, index, config)) {
|
|
1490
1554
|
const source = nodeByUid.get(edge2.from);
|
|
1491
1555
|
const artifact = nodeByUid.get(edge2.to);
|
|
1492
1556
|
if (!source || !artifact) {
|
|
@@ -1521,10 +1585,11 @@ async function refreshVersionLock(root, options = {}) {
|
|
|
1521
1585
|
throw new Error("Changed-only version-lock refresh includes artifact-graph.config.yaml and requires --all");
|
|
1522
1586
|
}
|
|
1523
1587
|
const index = await buildVersionIndex(root);
|
|
1588
|
+
const config = await loadConfig(root);
|
|
1524
1589
|
const lock = await readVersionLock(root, lockPath);
|
|
1525
1590
|
const nodeByUid = new Map(index.nodes.map((node) => [node.uid, node]));
|
|
1526
1591
|
const nodeByPath = new Map(index.nodes.map((node) => [node.path, node]));
|
|
1527
|
-
const currentImplementationEdges =
|
|
1592
|
+
const currentImplementationEdges = await lockableImplementationEdges(root, index, config);
|
|
1528
1593
|
const currentEdgePairs = new Set(currentImplementationEdges.map((edge2) => `${edge2.from} ${edge2.to}`));
|
|
1529
1594
|
const currentEntries = /* @__PURE__ */ new Map();
|
|
1530
1595
|
const changedPathSet = new Set(changedPaths);
|
|
@@ -1599,7 +1664,7 @@ async function refreshVersionLock(root, options = {}) {
|
|
|
1599
1664
|
locks: sortBy([...nextLocks.values()], (item) => item.edgeId)
|
|
1600
1665
|
};
|
|
1601
1666
|
await writeVersionLock(root, lockPath, next);
|
|
1602
|
-
const postAudit = await auditVersionLock(root, lockPath);
|
|
1667
|
+
const postAudit = await auditVersionLock(root, lockPath, void 0, config);
|
|
1603
1668
|
return {
|
|
1604
1669
|
schemaVersion: "1.0",
|
|
1605
1670
|
root,
|
|
@@ -1618,7 +1683,8 @@ async function refreshVersionLock(root, options = {}) {
|
|
|
1618
1683
|
async function traceVersion(root, target, lockPath = VERSION_LOCK_PATH) {
|
|
1619
1684
|
const index = await buildVersionIndex(root);
|
|
1620
1685
|
const safeLockPath = normalizeRelativePath(root, lockPath);
|
|
1621
|
-
const
|
|
1686
|
+
const config = await loadConfig(root);
|
|
1687
|
+
const audit = await auditVersionLock(root, safeLockPath, void 0, config);
|
|
1622
1688
|
const targetUid = parseTarget(target);
|
|
1623
1689
|
const lock = await readVersionLock(root, safeLockPath);
|
|
1624
1690
|
const targetNode = index.nodes.find((node) => node.uid === targetUid);
|
|
@@ -1854,6 +1920,28 @@ function implementationEdges(index) {
|
|
|
1854
1920
|
};
|
|
1855
1921
|
});
|
|
1856
1922
|
}
|
|
1923
|
+
async function lockableImplementationEdges(root, index, config) {
|
|
1924
|
+
const edges = implementationEdges(index);
|
|
1925
|
+
const nodesByUid = new Map(index.nodes.map((node) => [node.uid, node]));
|
|
1926
|
+
const livenessByPath = /* @__PURE__ */ new Map();
|
|
1927
|
+
const result = [];
|
|
1928
|
+
for (const edge2 of edges) {
|
|
1929
|
+
const source = nodesByUid.get(edge2.from);
|
|
1930
|
+
if (source?.sourceKind !== "test") {
|
|
1931
|
+
result.push(edge2);
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1934
|
+
let liveness = livenessByPath.get(source.path);
|
|
1935
|
+
if (liveness === void 0) {
|
|
1936
|
+
liveness = await getTestFileRunnerLiveness(root, source.path, config);
|
|
1937
|
+
livenessByPath.set(source.path, liveness);
|
|
1938
|
+
}
|
|
1939
|
+
if (liveness !== "inactive") {
|
|
1940
|
+
result.push(edge2);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
return result;
|
|
1944
|
+
}
|
|
1857
1945
|
function lockRefFromNode(node) {
|
|
1858
1946
|
return {
|
|
1859
1947
|
type: node.type,
|
|
@@ -1966,6 +2054,53 @@ function normalizeRelativePath(root, path) {
|
|
|
1966
2054
|
function sortBy(items, keyFn) {
|
|
1967
2055
|
return [...items].sort((left, right) => keyFn(left).localeCompare(keyFn(right)));
|
|
1968
2056
|
}
|
|
2057
|
+
async function getTestFileRunnerLiveness(root, filePath, config) {
|
|
2058
|
+
const schema = config ?? await loadConfig(root);
|
|
2059
|
+
const runners = schema.e2e?.runners ?? [];
|
|
2060
|
+
if (runners.length === 0) {
|
|
2061
|
+
if (!/e2e/i.test(filePath) && !/\.e2e\./i.test(filePath)) {
|
|
2062
|
+
return "active";
|
|
2063
|
+
}
|
|
2064
|
+
const fullSourcePath = join2(root, filePath);
|
|
2065
|
+
if (!existsSync(fullSourcePath)) return "inactive";
|
|
2066
|
+
try {
|
|
2067
|
+
const content = await readFile(fullSourcePath, "utf-8");
|
|
2068
|
+
return /\/\/!?\s*@(?:e2e_test|tc)\s+/.test(content) ? "active" : "inactive";
|
|
2069
|
+
} catch {
|
|
2070
|
+
return "inactive";
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
let inRunnerScope = false;
|
|
2074
|
+
for (const runner of runners) {
|
|
2075
|
+
if (!isFileIncludedByRunner(filePath, runner)) continue;
|
|
2076
|
+
inRunnerScope = true;
|
|
2077
|
+
const isActive = await isFileActiveInRunner(root, filePath, runner);
|
|
2078
|
+
if (isActive) return "active";
|
|
2079
|
+
}
|
|
2080
|
+
return inRunnerScope ? "inactive" : "unscoped";
|
|
2081
|
+
}
|
|
2082
|
+
function isFileIncludedByRunner(filePath, runner) {
|
|
2083
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
2084
|
+
const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
|
|
2085
|
+
if (runnerRoot !== "." && !normalizedPath.startsWith(`${runnerRoot}/`)) return false;
|
|
2086
|
+
const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.slice(runnerRoot.length + 1);
|
|
2087
|
+
return runner.include.some((pattern) => matchesRunnerGlob(relativePath, pattern));
|
|
2088
|
+
}
|
|
2089
|
+
async function isFileActiveInRunner(root, filePath, runner) {
|
|
2090
|
+
if (!isFileIncludedByRunner(filePath, runner)) return false;
|
|
2091
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
2092
|
+
const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
|
|
2093
|
+
const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.slice(runnerRoot.length).replace(/^\//, "");
|
|
2094
|
+
const matchesExclude = (runner.exclude ?? []).some(
|
|
2095
|
+
(pattern) => matchesRunnerGlob(relativePath, pattern)
|
|
2096
|
+
);
|
|
2097
|
+
if (matchesExclude) return false;
|
|
2098
|
+
const matchesTestIgnore = (runner.testIgnore ?? []).some(
|
|
2099
|
+
(pattern) => matchesRunnerGlob(relativePath, pattern)
|
|
2100
|
+
);
|
|
2101
|
+
if (matchesTestIgnore) return false;
|
|
2102
|
+
return true;
|
|
2103
|
+
}
|
|
1969
2104
|
function sortUnique(items) {
|
|
1970
2105
|
return [...new Set(items)].sort((left, right) => left.localeCompare(right));
|
|
1971
2106
|
}
|
|
@@ -3025,7 +3160,12 @@ var DEFAULT_SCHEMA = {
|
|
|
3025
3160
|
allowedEdges: [],
|
|
3026
3161
|
forbiddenEdges: [{ from: "scenario", to: "entity", kind: "references" }],
|
|
3027
3162
|
statuses: ["planned", "active", "done", "deprecated"],
|
|
3028
|
-
idRanges: {}
|
|
3163
|
+
idRanges: {},
|
|
3164
|
+
e2e: {
|
|
3165
|
+
report_uncovered_scenarios: true,
|
|
3166
|
+
report_uncovered_features: true,
|
|
3167
|
+
runners: []
|
|
3168
|
+
}
|
|
3029
3169
|
};
|
|
3030
3170
|
async function loadConfig(root) {
|
|
3031
3171
|
const configPath = join5(root, "artifact-graph.config.yaml");
|
|
@@ -3044,7 +3184,21 @@ async function loadConfig(root) {
|
|
|
3044
3184
|
`Invalid context.universal_baseline: ${JSON.stringify(ub)}. Must be boolean (true or false).`
|
|
3045
3185
|
);
|
|
3046
3186
|
}
|
|
3047
|
-
|
|
3187
|
+
if (parsed.e2e !== void 0) {
|
|
3188
|
+
if (typeof parsed.e2e !== "object" || parsed.e2e === null || Array.isArray(parsed.e2e)) {
|
|
3189
|
+
throw new Error("Invalid e2e: must be an object.");
|
|
3190
|
+
}
|
|
3191
|
+
validateE2eConfig(parsed.e2e);
|
|
3192
|
+
}
|
|
3193
|
+
const mergedE2e = parsed.e2e === void 0 ? DEFAULT_SCHEMA.e2e : {
|
|
3194
|
+
...DEFAULT_SCHEMA.e2e,
|
|
3195
|
+
...parsed.e2e,
|
|
3196
|
+
runners: (parsed.e2e.runners ?? DEFAULT_SCHEMA.e2e?.runners ?? []).map((runner) => ({
|
|
3197
|
+
kind: "e2e",
|
|
3198
|
+
...runner
|
|
3199
|
+
}))
|
|
3200
|
+
};
|
|
3201
|
+
const merged = {
|
|
3048
3202
|
...DEFAULT_SCHEMA,
|
|
3049
3203
|
...parsed,
|
|
3050
3204
|
types: mergeArtifactTypes(DEFAULT_SCHEMA.types, parsed.types),
|
|
@@ -3053,8 +3207,101 @@ async function loadConfig(root) {
|
|
|
3053
3207
|
allowedEdges: parsed.allowedEdges ?? DEFAULT_SCHEMA.allowedEdges,
|
|
3054
3208
|
forbiddenEdges: parsed.forbiddenEdges ?? DEFAULT_SCHEMA.forbiddenEdges,
|
|
3055
3209
|
statuses: parsed.statuses ?? DEFAULT_SCHEMA.statuses,
|
|
3056
|
-
idRanges: mergeRecord(DEFAULT_SCHEMA.idRanges, parsed.idRanges)
|
|
3210
|
+
idRanges: mergeRecord(DEFAULT_SCHEMA.idRanges, parsed.idRanges),
|
|
3211
|
+
e2e: mergedE2e
|
|
3057
3212
|
};
|
|
3213
|
+
return merged;
|
|
3214
|
+
}
|
|
3215
|
+
function validateE2eConfig(e2e) {
|
|
3216
|
+
for (const field of ["report_uncovered_scenarios", "report_uncovered_features"]) {
|
|
3217
|
+
if (e2e[field] !== void 0 && typeof e2e[field] !== "boolean") {
|
|
3218
|
+
throw new Error(`Invalid e2e.${field}: must be boolean.`);
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
if (e2e.executable_ref_warning !== void 0) {
|
|
3222
|
+
if (typeof e2e.executable_ref_warning !== "number" || e2e.executable_ref_warning < 0 || e2e.executable_ref_warning > 1) {
|
|
3223
|
+
throw new Error(`Invalid e2e.executable_ref_warning: ${JSON.stringify(e2e.executable_ref_warning)}. Must be a number between 0 and 1.`);
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
if (e2e.executable_ref_error !== void 0) {
|
|
3227
|
+
if (typeof e2e.executable_ref_error !== "number" || e2e.executable_ref_error < 0 || e2e.executable_ref_error > 1) {
|
|
3228
|
+
throw new Error(`Invalid e2e.executable_ref_error: ${JSON.stringify(e2e.executable_ref_error)}. Must be a number between 0 and 1.`);
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
const validateWaivers = (waivers, field) => {
|
|
3232
|
+
if (waivers === void 0) return;
|
|
3233
|
+
if (!Array.isArray(waivers)) {
|
|
3234
|
+
throw new Error(`Invalid ${field}: must be an array of {id, reason} objects.`);
|
|
3235
|
+
}
|
|
3236
|
+
for (const w of waivers) {
|
|
3237
|
+
if (typeof w !== "object" || w === null || !("id" in w) || !("reason" in w)) {
|
|
3238
|
+
throw new Error(`Invalid ${field} entry: ${JSON.stringify(w)}. Must be {id, reason} object.`);
|
|
3239
|
+
}
|
|
3240
|
+
if (typeof w.id !== "string" || !w.id.trim()) {
|
|
3241
|
+
throw new Error(`Invalid ${field} entry: id must be a non-empty string. Got: ${JSON.stringify(w.id)}`);
|
|
3242
|
+
}
|
|
3243
|
+
if (typeof w.reason !== "string" || !w.reason.trim()) {
|
|
3244
|
+
throw new Error(`Invalid ${field} entry: reason must be a non-empty string. Got: ${JSON.stringify(w.reason)}`);
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
};
|
|
3248
|
+
validateWaivers(e2e.scenario_waivers, "e2e.scenario_waivers");
|
|
3249
|
+
validateWaivers(e2e.feature_waivers, "e2e.feature_waivers");
|
|
3250
|
+
if (e2e.runners !== void 0) {
|
|
3251
|
+
if (!Array.isArray(e2e.runners)) {
|
|
3252
|
+
throw new Error(`Invalid e2e.runners: must be an array.`);
|
|
3253
|
+
}
|
|
3254
|
+
for (const runner of e2e.runners) {
|
|
3255
|
+
if (typeof runner !== "object" || runner === null) {
|
|
3256
|
+
throw new Error(`Invalid e2e.runners entry: must be an object.`);
|
|
3257
|
+
}
|
|
3258
|
+
if (typeof runner.name !== "string" || !runner.name.trim()) {
|
|
3259
|
+
throw new Error(`Invalid e2e.runners entry: name must be a non-empty string.`);
|
|
3260
|
+
}
|
|
3261
|
+
if (typeof runner.root !== "string" || !runner.root.trim()) {
|
|
3262
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].root: must be a non-empty string.`);
|
|
3263
|
+
}
|
|
3264
|
+
if (isAbsolute3(runner.root)) {
|
|
3265
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].root: "${runner.root}" must not be an absolute path.`);
|
|
3266
|
+
}
|
|
3267
|
+
if (runner.root.replace(/\\/g, "/").split("/").includes("..")) {
|
|
3268
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].root: "${runner.root}" must not contain ".." segments.`);
|
|
3269
|
+
}
|
|
3270
|
+
if (!Array.isArray(runner.include) || runner.include.length === 0) {
|
|
3271
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].include: must be a non-empty array of glob patterns.`);
|
|
3272
|
+
}
|
|
3273
|
+
for (const pattern of runner.include) {
|
|
3274
|
+
if (typeof pattern !== "string" || !pattern.trim()) {
|
|
3275
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].include: pattern must be a non-empty string.`);
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
if (runner.exclude !== void 0) {
|
|
3279
|
+
if (!Array.isArray(runner.exclude)) {
|
|
3280
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].exclude: must be an array.`);
|
|
3281
|
+
}
|
|
3282
|
+
for (const pattern of runner.exclude) {
|
|
3283
|
+
if (typeof pattern !== "string" || !pattern.trim()) {
|
|
3284
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].exclude: pattern must be a non-empty string.`);
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
if (runner.testIgnore !== void 0) {
|
|
3289
|
+
if (!Array.isArray(runner.testIgnore)) {
|
|
3290
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].testIgnore: must be an array.`);
|
|
3291
|
+
}
|
|
3292
|
+
for (const pattern of runner.testIgnore) {
|
|
3293
|
+
if (typeof pattern !== "string" || !pattern.trim()) {
|
|
3294
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].testIgnore: pattern must be a non-empty string.`);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
if (runner.kind !== void 0) {
|
|
3299
|
+
if (!["unit", "integration", "e2e"].includes(runner.kind)) {
|
|
3300
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].kind: "${runner.kind}". Must be unit, integration, or e2e.`);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3058
3305
|
}
|
|
3059
3306
|
function buildGraph(nodes, edges, diagnostics = [], root) {
|
|
3060
3307
|
const graphNodes = nodes.map((node) => ({ ...node, uid: toUid(node.type, node.code) }));
|
|
@@ -5203,6 +5450,29 @@ function validateE2eTests(graph) {
|
|
|
5203
5450
|
issues.push(issue("E2E_AC_UNKNOWN", `${node.uid} references unknown AC ${reference.feature}(${reference.ac})`, node.path, node.line, { node: node.uid, severity: "warning" }));
|
|
5204
5451
|
}
|
|
5205
5452
|
}
|
|
5453
|
+
const tcStatus = String(fields["status"] ?? "").trim().toLowerCase();
|
|
5454
|
+
if (tcStatus && !VALID_TC_STATUSES.has(tcStatus)) {
|
|
5455
|
+
issues.push(issue("E2E_INVALID_TC_STATUS", `${node.uid} has invalid TC status "${tcStatus}"; allowed: ${[...VALID_TC_STATUSES].join(", ")}`, node.path, node.line, { node: node.uid, severity: "warning" }));
|
|
5456
|
+
}
|
|
5457
|
+
if (tcStatus === "waived") {
|
|
5458
|
+
const reason = String(fields["waived_reason"] ?? "").trim();
|
|
5459
|
+
if (!reason) {
|
|
5460
|
+
issues.push(issue("E2E_WAIVED_NO_REASON", `${node.uid} has status "waived" but no waived_reason`, node.path, node.line, { node: node.uid, severity: "warning" }));
|
|
5461
|
+
}
|
|
5462
|
+
}
|
|
5463
|
+
const rawChainType = String(fields["chain_type"] ?? "").trim();
|
|
5464
|
+
const chainType = rawChainType.toLowerCase();
|
|
5465
|
+
if (rawChainType) {
|
|
5466
|
+
if (!VALID_CHAIN_TYPES.has(chainType) && !(chainType in DEPRECATED_CHAIN_TYPE_ALIASES)) {
|
|
5467
|
+
issues.push(issue("E2E_INVALID_CHAIN_TYPE", `${node.uid} has invalid chain_type "${rawChainType}"; allowed: ${[...VALID_CHAIN_TYPES].join(", ")}`, node.path, node.line, { node: node.uid, severity: "warning" }));
|
|
5468
|
+
} else if (chainType in DEPRECATED_CHAIN_TYPE_ALIASES) {
|
|
5469
|
+
issues.push(issue("E2E_DEPRECATED_CHAIN_TYPE", `${node.uid} uses deprecated chain_type "${rawChainType}"; migrate to "${DEPRECATED_CHAIN_TYPE_ALIASES[chainType]}"`, node.path, node.line, { node: node.uid, severity: "warning" }));
|
|
5470
|
+
}
|
|
5471
|
+
}
|
|
5472
|
+
const rawAcCoverageRate = String(fields["ac_coverage_rate"] ?? "").trim();
|
|
5473
|
+
if (rawAcCoverageRate) {
|
|
5474
|
+
issues.push(issue("E2E_AC_COVERAGE_RATE_FREETEXT", `${node.uid} has handwritten ac_coverage_rate "${rawAcCoverageRate}"; this field must be derived from ac_coverage and the feature acceptance-criteria inventory`, node.path, node.line, { node: node.uid, severity: "warning" }));
|
|
5475
|
+
}
|
|
5206
5476
|
if (needsDesktopChainWarning(node)) {
|
|
5207
5477
|
issues.push(issue("E2E_DESKTOP_CHAIN_WARNING", `${node.uid} appears desktop-related but does not cover the full React/UI -> Tauri/IPC -> Node sidecar/JSON Lines -> core/engine -> SQLite/report data \u771F\u5B9E\u684C\u9762\u94FE\u8DEF`, node.path, node.line, { node: node.uid, severity: "warning" }));
|
|
5208
5478
|
}
|
|
@@ -5261,10 +5531,10 @@ function validateE2eRegistry(graph) {
|
|
|
5261
5531
|
}
|
|
5262
5532
|
return issues;
|
|
5263
5533
|
}
|
|
5264
|
-
async function validateExecutableTraceability(root) {
|
|
5534
|
+
async function validateExecutableTraceability(root, config) {
|
|
5265
5535
|
const issues = [];
|
|
5536
|
+
const schema = config ?? await loadConfig(root);
|
|
5266
5537
|
const e2eDir = join5(root, "artifacts", "tests", "e2e");
|
|
5267
|
-
const specPatterns = ["heimdall/**/*.spec.ts", "heimdall/**/*.e2e.spec.ts"];
|
|
5268
5538
|
let e2eFiles;
|
|
5269
5539
|
try {
|
|
5270
5540
|
e2eFiles = (await readdir(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).map((name) => join5(e2eDir, name));
|
|
@@ -5307,9 +5577,16 @@ async function validateExecutableTraceability(root) {
|
|
|
5307
5577
|
}
|
|
5308
5578
|
const allFiles = await walk(root);
|
|
5309
5579
|
const specFiles = /* @__PURE__ */ new Set();
|
|
5310
|
-
|
|
5580
|
+
const configuredRunners = schema.e2e?.runners ?? [];
|
|
5581
|
+
if (configuredRunners.length > 0) {
|
|
5311
5582
|
for (const file of allFiles) {
|
|
5312
|
-
if (
|
|
5583
|
+
if (configuredRunners.some((runner) => isRunnerIncludeCandidate(file, runner))) {
|
|
5584
|
+
specFiles.add(file);
|
|
5585
|
+
}
|
|
5586
|
+
}
|
|
5587
|
+
} else {
|
|
5588
|
+
for (const file of allFiles) {
|
|
5589
|
+
if (/\.(?:e2e\.)?spec\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(file)) {
|
|
5313
5590
|
specFiles.add(file);
|
|
5314
5591
|
}
|
|
5315
5592
|
}
|
|
@@ -5380,12 +5657,21 @@ async function validateExecutableTraceability(root) {
|
|
|
5380
5657
|
const refEntries = parseExecutableRefLines(ref);
|
|
5381
5658
|
const validFiles = [];
|
|
5382
5659
|
for (const entry of refEntries) {
|
|
5383
|
-
const normalizedRefFile =
|
|
5384
|
-
const fileExists = specFiles.has(normalizedRefFile);
|
|
5660
|
+
const normalizedRefFile = resolveExecutableRefFile(entry.file, allFiles);
|
|
5661
|
+
const fileExists = normalizedRefFile !== void 0 && specFiles.has(normalizedRefFile);
|
|
5385
5662
|
if (!fileExists) {
|
|
5386
5663
|
issues.push(issue("E2E-TRACE-001", `executable_ref target file not found: ${entry.file}`, path, line, { node: tcKey, severity: "warning" }));
|
|
5387
5664
|
continue;
|
|
5388
5665
|
}
|
|
5666
|
+
const runners = schema.e2e?.runners ?? [];
|
|
5667
|
+
if (runners.length > 0) {
|
|
5668
|
+
const acceptingRunners = await getAcceptingRunners(root, normalizedRefFile, runners);
|
|
5669
|
+
const hasE2eRunner = acceptingRunners.some((r) => r.kind === "e2e" || r.kind === "integration");
|
|
5670
|
+
const hasUnitRunner = acceptingRunners.some((r) => r.kind === "unit");
|
|
5671
|
+
if (hasUnitRunner && !hasE2eRunner && acceptingRunners.length > 0) {
|
|
5672
|
+
issues.push(issue("E2E-UNIT-TEST-NOT-E2E", `executable_ref target ${entry.file} is only accepted by unit runner(s) [${acceptingRunners.map((r) => r.name).join(", ")}], not by any e2e/integration runner`, path, line, { node: tcKey, severity: "warning" }));
|
|
5673
|
+
}
|
|
5674
|
+
}
|
|
5389
5675
|
if (entry.testId) {
|
|
5390
5676
|
let content;
|
|
5391
5677
|
try {
|
|
@@ -5400,10 +5686,7 @@ async function validateExecutableTraceability(root) {
|
|
|
5400
5686
|
}
|
|
5401
5687
|
}
|
|
5402
5688
|
const annotationsForTc = refToSource.get(tcKey);
|
|
5403
|
-
const hasAnnotationInFile = annotationsForTc?.some((ann) =>
|
|
5404
|
-
const normalizedAnnFile = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
|
|
5405
|
-
return normalizedAnnFile === normalizedRefFile;
|
|
5406
|
-
}) ?? false;
|
|
5689
|
+
const hasAnnotationInFile = annotationsForTc?.some((ann) => ann.file === normalizedRefFile) ?? false;
|
|
5407
5690
|
if (!hasAnnotationInFile) {
|
|
5408
5691
|
issues.push(issue("E2E-TRACE-003", `executable_ref target ${entry.file} has no E2E trace annotation ${tcKey}`, path, line, { node: tcKey, severity: "warning" }));
|
|
5409
5692
|
continue;
|
|
@@ -5441,24 +5724,37 @@ async function validateExecutableTraceability(root) {
|
|
|
5441
5724
|
if (matchesAnyRef) {
|
|
5442
5725
|
continue;
|
|
5443
5726
|
}
|
|
5444
|
-
const primaryRef = refEntries[0];
|
|
5445
|
-
const normalizedPrimary = primaryRef?.file.startsWith("heimdall/") ? primaryRef?.file : `heimdall/${primaryRef?.file ?? ""}`;
|
|
5446
5727
|
const detail = `file: MD refs=[${refEntries.map((e) => e.file).join(", ")}] vs source=${ann.file}`;
|
|
5447
5728
|
issues.push(issue("E2E-TRACE-003", `executable_ref \u2194 E2E trace annotation mismatch for ${tcKey}: ${detail}`, path, line, { node: tcKey, severity: "warning" }));
|
|
5448
5729
|
}
|
|
5449
5730
|
}
|
|
5450
|
-
for (const [tcKey, { chainType, path, line }] of
|
|
5451
|
-
|
|
5731
|
+
for (const [tcKey, { chainType, path, line }] of allMdTcInfo) {
|
|
5732
|
+
const tcFields = tcKeyToFields.get(tcKey);
|
|
5733
|
+
const explicitChainType = String(tcFields?.["chain_type"] ?? "").trim().toLowerCase();
|
|
5734
|
+
if (explicitChainType !== "desktop_chain") {
|
|
5452
5735
|
continue;
|
|
5453
5736
|
}
|
|
5737
|
+
if (!mdToRef.has(tcKey)) {
|
|
5738
|
+
issues.push(issue("E2E-DESKTOP-CHAIN-MISSING", `desktop_chain TC ${tcKey} has no executable_ref`, path, line, { node: tcKey, severity: "warning" }));
|
|
5739
|
+
}
|
|
5740
|
+
}
|
|
5741
|
+
for (const [tcKey, { chainType, path, line }] of mdToRef) {
|
|
5742
|
+
const normalizedDeclaredChainType = chainType.trim().toLowerCase();
|
|
5743
|
+
const hasLegalNonDesktopDeclaration = normalizedDeclaredChainType.length > 0 && VALID_CHAIN_TYPES.has(normalizedDeclaredChainType) && normalizedDeclaredChainType !== "desktop_chain";
|
|
5744
|
+
if (hasLegalNonDesktopDeclaration) continue;
|
|
5454
5745
|
const validFiles = new Set(tcValidRefFiles.get(tcKey) ?? []);
|
|
5455
5746
|
const sourceAnnotations = refToSource.get(tcKey);
|
|
5456
5747
|
if (!sourceAnnotations) {
|
|
5457
5748
|
continue;
|
|
5458
5749
|
}
|
|
5750
|
+
const tcFields = tcKeyToFields.get(tcKey);
|
|
5751
|
+
const explicitChainType = String(tcFields?.["chain_type"] ?? "").trim().toLowerCase();
|
|
5752
|
+
const hasExplicitDesktopChain = explicitChainType === "desktop_chain";
|
|
5753
|
+
if (hasExplicitDesktopChain) {
|
|
5754
|
+
continue;
|
|
5755
|
+
}
|
|
5459
5756
|
for (const ann of sourceAnnotations) {
|
|
5460
|
-
|
|
5461
|
-
if (!validFiles.has(normalizedAnnFile)) {
|
|
5757
|
+
if (!validFiles.has(ann.file)) {
|
|
5462
5758
|
continue;
|
|
5463
5759
|
}
|
|
5464
5760
|
if (ann.level === "mock_playwright") {
|
|
@@ -5484,10 +5780,7 @@ async function validateExecutableTraceability(root) {
|
|
|
5484
5780
|
continue;
|
|
5485
5781
|
}
|
|
5486
5782
|
const validFiles = new Set(tcValidRefFiles.get(tcKey) ?? []);
|
|
5487
|
-
const sourceAnnotations = refToSource.get(tcKey)?.filter((ann) =>
|
|
5488
|
-
const normalized = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
|
|
5489
|
-
return validFiles.has(normalized);
|
|
5490
|
-
});
|
|
5783
|
+
const sourceAnnotations = refToSource.get(tcKey)?.filter((ann) => validFiles.has(ann.file));
|
|
5491
5784
|
const hasDesktopChain = sourceAnnotations?.some((ann) => ann.level === "desktop_chain") ?? false;
|
|
5492
5785
|
const hasBridge = sourceAnnotations?.some((ann) => ann.level === "ui_sidecar_bridge") ?? false;
|
|
5493
5786
|
if (isComplete) {
|
|
@@ -5496,7 +5789,7 @@ async function validateExecutableTraceability(root) {
|
|
|
5496
5789
|
if (hasDesktopChain) {
|
|
5497
5790
|
hasValidEvidence = true;
|
|
5498
5791
|
} else if (hasBridge) {
|
|
5499
|
-
const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root);
|
|
5792
|
+
const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root, allFiles);
|
|
5500
5793
|
if (partialResult.hasValidPartialRust) {
|
|
5501
5794
|
hasValidEvidence = true;
|
|
5502
5795
|
} else {
|
|
@@ -5536,7 +5829,7 @@ async function validateExecutableTraceability(root) {
|
|
|
5536
5829
|
}
|
|
5537
5830
|
let partialDetail = "";
|
|
5538
5831
|
if (hasBridge) {
|
|
5539
|
-
const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root);
|
|
5832
|
+
const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root, allFiles);
|
|
5540
5833
|
if (partialResult.hasValidPartialRust) {
|
|
5541
5834
|
continue;
|
|
5542
5835
|
}
|
|
@@ -5553,6 +5846,287 @@ async function validateExecutableTraceability(root) {
|
|
|
5553
5846
|
}
|
|
5554
5847
|
return issues;
|
|
5555
5848
|
}
|
|
5849
|
+
async function computeE2eCoverageStats(graph, root, thresholds = {}) {
|
|
5850
|
+
const e2eNodes = graph.nodes.filter((n) => n.type === "e2e_test" && n.attrs?.fileLevelOnly !== true);
|
|
5851
|
+
const totalTestCases = e2eNodes.length;
|
|
5852
|
+
let withExecutableRef = 0;
|
|
5853
|
+
const statusBreakdown = {};
|
|
5854
|
+
const chainTypeBreakdown = {};
|
|
5855
|
+
const e2eDir = join5(root, "artifacts", "tests", "e2e");
|
|
5856
|
+
const tcFieldsMap = /* @__PURE__ */ new Map();
|
|
5857
|
+
let e2eFiles;
|
|
5858
|
+
try {
|
|
5859
|
+
e2eFiles = (await readdir(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).map((name) => join5(e2eDir, name));
|
|
5860
|
+
} catch {
|
|
5861
|
+
e2eFiles = [];
|
|
5862
|
+
}
|
|
5863
|
+
for (const filePath of e2eFiles) {
|
|
5864
|
+
const raw = await readFile2(filePath, "utf-8");
|
|
5865
|
+
const lines = raw.split(/\r?\n/);
|
|
5866
|
+
const tcStarts = [];
|
|
5867
|
+
lines.forEach((line, index) => {
|
|
5868
|
+
const match = /^#{2,3}\s+(TC-\d+[a-z]?)\s*[::]?\s*.*$/.exec(line);
|
|
5869
|
+
if (match) {
|
|
5870
|
+
tcStarts.push({ id: match[1], index });
|
|
5871
|
+
}
|
|
5872
|
+
});
|
|
5873
|
+
const parsed = matter(raw);
|
|
5874
|
+
const batch = String(parsed.data.test_batch ?? basename2(filePath, extname(filePath))).trim();
|
|
5875
|
+
for (let i = 0; i < tcStarts.length; i++) {
|
|
5876
|
+
const start = tcStarts[i];
|
|
5877
|
+
const end = tcStarts[i + 1]?.index ?? lines.length;
|
|
5878
|
+
const block = lines.slice(start.index, end);
|
|
5879
|
+
const fields = extractE2eTcFields(block);
|
|
5880
|
+
tcFieldsMap.set(`${batch}:${start.id}`, fields);
|
|
5881
|
+
}
|
|
5882
|
+
}
|
|
5883
|
+
for (const node of e2eNodes) {
|
|
5884
|
+
const tcKey = node.code;
|
|
5885
|
+
const fields = tcFieldsMap.get(tcKey) ?? asRecord(node.attrs?.tcFields);
|
|
5886
|
+
const execRef = String(fields["executable_ref"] ?? "").trim();
|
|
5887
|
+
if (execRef && !isPendingExecutableRef(execRef)) {
|
|
5888
|
+
withExecutableRef++;
|
|
5889
|
+
}
|
|
5890
|
+
const status = String(fields["status"] ?? "created").trim().toLowerCase();
|
|
5891
|
+
statusBreakdown[status] = (statusBreakdown[status] ?? 0) + 1;
|
|
5892
|
+
const chainType = String(fields["chain_type"] ?? "").trim().toLowerCase() || "unspecified";
|
|
5893
|
+
chainTypeBreakdown[chainType] = (chainTypeBreakdown[chainType] ?? 0) + 1;
|
|
5894
|
+
}
|
|
5895
|
+
const executableRefRate = totalTestCases > 0 ? `${withExecutableRef}/${totalTestCases} (${(withExecutableRef / totalTestCases * 100).toFixed(1)}%)` : "0/0";
|
|
5896
|
+
const scenarioNodes = graph.nodes.filter((n) => n.type === "scenario");
|
|
5897
|
+
const featureNodes = graph.nodes.filter((n) => n.type === "feature");
|
|
5898
|
+
const linkedScenarios = /* @__PURE__ */ new Set();
|
|
5899
|
+
const linkedFeatures = /* @__PURE__ */ new Set();
|
|
5900
|
+
const acCoveredScenarios = /* @__PURE__ */ new Set();
|
|
5901
|
+
const acCoveredFeatures = /* @__PURE__ */ new Set();
|
|
5902
|
+
const verifiedScenarios = /* @__PURE__ */ new Set();
|
|
5903
|
+
const verifiedFeatures = /* @__PURE__ */ new Set();
|
|
5904
|
+
for (const edge2 of graph.edges) {
|
|
5905
|
+
if (edge2.kind === "verifies" && edge2.from.startsWith("e2e_test:")) {
|
|
5906
|
+
if (edge2.to.startsWith("scenario:")) {
|
|
5907
|
+
linkedScenarios.add(edge2.to.replace("scenario:", ""));
|
|
5908
|
+
}
|
|
5909
|
+
if (edge2.to.startsWith("feature:")) {
|
|
5910
|
+
linkedFeatures.add(edge2.to.replace("feature:", ""));
|
|
5911
|
+
}
|
|
5912
|
+
}
|
|
5913
|
+
}
|
|
5914
|
+
for (const node of e2eNodes) {
|
|
5915
|
+
const fields = tcFieldsMap.get(node.code) ?? asRecord(node.attrs?.tcFields);
|
|
5916
|
+
const acCoverage = asRecord(fields["ac_coverage"] ?? node.attrs?.ac_coverage);
|
|
5917
|
+
for (const feature of Object.keys(acCoverage)) {
|
|
5918
|
+
acCoveredFeatures.add(feature);
|
|
5919
|
+
}
|
|
5920
|
+
const relatedScenarios = toArray(fields["related_scenarios"] ?? node.attrs?.related_scenarios).map(String);
|
|
5921
|
+
for (const scenario of relatedScenarios) {
|
|
5922
|
+
acCoveredScenarios.add(scenario);
|
|
5923
|
+
}
|
|
5924
|
+
}
|
|
5925
|
+
const runners = (await loadConfig(root)).e2e?.runners ?? [];
|
|
5926
|
+
const allProjectFiles = await walk(root);
|
|
5927
|
+
for (const node of e2eNodes) {
|
|
5928
|
+
const fields = tcFieldsMap.get(node.code) ?? asRecord(node.attrs?.tcFields);
|
|
5929
|
+
const status = String(fields["status"] ?? "").trim().toLowerCase();
|
|
5930
|
+
const execRef = String(fields["executable_ref"] ?? "").trim();
|
|
5931
|
+
if (status !== "verified") continue;
|
|
5932
|
+
if (!execRef || isPendingExecutableRef(execRef)) continue;
|
|
5933
|
+
if (runners.length === 0) continue;
|
|
5934
|
+
let hasActiveE2eRef = false;
|
|
5935
|
+
for (const entry of parseExecutableRefLines(execRef)) {
|
|
5936
|
+
const normalized = resolveExecutableRefFile(entry.file, allProjectFiles);
|
|
5937
|
+
if (!normalized || !existsSync2(join5(root, normalized))) continue;
|
|
5938
|
+
const accepting = await getAcceptingRunners(root, normalized, runners);
|
|
5939
|
+
if (accepting.some((runner) => runner.kind === "e2e")) {
|
|
5940
|
+
hasActiveE2eRef = true;
|
|
5941
|
+
break;
|
|
5942
|
+
}
|
|
5943
|
+
}
|
|
5944
|
+
if (!hasActiveE2eRef) continue;
|
|
5945
|
+
const relatedScenarios = toArray(fields["related_scenarios"] ?? node.attrs?.related_scenarios).map(String);
|
|
5946
|
+
for (const scenario of relatedScenarios) {
|
|
5947
|
+
verifiedScenarios.add(scenario);
|
|
5948
|
+
}
|
|
5949
|
+
const acCoverage = asRecord(fields["ac_coverage"] ?? node.attrs?.ac_coverage);
|
|
5950
|
+
for (const feature of Object.keys(acCoverage)) {
|
|
5951
|
+
verifiedFeatures.add(feature);
|
|
5952
|
+
}
|
|
5953
|
+
}
|
|
5954
|
+
const scenarioWaivers = new Set((thresholds.scenarioWaivers ?? []).map((w) => w.id));
|
|
5955
|
+
const featureWaivers = new Set((thresholds.featureWaivers ?? []).map((w) => w.id));
|
|
5956
|
+
const uncoveredScenarios = scenarioNodes.map((n) => n.code).filter((code) => !linkedScenarios.has(code) && !scenarioWaivers.has(code));
|
|
5957
|
+
const uncoveredFeatures = featureNodes.map((n) => n.code).filter((code) => !acCoveredFeatures.has(code) && !featureWaivers.has(code));
|
|
5958
|
+
const scenarioCoverage = {};
|
|
5959
|
+
for (const node of scenarioNodes) {
|
|
5960
|
+
scenarioCoverage[node.code] = {
|
|
5961
|
+
linked: linkedScenarios.has(node.code),
|
|
5962
|
+
acCovered: acCoveredScenarios.has(node.code),
|
|
5963
|
+
waived: scenarioWaivers.has(node.code),
|
|
5964
|
+
verified: !scenarioWaivers.has(node.code) && verifiedScenarios.has(node.code)
|
|
5965
|
+
};
|
|
5966
|
+
}
|
|
5967
|
+
const featureCoverage = {};
|
|
5968
|
+
for (const node of featureNodes) {
|
|
5969
|
+
featureCoverage[node.code] = {
|
|
5970
|
+
linked: linkedFeatures.has(node.code),
|
|
5971
|
+
acCovered: acCoveredFeatures.has(node.code),
|
|
5972
|
+
waived: featureWaivers.has(node.code),
|
|
5973
|
+
verified: !featureWaivers.has(node.code) && verifiedFeatures.has(node.code)
|
|
5974
|
+
};
|
|
5975
|
+
}
|
|
5976
|
+
const thresholdWarnings = [];
|
|
5977
|
+
const thresholdErrors = [];
|
|
5978
|
+
const warningRate = thresholds.executableRefWarning;
|
|
5979
|
+
const errorRate = thresholds.executableRefError;
|
|
5980
|
+
const actualRate = totalTestCases > 0 ? withExecutableRef / totalTestCases : 1;
|
|
5981
|
+
if (warningRate !== void 0 && actualRate < warningRate) {
|
|
5982
|
+
thresholdWarnings.push(`executable_ref coverage ${executableRefRate} < warning threshold ${(warningRate * 100).toFixed(0)}%`);
|
|
5983
|
+
}
|
|
5984
|
+
if (errorRate !== void 0 && actualRate < errorRate) {
|
|
5985
|
+
thresholdErrors.push(`executable_ref coverage ${executableRefRate} < error threshold ${(errorRate * 100).toFixed(0)}%`);
|
|
5986
|
+
}
|
|
5987
|
+
if (thresholds.reportUncoveredScenarios !== false && uncoveredScenarios.length > 0) {
|
|
5988
|
+
thresholdWarnings.push(`${uncoveredScenarios.length} scenario(s) have no E2E coverage: ${uncoveredScenarios.join(", ")}`);
|
|
5989
|
+
}
|
|
5990
|
+
if (thresholds.reportUncoveredFeatures !== false && uncoveredFeatures.length > 0) {
|
|
5991
|
+
thresholdWarnings.push(`${uncoveredFeatures.length} feature(s) have no E2E coverage: ${uncoveredFeatures.join(", ")}`);
|
|
5992
|
+
}
|
|
5993
|
+
const acCoverageRateByFeature = {};
|
|
5994
|
+
const featureAcMap = /* @__PURE__ */ new Map();
|
|
5995
|
+
for (const node of featureNodes) {
|
|
5996
|
+
const acs = parseAcceptanceCriteria(await readFile2(join5(root, node.path), "utf-8"));
|
|
5997
|
+
featureAcMap.set(node.code, new Set(acs));
|
|
5998
|
+
}
|
|
5999
|
+
const coveredAcByFeature = /* @__PURE__ */ new Map();
|
|
6000
|
+
for (const node of e2eNodes) {
|
|
6001
|
+
const fields = tcFieldsMap.get(node.code) ?? asRecord(node.attrs?.tcFields);
|
|
6002
|
+
const acCoverage = asRecord(fields["ac_coverage"] ?? node.attrs?.ac_coverage);
|
|
6003
|
+
for (const [feature, acs] of Object.entries(acCoverage)) {
|
|
6004
|
+
const existing = coveredAcByFeature.get(feature) ?? /* @__PURE__ */ new Set();
|
|
6005
|
+
for (const ac of toArray(acs)) {
|
|
6006
|
+
existing.add(String(ac));
|
|
6007
|
+
}
|
|
6008
|
+
coveredAcByFeature.set(feature, existing);
|
|
6009
|
+
}
|
|
6010
|
+
}
|
|
6011
|
+
for (const [feature, allAcs] of featureAcMap) {
|
|
6012
|
+
const denominator = allAcs.size;
|
|
6013
|
+
if (denominator === 0) continue;
|
|
6014
|
+
const coveredAcs = coveredAcByFeature.get(feature) ?? /* @__PURE__ */ new Set();
|
|
6015
|
+
const numerator = [...coveredAcs].filter((ac) => allAcs.has(ac)).length;
|
|
6016
|
+
acCoverageRateByFeature[feature] = {
|
|
6017
|
+
numerator,
|
|
6018
|
+
denominator,
|
|
6019
|
+
rate: denominator > 0 ? numerator / denominator : 0
|
|
6020
|
+
};
|
|
6021
|
+
}
|
|
6022
|
+
return {
|
|
6023
|
+
totalTestCases,
|
|
6024
|
+
withExecutableRef,
|
|
6025
|
+
executableRefRate,
|
|
6026
|
+
statusBreakdown,
|
|
6027
|
+
chainTypeBreakdown,
|
|
6028
|
+
uncoveredScenarios,
|
|
6029
|
+
uncoveredFeatures,
|
|
6030
|
+
thresholdWarnings,
|
|
6031
|
+
thresholdErrors,
|
|
6032
|
+
acCoverageRateByFeature,
|
|
6033
|
+
scenarioCoverage,
|
|
6034
|
+
featureCoverage
|
|
6035
|
+
};
|
|
6036
|
+
}
|
|
6037
|
+
async function generateE2eRegistry(root, opts) {
|
|
6038
|
+
const e2eDir = join5(root, "artifacts", "tests", "e2e");
|
|
6039
|
+
let files;
|
|
6040
|
+
try {
|
|
6041
|
+
files = (await readdir(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).sort();
|
|
6042
|
+
} catch {
|
|
6043
|
+
return {
|
|
6044
|
+
registry_version: "1.0",
|
|
6045
|
+
generated_at: opts?.deterministic ? "1970-01-01T00:00:00.000Z" : (/* @__PURE__ */ new Date()).toISOString(),
|
|
6046
|
+
total_batches: 0,
|
|
6047
|
+
total_test_cases: 0,
|
|
6048
|
+
batches: []
|
|
6049
|
+
};
|
|
6050
|
+
}
|
|
6051
|
+
const batches = [];
|
|
6052
|
+
let totalTestCases = 0;
|
|
6053
|
+
for (const file of files) {
|
|
6054
|
+
const filePath = join5(e2eDir, file);
|
|
6055
|
+
const raw = await readFile2(filePath, "utf-8");
|
|
6056
|
+
const parsed = matter(raw);
|
|
6057
|
+
const data = parsed.data;
|
|
6058
|
+
const batch = String(data.test_batch ?? basename2(file, extname(file))).trim();
|
|
6059
|
+
const relPath = `artifacts/tests/e2e/${file}`;
|
|
6060
|
+
const scope = String(data.scope ?? "").trim();
|
|
6061
|
+
const acCoverage = normalizeAcCoverageForRegistry(data.ac_coverage);
|
|
6062
|
+
const relatedScenarios = toArray(data.related_scenarios).map(String).filter(Boolean);
|
|
6063
|
+
const lines = raw.split(/\r?\n/);
|
|
6064
|
+
const tcStarts = [];
|
|
6065
|
+
lines.forEach((line, index) => {
|
|
6066
|
+
const match = /^#{2,3}\s+(TC-\d+[a-z]?)\s*[::]?\s*.*$/.exec(line);
|
|
6067
|
+
if (match) {
|
|
6068
|
+
tcStarts.push({ id: match[1], index });
|
|
6069
|
+
}
|
|
6070
|
+
});
|
|
6071
|
+
const statusSummary = {};
|
|
6072
|
+
const blockingReasons = {};
|
|
6073
|
+
const frontmatterFixesBlock = String(data.fixes_block ?? "").trim();
|
|
6074
|
+
if (frontmatterFixesBlock && /no\s+(test\s+file|e2e)/i.test(frontmatterFixesBlock)) {
|
|
6075
|
+
for (const tc of tcStarts) {
|
|
6076
|
+
blockingReasons[tc.id] = frontmatterFixesBlock;
|
|
6077
|
+
}
|
|
6078
|
+
}
|
|
6079
|
+
for (const start of tcStarts) {
|
|
6080
|
+
const end = tcStarts[tcStarts.indexOf(start) + 1]?.index ?? lines.length;
|
|
6081
|
+
const block = lines.slice(start.index, end);
|
|
6082
|
+
const fields = extractE2eTcFields(block);
|
|
6083
|
+
const status = String(fields["status"] ?? "created").trim().toLowerCase() || "created";
|
|
6084
|
+
statusSummary[status] = (statusSummary[status] ?? 0) + 1;
|
|
6085
|
+
if (status === "created" && !blockingReasons[start.id]) {
|
|
6086
|
+
const executableRef = String(fields["executable_ref"] ?? "").trim();
|
|
6087
|
+
const chainType = String(fields["chain_type"] ?? "").trim().toLowerCase();
|
|
6088
|
+
if (!executableRef && chainType === "desktop_chain") {
|
|
6089
|
+
blockingReasons[start.id] = "desktop_chain TC requires executable_ref";
|
|
6090
|
+
} else if (isPendingExecutableRef(executableRef)) {
|
|
6091
|
+
blockingReasons[start.id] = `pending: ${executableRef}`;
|
|
6092
|
+
}
|
|
6093
|
+
}
|
|
6094
|
+
}
|
|
6095
|
+
const testCaseCount = tcStarts.length;
|
|
6096
|
+
totalTestCases += testCaseCount;
|
|
6097
|
+
const batchStatus = Object.keys(blockingReasons).length > 0 ? "blocked" : void 0;
|
|
6098
|
+
batches.push({
|
|
6099
|
+
batch_id: batch,
|
|
6100
|
+
file: relPath,
|
|
6101
|
+
scope,
|
|
6102
|
+
ac_coverage: acCoverage,
|
|
6103
|
+
related_scenarios: relatedScenarios,
|
|
6104
|
+
test_case_count: testCaseCount,
|
|
6105
|
+
status_summary: statusSummary,
|
|
6106
|
+
status: batchStatus,
|
|
6107
|
+
blocking_reasons: Object.keys(blockingReasons).length > 0 ? blockingReasons : void 0
|
|
6108
|
+
});
|
|
6109
|
+
}
|
|
6110
|
+
return {
|
|
6111
|
+
registry_version: "1.0",
|
|
6112
|
+
generated_at: opts?.deterministic ? "1970-01-01T00:00:00.000Z" : (/* @__PURE__ */ new Date()).toISOString(),
|
|
6113
|
+
total_batches: batches.length,
|
|
6114
|
+
total_test_cases: totalTestCases,
|
|
6115
|
+
batches
|
|
6116
|
+
};
|
|
6117
|
+
}
|
|
6118
|
+
function normalizeAcCoverageForRegistry(value) {
|
|
6119
|
+
if (!value || typeof value !== "object") return {};
|
|
6120
|
+
const result = {};
|
|
6121
|
+
for (const [key, val] of Object.entries(value)) {
|
|
6122
|
+
if (Array.isArray(val)) {
|
|
6123
|
+
result[key] = val.map(String);
|
|
6124
|
+
} else if (typeof val === "string") {
|
|
6125
|
+
result[key] = val.split(",").map((s) => s.trim()).filter(Boolean);
|
|
6126
|
+
}
|
|
6127
|
+
}
|
|
6128
|
+
return result;
|
|
6129
|
+
}
|
|
5556
6130
|
function isPendingExecutableRef(ref) {
|
|
5557
6131
|
const stripped = ref.replace(/^[\s-*()]+/, "").trim();
|
|
5558
6132
|
return /^pending\b/i.test(stripped);
|
|
@@ -5570,6 +6144,20 @@ function parseExecutableRefLines(ref) {
|
|
|
5570
6144
|
}
|
|
5571
6145
|
return results;
|
|
5572
6146
|
}
|
|
6147
|
+
var VALID_TC_STATUSES = /* @__PURE__ */ new Set(["created", "automated", "verified", "waived"]);
|
|
6148
|
+
var VALID_CHAIN_TYPES = /* @__PURE__ */ new Set([
|
|
6149
|
+
"desktop_chain",
|
|
6150
|
+
"mock_playwright",
|
|
6151
|
+
"core_e2e",
|
|
6152
|
+
"cli_e2e",
|
|
6153
|
+
"ui_sidecar_bridge",
|
|
6154
|
+
"partial_sidecar",
|
|
6155
|
+
"partial_rust"
|
|
6156
|
+
]);
|
|
6157
|
+
var DEPRECATED_CHAIN_TYPE_ALIASES = {
|
|
6158
|
+
core_only: "core_e2e",
|
|
6159
|
+
frontend_only: "mock_playwright"
|
|
6160
|
+
};
|
|
5573
6161
|
function isDesktopChainType(chainType) {
|
|
5574
6162
|
const normalizedChainType = chainType.trim().toLowerCase();
|
|
5575
6163
|
return normalizedChainType === "desktop_chain" || normalizedChainType === "";
|
|
@@ -5577,7 +6165,7 @@ function isDesktopChainType(chainType) {
|
|
|
5577
6165
|
function parseChainCoverageStatus(chainCoverage) {
|
|
5578
6166
|
return chainCoverage.trim().toLowerCase().match(/^[a-z_]+/)?.[0] ?? "";
|
|
5579
6167
|
}
|
|
5580
|
-
async function validatePartialRustEvidence(tcFields, tcKey, root) {
|
|
6168
|
+
async function validatePartialRustEvidence(tcFields, tcKey, root, allFiles) {
|
|
5581
6169
|
const partialEvidence = String(tcFields["partial_evidence"] ?? "");
|
|
5582
6170
|
if (!partialEvidence.trim()) {
|
|
5583
6171
|
return { hasValidPartialRust: false, detail: "no partial_evidence field" };
|
|
@@ -5601,7 +6189,10 @@ async function validatePartialRustEvidence(tcFields, tcKey, root) {
|
|
|
5601
6189
|
return { hasValidPartialRust: false, detail: "no .rs file in partial_evidence" };
|
|
5602
6190
|
}
|
|
5603
6191
|
for (const ref of rustRefs) {
|
|
5604
|
-
const normalizedPath =
|
|
6192
|
+
const normalizedPath = resolveExecutableRefFile(ref.file, allFiles);
|
|
6193
|
+
if (!normalizedPath) {
|
|
6194
|
+
return { hasValidPartialRust: false, detail: `partial_rust file not found: ${ref.file}` };
|
|
6195
|
+
}
|
|
5605
6196
|
const fullPath = join5(root, normalizedPath);
|
|
5606
6197
|
let content;
|
|
5607
6198
|
try {
|
|
@@ -5637,6 +6228,46 @@ function detectTestLevel(specFile, content) {
|
|
|
5637
6228
|
}
|
|
5638
6229
|
return "desktop_chain";
|
|
5639
6230
|
}
|
|
6231
|
+
function resolveExecutableRefFile(refFile, allFiles) {
|
|
6232
|
+
const normalized = refFile.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
6233
|
+
if (!normalized || isAbsolute3(refFile) || normalized.split("/").includes("..")) {
|
|
6234
|
+
return void 0;
|
|
6235
|
+
}
|
|
6236
|
+
if (allFiles.includes(normalized)) {
|
|
6237
|
+
return normalized;
|
|
6238
|
+
}
|
|
6239
|
+
const suffix = `/${normalized}`;
|
|
6240
|
+
const matches = allFiles.filter((file) => file.endsWith(suffix));
|
|
6241
|
+
return matches.length === 1 ? matches[0] : void 0;
|
|
6242
|
+
}
|
|
6243
|
+
function isRunnerIncludeCandidate(filePath, runner) {
|
|
6244
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
6245
|
+
const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
|
|
6246
|
+
if (runnerRoot !== "." && !normalizedPath.startsWith(`${runnerRoot}/`)) {
|
|
6247
|
+
return false;
|
|
6248
|
+
}
|
|
6249
|
+
const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.slice(runnerRoot.length + 1);
|
|
6250
|
+
return runner.include.some((pattern) => matchesRunnerGlob(relativePath, pattern));
|
|
6251
|
+
}
|
|
6252
|
+
async function getAcceptingRunners(root, filePath, runners) {
|
|
6253
|
+
const accepting = [];
|
|
6254
|
+
for (const runner of runners) {
|
|
6255
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
6256
|
+
const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
|
|
6257
|
+
const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.startsWith(runnerRoot + "/") ? normalizedPath.slice(runnerRoot.length + 1) : normalizedPath;
|
|
6258
|
+
if (runnerRoot !== "." && !normalizedPath.startsWith(`${runnerRoot}/`)) {
|
|
6259
|
+
continue;
|
|
6260
|
+
}
|
|
6261
|
+
const matchesInclude = runner.include.some((p) => matchesRunnerGlob(relativePath, p));
|
|
6262
|
+
if (!matchesInclude) continue;
|
|
6263
|
+
const matchesExclude = (runner.exclude ?? []).some((p) => matchesRunnerGlob(relativePath, p));
|
|
6264
|
+
if (matchesExclude) continue;
|
|
6265
|
+
const matchesTestIgnore = (runner.testIgnore ?? []).some((p) => matchesRunnerGlob(relativePath, p));
|
|
6266
|
+
if (matchesTestIgnore) continue;
|
|
6267
|
+
accepting.push(runner);
|
|
6268
|
+
}
|
|
6269
|
+
return accepting;
|
|
6270
|
+
}
|
|
5640
6271
|
function splitMarkdownCells(line) {
|
|
5641
6272
|
const cells = [];
|
|
5642
6273
|
let current = "";
|
|
@@ -5790,7 +6421,7 @@ function flattenAcCoverage(value) {
|
|
|
5790
6421
|
function needsDesktopChainWarning(node) {
|
|
5791
6422
|
const tcFields = asRecord(node.attrs?.tcFields);
|
|
5792
6423
|
const chainType = String(tcFields["chain_type"] ?? "").trim().toLowerCase();
|
|
5793
|
-
if (chainType
|
|
6424
|
+
if (chainType && VALID_CHAIN_TYPES.has(chainType) && chainType !== "desktop_chain" || chainType in DEPRECATED_CHAIN_TYPE_ALIASES) {
|
|
5794
6425
|
return false;
|
|
5795
6426
|
}
|
|
5796
6427
|
const chainCoverage = String(tcFields["chain_coverage"] ?? "").trim().toLowerCase();
|
|
@@ -6663,14 +7294,109 @@ async function runCli(argv, io = {}) {
|
|
|
6663
7294
|
const graph = await scanArtifacts(root, config);
|
|
6664
7295
|
const issues = validateGraph(graph, config);
|
|
6665
7296
|
issues.push(...await validateScenarioPrdLinkIndex(root, graph));
|
|
6666
|
-
issues.push(...await validateExecutableTraceability(root));
|
|
7297
|
+
issues.push(...await validateExecutableTraceability(root, config));
|
|
7298
|
+
const includeCoverage = includes.has("e2e-coverage") || config.e2e?.executable_ref_warning !== void 0 || config.e2e?.executable_ref_error !== void 0;
|
|
7299
|
+
let coverageStats = null;
|
|
7300
|
+
if (includeCoverage) {
|
|
7301
|
+
const e2eConfig = config.e2e ?? {};
|
|
7302
|
+
coverageStats = await computeE2eCoverageStats(graph, root, {
|
|
7303
|
+
executableRefWarning: e2eConfig.executable_ref_warning,
|
|
7304
|
+
executableRefError: e2eConfig.executable_ref_error,
|
|
7305
|
+
reportUncoveredScenarios: e2eConfig.report_uncovered_scenarios,
|
|
7306
|
+
reportUncoveredFeatures: e2eConfig.report_uncovered_features,
|
|
7307
|
+
scenarioWaivers: e2eConfig.scenario_waivers,
|
|
7308
|
+
featureWaivers: e2eConfig.feature_waivers
|
|
7309
|
+
});
|
|
7310
|
+
for (const msg of coverageStats.thresholdWarnings) {
|
|
7311
|
+
issues.push({
|
|
7312
|
+
code: "E2E_COVERAGE_WARNING",
|
|
7313
|
+
severity: "warning",
|
|
7314
|
+
message: msg,
|
|
7315
|
+
path: "e2e-coverage",
|
|
7316
|
+
line: 1
|
|
7317
|
+
});
|
|
7318
|
+
}
|
|
7319
|
+
for (const msg of coverageStats.thresholdErrors) {
|
|
7320
|
+
issues.push({
|
|
7321
|
+
code: "E2E_COVERAGE_ERROR",
|
|
7322
|
+
severity: "error",
|
|
7323
|
+
message: msg,
|
|
7324
|
+
path: "e2e-coverage",
|
|
7325
|
+
line: 1
|
|
7326
|
+
});
|
|
7327
|
+
}
|
|
7328
|
+
}
|
|
6667
7329
|
if (parsed.flags.format === "json") {
|
|
6668
|
-
|
|
7330
|
+
if (coverageStats) {
|
|
7331
|
+
const output = {
|
|
7332
|
+
issues,
|
|
7333
|
+
e2eCoverage: {
|
|
7334
|
+
totalTestCases: coverageStats.totalTestCases,
|
|
7335
|
+
withExecutableRef: coverageStats.withExecutableRef,
|
|
7336
|
+
executableRefRate: coverageStats.executableRefRate,
|
|
7337
|
+
statusBreakdown: coverageStats.statusBreakdown,
|
|
7338
|
+
chainTypeBreakdown: coverageStats.chainTypeBreakdown,
|
|
7339
|
+
uncoveredScenarios: coverageStats.uncoveredScenarios,
|
|
7340
|
+
uncoveredFeatures: coverageStats.uncoveredFeatures,
|
|
7341
|
+
acCoverageRateByFeature: coverageStats.acCoverageRateByFeature,
|
|
7342
|
+
scenarioCoverage: coverageStats.scenarioCoverage,
|
|
7343
|
+
featureCoverage: coverageStats.featureCoverage
|
|
7344
|
+
}
|
|
7345
|
+
};
|
|
7346
|
+
out(`${JSON.stringify(output, null, 2)}
|
|
7347
|
+
`);
|
|
7348
|
+
} else {
|
|
7349
|
+
out(`${JSON.stringify(issues, null, 2)}
|
|
6669
7350
|
`);
|
|
6670
|
-
|
|
6671
|
-
out("No validation issues\n");
|
|
7351
|
+
}
|
|
6672
7352
|
} else {
|
|
6673
|
-
|
|
7353
|
+
if (coverageStats) {
|
|
7354
|
+
out(`E2E Coverage: ${coverageStats.executableRefRate} executable_ref
|
|
7355
|
+
`);
|
|
7356
|
+
out(` Status: ${JSON.stringify(coverageStats.statusBreakdown)}
|
|
7357
|
+
`);
|
|
7358
|
+
out(` Chain types: ${JSON.stringify(coverageStats.chainTypeBreakdown)}
|
|
7359
|
+
`);
|
|
7360
|
+
if (coverageStats.uncoveredScenarios.length > 0) {
|
|
7361
|
+
out(` Uncovered scenarios (${coverageStats.uncoveredScenarios.length}): ${coverageStats.uncoveredScenarios.join(", ")}
|
|
7362
|
+
`);
|
|
7363
|
+
}
|
|
7364
|
+
if (coverageStats.uncoveredFeatures.length > 0) {
|
|
7365
|
+
out(` Uncovered features (${coverageStats.uncoveredFeatures.length}): ${coverageStats.uncoveredFeatures.join(", ")}
|
|
7366
|
+
`);
|
|
7367
|
+
}
|
|
7368
|
+
if (Object.keys(coverageStats.acCoverageRateByFeature).length > 0) {
|
|
7369
|
+
out(` AC coverage by feature:
|
|
7370
|
+
`);
|
|
7371
|
+
for (const [feature, rate] of Object.entries(coverageStats.acCoverageRateByFeature)) {
|
|
7372
|
+
out(` ${feature}: ${rate.numerator}/${rate.denominator} (${(rate.rate * 100).toFixed(1)}%)
|
|
7373
|
+
`);
|
|
7374
|
+
}
|
|
7375
|
+
}
|
|
7376
|
+
const scenarioStats = Object.values(coverageStats.scenarioCoverage);
|
|
7377
|
+
const featureStats = Object.values(coverageStats.featureCoverage);
|
|
7378
|
+
if (scenarioStats.length > 0) {
|
|
7379
|
+
const linked = scenarioStats.filter((s) => s.linked).length;
|
|
7380
|
+
const acCovered = scenarioStats.filter((s) => s.acCovered).length;
|
|
7381
|
+
const waived = scenarioStats.filter((s) => s.waived).length;
|
|
7382
|
+
const verified = scenarioStats.filter((s) => s.verified).length;
|
|
7383
|
+
out(` Scenario coverage: linked=${linked}, acCovered=${acCovered}, waived=${waived}, verified=${verified}
|
|
7384
|
+
`);
|
|
7385
|
+
}
|
|
7386
|
+
if (featureStats.length > 0) {
|
|
7387
|
+
const linked = featureStats.filter((s) => s.linked).length;
|
|
7388
|
+
const acCovered = featureStats.filter((s) => s.acCovered).length;
|
|
7389
|
+
const waived = featureStats.filter((s) => s.waived).length;
|
|
7390
|
+
const verified = featureStats.filter((s) => s.verified).length;
|
|
7391
|
+
out(` Feature coverage: linked=${linked}, acCovered=${acCovered}, waived=${waived}, verified=${verified}
|
|
7392
|
+
`);
|
|
7393
|
+
}
|
|
7394
|
+
}
|
|
7395
|
+
if (issues.length === 0) {
|
|
7396
|
+
out("No validation issues\n");
|
|
7397
|
+
} else {
|
|
7398
|
+
out(issues.map((issue2) => `${issue2.code} ${issue2.path}:${issue2.line} ${issue2.message}`).join("\n") + "\n");
|
|
7399
|
+
}
|
|
6674
7400
|
}
|
|
6675
7401
|
return issues.some((issue2) => issue2.severity === "error") && !parsed.flags["warning-only"] ? 1 : 0;
|
|
6676
7402
|
}
|
|
@@ -7296,7 +8022,8 @@ async function runCli(argv, io = {}) {
|
|
|
7296
8022
|
`);
|
|
7297
8023
|
return 1;
|
|
7298
8024
|
}
|
|
7299
|
-
const
|
|
8025
|
+
const config = await loadConfig(root);
|
|
8026
|
+
const result = await auditVersionLock(root, lockPath, void 0, config);
|
|
7300
8027
|
if (versionLockAuditFormat === "json") {
|
|
7301
8028
|
out(`${JSON.stringify(result, null, 2)}
|
|
7302
8029
|
`);
|
|
@@ -7519,6 +8246,39 @@ async function runCli(argv, io = {}) {
|
|
|
7519
8246
|
}
|
|
7520
8247
|
return validationErrors.length === 0 ? 0 : 1;
|
|
7521
8248
|
}
|
|
8249
|
+
case "generate-e2e-registry": {
|
|
8250
|
+
const checkMode = parsed.flags.check === true;
|
|
8251
|
+
const deterministic = checkMode || parsed.flags.deterministic === true;
|
|
8252
|
+
const registry = await generateE2eRegistry(root, { deterministic });
|
|
8253
|
+
const output = JSON.stringify(registry, null, 2) + "\n";
|
|
8254
|
+
const outPath = typeof parsed.flags.out === "string" ? parsed.flags.out : join7(root, "artifacts/tests/e2e/e2e-test-registry.json");
|
|
8255
|
+
if (checkMode) {
|
|
8256
|
+
let existing = "";
|
|
8257
|
+
try {
|
|
8258
|
+
existing = await readFile3(outPath, "utf-8");
|
|
8259
|
+
} catch {
|
|
8260
|
+
err(`Check failed: ${outPath} does not exist or is not readable
|
|
8261
|
+
`);
|
|
8262
|
+
return 1;
|
|
8263
|
+
}
|
|
8264
|
+
if (existing !== output) {
|
|
8265
|
+
err(`Registry drift detected: ${outPath} differs from deterministic generation
|
|
8266
|
+
`);
|
|
8267
|
+
return 1;
|
|
8268
|
+
}
|
|
8269
|
+
out(`Registry check passed: ${outPath} matches deterministic generation
|
|
8270
|
+
`);
|
|
8271
|
+
return 0;
|
|
8272
|
+
}
|
|
8273
|
+
if (typeof parsed.flags.out === "string") {
|
|
8274
|
+
await writeFile5(parsed.flags.out, output);
|
|
8275
|
+
out(`Registry written to ${parsed.flags.out} (${registry.total_batches} batches, ${registry.total_test_cases} TCs)
|
|
8276
|
+
`);
|
|
8277
|
+
} else {
|
|
8278
|
+
out(output);
|
|
8279
|
+
}
|
|
8280
|
+
return 0;
|
|
8281
|
+
}
|
|
7522
8282
|
default:
|
|
7523
8283
|
err(helpText());
|
|
7524
8284
|
return 1;
|
|
@@ -7581,7 +8341,7 @@ function helpText() {
|
|
|
7581
8341
|
Commands:
|
|
7582
8342
|
init
|
|
7583
8343
|
scan
|
|
7584
|
-
validate [--format json] [--warning-only] [--include scenario-prd-links]
|
|
8344
|
+
validate [--format json] [--warning-only] [--include scenario-prd-links,e2e-coverage]
|
|
7585
8345
|
query --from <code> [--format json]
|
|
7586
8346
|
context (--target <type>:<id> | --feature <id> | --scenario <id> | --decision <id> | --design <id> | --e2e-test <id>) [--mode full|implementation] [--max-per-category <n>] [--format json]
|
|
7587
8347
|
packet (--target <type>:<id> | --feature <id> | --scenario <id> | --decision <id> | --design <id> | --e2e-test <id>) [--mode full|implementation] [--max-per-category <n>] [--format json|markdown] [--out <path>] [--no-validate]
|
|
@@ -7599,6 +8359,7 @@ Commands:
|
|
|
7599
8359
|
render [--format mermaid]
|
|
7600
8360
|
doctor [--format json|markdown]
|
|
7601
8361
|
validate-review-result --file <path> [--format json]
|
|
8362
|
+
generate-e2e-registry [--deterministic] [--out <path>] [--check]
|
|
7602
8363
|
`;
|
|
7603
8364
|
}
|
|
7604
8365
|
function isCliEntrypoint(argvPath) {
|