artifact-graph 0.5.0 → 0.6.0
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 +41 -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/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
3
|
import matter from "gray-matter";
|
|
4
4
|
import yaml from "js-yaml";
|
|
5
|
-
import { accessSync, constants as fsConstants, statSync } from "fs";
|
|
5
|
+
import { accessSync, constants as fsConstants, existsSync as existsSync2, statSync } from "fs";
|
|
6
6
|
import { mkdir as mkdir4, readFile as readFile2, readdir, writeFile as writeFile3 } from "fs/promises";
|
|
7
7
|
import { basename as basename2, dirname as dirname3, extname, isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve as resolve3 } from "path";
|
|
8
8
|
|
|
@@ -259,6 +259,45 @@ function validatePacketMarkdown(markdown) {
|
|
|
259
259
|
return { ok: !hasError, issues };
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
// src/glob-matcher.ts
|
|
263
|
+
function matchesRunnerGlob(filePath, pattern) {
|
|
264
|
+
const normalizedPath = normalizeGlobValue(filePath);
|
|
265
|
+
const normalizedPattern = normalizeGlobValue(pattern);
|
|
266
|
+
let expression = "^";
|
|
267
|
+
for (let index = 0; index < normalizedPattern.length; index += 1) {
|
|
268
|
+
const character = normalizedPattern[index];
|
|
269
|
+
if (character === "*") {
|
|
270
|
+
if (normalizedPattern[index + 1] === "*") {
|
|
271
|
+
while (normalizedPattern[index + 1] === "*") {
|
|
272
|
+
index += 1;
|
|
273
|
+
}
|
|
274
|
+
if (normalizedPattern[index + 1] === "/") {
|
|
275
|
+
index += 1;
|
|
276
|
+
expression += "(?:[^/]+/)*";
|
|
277
|
+
} else {
|
|
278
|
+
expression += ".*";
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
expression += "[^/]*";
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (character === "?") {
|
|
286
|
+
expression += "[^/]";
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
expression += escapeRegexCharacter(character);
|
|
290
|
+
}
|
|
291
|
+
expression += "$";
|
|
292
|
+
return new RegExp(expression).test(normalizedPath);
|
|
293
|
+
}
|
|
294
|
+
function normalizeGlobValue(value) {
|
|
295
|
+
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
296
|
+
}
|
|
297
|
+
function escapeRegexCharacter(character) {
|
|
298
|
+
return "\\^$+?.()|{}[]".includes(character) ? String.fromCharCode(92) + character : character;
|
|
299
|
+
}
|
|
300
|
+
|
|
262
301
|
// src/target-selector.ts
|
|
263
302
|
function parseTargetSelector(value) {
|
|
264
303
|
const separator = value.indexOf(":");
|
|
@@ -1276,6 +1315,7 @@ function validatePacketPrompt(prompt) {
|
|
|
1276
1315
|
|
|
1277
1316
|
// src/versioned-traceability.ts
|
|
1278
1317
|
import { createHash } from "crypto";
|
|
1318
|
+
import { existsSync } from "fs";
|
|
1279
1319
|
import { mkdir as mkdir2, readFile, writeFile as writeFile2 } from "fs/promises";
|
|
1280
1320
|
import { dirname, join as join2, relative } from "path";
|
|
1281
1321
|
var VERSION_LOCK_PATH = "artifacts/traceability-version-lock.json";
|
|
@@ -1316,13 +1356,15 @@ async function buildVersionIndex(root, graph) {
|
|
|
1316
1356
|
edges: sortBy(edges, (edge2) => `${edge2.from} ${edge2.to} ${edge2.kind} ${edge2.sourcePath} ${edge2.sourceLine}`)
|
|
1317
1357
|
};
|
|
1318
1358
|
}
|
|
1319
|
-
async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph) {
|
|
1359
|
+
async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph, config) {
|
|
1320
1360
|
const index = await buildVersionIndex(root, graph);
|
|
1361
|
+
const schema = config ?? await loadConfig(root);
|
|
1321
1362
|
const safeLockPath = normalizeRelativePath(root, lockPath);
|
|
1322
1363
|
const lock = await readVersionLock(root, safeLockPath);
|
|
1323
1364
|
const nodeByArtifact = new Map(index.nodes.map((node) => [`${node.type}:${node.id}`, node]));
|
|
1324
1365
|
const nodeByPath = new Map(index.nodes.map((node) => [node.path, node]));
|
|
1325
1366
|
const currentEdges = implementationEdges(index);
|
|
1367
|
+
const lockableEdges = await lockableImplementationEdges(root, index, schema);
|
|
1326
1368
|
const currentEdgeIds = new Set(currentEdges.map((edge2) => edge2.edgeId));
|
|
1327
1369
|
const issues = [];
|
|
1328
1370
|
let fresh = 0;
|
|
@@ -1410,8 +1452,29 @@ async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph) {
|
|
|
1410
1452
|
issues.push(...entryIssues);
|
|
1411
1453
|
}
|
|
1412
1454
|
}
|
|
1455
|
+
const livenessCache = /* @__PURE__ */ new Map();
|
|
1456
|
+
for (const entry of lock.locks) {
|
|
1457
|
+
if (entry.kind !== "verifies") continue;
|
|
1458
|
+
const sourcePath = entry.source.path;
|
|
1459
|
+
const fullSourcePath = join2(root, sourcePath);
|
|
1460
|
+
if (!existsSync(fullSourcePath)) continue;
|
|
1461
|
+
let liveness = livenessCache.get(sourcePath);
|
|
1462
|
+
if (liveness === void 0) {
|
|
1463
|
+
liveness = await getTestFileRunnerLiveness(root, sourcePath, schema);
|
|
1464
|
+
livenessCache.set(sourcePath, liveness);
|
|
1465
|
+
}
|
|
1466
|
+
if (liveness === "inactive") {
|
|
1467
|
+
issues.push({
|
|
1468
|
+
status: "orphan_lock",
|
|
1469
|
+
edgeId: entry.edgeId,
|
|
1470
|
+
message: `Liveness: ${sourcePath} is not active in any configured runner \u2014 locked verifies edges may reference dead tests`,
|
|
1471
|
+
artifact: entry.artifact,
|
|
1472
|
+
source: entry.source
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1413
1476
|
const reportedMissingLocks = /* @__PURE__ */ new Set();
|
|
1414
|
-
for (const edge2 of
|
|
1477
|
+
for (const edge2 of lockableEdges) {
|
|
1415
1478
|
const edgeId = edge2.edgeId;
|
|
1416
1479
|
if (!lock.locks.some((entry) => entry.edgeId === edgeId)) {
|
|
1417
1480
|
if (reportedMissingLocks.has(edgeId)) {
|
|
@@ -1493,6 +1556,7 @@ async function updateVersionLock(root, options) {
|
|
|
1493
1556
|
}
|
|
1494
1557
|
async function bootstrapVersionLock(root, options = {}) {
|
|
1495
1558
|
const index = await buildVersionIndex(root);
|
|
1559
|
+
const config = await loadConfig(root);
|
|
1496
1560
|
const lockPath = normalizeRelativePath(root, options.lockPath ?? VERSION_LOCK_PATH);
|
|
1497
1561
|
if (!options.force) {
|
|
1498
1562
|
const existing = await readVersionLock(root, lockPath);
|
|
@@ -1502,7 +1566,7 @@ async function bootstrapVersionLock(root, options = {}) {
|
|
|
1502
1566
|
}
|
|
1503
1567
|
const nodeByUid = new Map(index.nodes.map((node) => [node.uid, node]));
|
|
1504
1568
|
const entries = /* @__PURE__ */ new Map();
|
|
1505
|
-
for (const edge2 of
|
|
1569
|
+
for (const edge2 of await lockableImplementationEdges(root, index, config)) {
|
|
1506
1570
|
const source = nodeByUid.get(edge2.from);
|
|
1507
1571
|
const artifact = nodeByUid.get(edge2.to);
|
|
1508
1572
|
if (!source || !artifact) {
|
|
@@ -1537,10 +1601,11 @@ async function refreshVersionLock(root, options = {}) {
|
|
|
1537
1601
|
throw new Error("Changed-only version-lock refresh includes artifact-graph.config.yaml and requires --all");
|
|
1538
1602
|
}
|
|
1539
1603
|
const index = await buildVersionIndex(root);
|
|
1604
|
+
const config = await loadConfig(root);
|
|
1540
1605
|
const lock = await readVersionLock(root, lockPath);
|
|
1541
1606
|
const nodeByUid = new Map(index.nodes.map((node) => [node.uid, node]));
|
|
1542
1607
|
const nodeByPath = new Map(index.nodes.map((node) => [node.path, node]));
|
|
1543
|
-
const currentImplementationEdges =
|
|
1608
|
+
const currentImplementationEdges = await lockableImplementationEdges(root, index, config);
|
|
1544
1609
|
const currentEdgePairs = new Set(currentImplementationEdges.map((edge2) => `${edge2.from} ${edge2.to}`));
|
|
1545
1610
|
const currentEntries = /* @__PURE__ */ new Map();
|
|
1546
1611
|
const changedPathSet = new Set(changedPaths);
|
|
@@ -1615,7 +1680,7 @@ async function refreshVersionLock(root, options = {}) {
|
|
|
1615
1680
|
locks: sortBy([...nextLocks.values()], (item) => item.edgeId)
|
|
1616
1681
|
};
|
|
1617
1682
|
await writeVersionLock(root, lockPath, next);
|
|
1618
|
-
const postAudit = await auditVersionLock(root, lockPath);
|
|
1683
|
+
const postAudit = await auditVersionLock(root, lockPath, void 0, config);
|
|
1619
1684
|
return {
|
|
1620
1685
|
schemaVersion: "1.0",
|
|
1621
1686
|
root,
|
|
@@ -1634,7 +1699,8 @@ async function refreshVersionLock(root, options = {}) {
|
|
|
1634
1699
|
async function traceVersion(root, target, lockPath = VERSION_LOCK_PATH) {
|
|
1635
1700
|
const index = await buildVersionIndex(root);
|
|
1636
1701
|
const safeLockPath = normalizeRelativePath(root, lockPath);
|
|
1637
|
-
const
|
|
1702
|
+
const config = await loadConfig(root);
|
|
1703
|
+
const audit = await auditVersionLock(root, safeLockPath, void 0, config);
|
|
1638
1704
|
const targetUid = parseTarget(target);
|
|
1639
1705
|
const lock = await readVersionLock(root, safeLockPath);
|
|
1640
1706
|
const targetNode = index.nodes.find((node) => node.uid === targetUid);
|
|
@@ -1870,6 +1936,28 @@ function implementationEdges(index) {
|
|
|
1870
1936
|
};
|
|
1871
1937
|
});
|
|
1872
1938
|
}
|
|
1939
|
+
async function lockableImplementationEdges(root, index, config) {
|
|
1940
|
+
const edges = implementationEdges(index);
|
|
1941
|
+
const nodesByUid = new Map(index.nodes.map((node) => [node.uid, node]));
|
|
1942
|
+
const livenessByPath = /* @__PURE__ */ new Map();
|
|
1943
|
+
const result = [];
|
|
1944
|
+
for (const edge2 of edges) {
|
|
1945
|
+
const source = nodesByUid.get(edge2.from);
|
|
1946
|
+
if (source?.sourceKind !== "test") {
|
|
1947
|
+
result.push(edge2);
|
|
1948
|
+
continue;
|
|
1949
|
+
}
|
|
1950
|
+
let liveness = livenessByPath.get(source.path);
|
|
1951
|
+
if (liveness === void 0) {
|
|
1952
|
+
liveness = await getTestFileRunnerLiveness(root, source.path, config);
|
|
1953
|
+
livenessByPath.set(source.path, liveness);
|
|
1954
|
+
}
|
|
1955
|
+
if (liveness !== "inactive") {
|
|
1956
|
+
result.push(edge2);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
return result;
|
|
1960
|
+
}
|
|
1873
1961
|
function lockRefFromNode(node) {
|
|
1874
1962
|
return {
|
|
1875
1963
|
type: node.type,
|
|
@@ -1982,6 +2070,53 @@ function normalizeRelativePath(root, path) {
|
|
|
1982
2070
|
function sortBy(items, keyFn) {
|
|
1983
2071
|
return [...items].sort((left, right) => keyFn(left).localeCompare(keyFn(right)));
|
|
1984
2072
|
}
|
|
2073
|
+
async function getTestFileRunnerLiveness(root, filePath, config) {
|
|
2074
|
+
const schema = config ?? await loadConfig(root);
|
|
2075
|
+
const runners = schema.e2e?.runners ?? [];
|
|
2076
|
+
if (runners.length === 0) {
|
|
2077
|
+
if (!/e2e/i.test(filePath) && !/\.e2e\./i.test(filePath)) {
|
|
2078
|
+
return "active";
|
|
2079
|
+
}
|
|
2080
|
+
const fullSourcePath = join2(root, filePath);
|
|
2081
|
+
if (!existsSync(fullSourcePath)) return "inactive";
|
|
2082
|
+
try {
|
|
2083
|
+
const content = await readFile(fullSourcePath, "utf-8");
|
|
2084
|
+
return /\/\/!?\s*@(?:e2e_test|tc)\s+/.test(content) ? "active" : "inactive";
|
|
2085
|
+
} catch {
|
|
2086
|
+
return "inactive";
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
let inRunnerScope = false;
|
|
2090
|
+
for (const runner of runners) {
|
|
2091
|
+
if (!isFileIncludedByRunner(filePath, runner)) continue;
|
|
2092
|
+
inRunnerScope = true;
|
|
2093
|
+
const isActive = await isFileActiveInRunner(root, filePath, runner);
|
|
2094
|
+
if (isActive) return "active";
|
|
2095
|
+
}
|
|
2096
|
+
return inRunnerScope ? "inactive" : "unscoped";
|
|
2097
|
+
}
|
|
2098
|
+
function isFileIncludedByRunner(filePath, runner) {
|
|
2099
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
2100
|
+
const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
|
|
2101
|
+
if (runnerRoot !== "." && !normalizedPath.startsWith(`${runnerRoot}/`)) return false;
|
|
2102
|
+
const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.slice(runnerRoot.length + 1);
|
|
2103
|
+
return runner.include.some((pattern) => matchesRunnerGlob(relativePath, pattern));
|
|
2104
|
+
}
|
|
2105
|
+
async function isFileActiveInRunner(root, filePath, runner) {
|
|
2106
|
+
if (!isFileIncludedByRunner(filePath, runner)) return false;
|
|
2107
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
2108
|
+
const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
|
|
2109
|
+
const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.slice(runnerRoot.length).replace(/^\//, "");
|
|
2110
|
+
const matchesExclude = (runner.exclude ?? []).some(
|
|
2111
|
+
(pattern) => matchesRunnerGlob(relativePath, pattern)
|
|
2112
|
+
);
|
|
2113
|
+
if (matchesExclude) return false;
|
|
2114
|
+
const matchesTestIgnore = (runner.testIgnore ?? []).some(
|
|
2115
|
+
(pattern) => matchesRunnerGlob(relativePath, pattern)
|
|
2116
|
+
);
|
|
2117
|
+
if (matchesTestIgnore) return false;
|
|
2118
|
+
return true;
|
|
2119
|
+
}
|
|
1985
2120
|
function sortUnique(items) {
|
|
1986
2121
|
return [...new Set(items)].sort((left, right) => left.localeCompare(right));
|
|
1987
2122
|
}
|
|
@@ -3045,7 +3180,12 @@ var DEFAULT_SCHEMA = {
|
|
|
3045
3180
|
allowedEdges: [],
|
|
3046
3181
|
forbiddenEdges: [{ from: "scenario", to: "entity", kind: "references" }],
|
|
3047
3182
|
statuses: ["planned", "active", "done", "deprecated"],
|
|
3048
|
-
idRanges: {}
|
|
3183
|
+
idRanges: {},
|
|
3184
|
+
e2e: {
|
|
3185
|
+
report_uncovered_scenarios: true,
|
|
3186
|
+
report_uncovered_features: true,
|
|
3187
|
+
runners: []
|
|
3188
|
+
}
|
|
3049
3189
|
};
|
|
3050
3190
|
async function loadConfig(root) {
|
|
3051
3191
|
const configPath = join5(root, "artifact-graph.config.yaml");
|
|
@@ -3064,7 +3204,21 @@ async function loadConfig(root) {
|
|
|
3064
3204
|
`Invalid context.universal_baseline: ${JSON.stringify(ub)}. Must be boolean (true or false).`
|
|
3065
3205
|
);
|
|
3066
3206
|
}
|
|
3067
|
-
|
|
3207
|
+
if (parsed.e2e !== void 0) {
|
|
3208
|
+
if (typeof parsed.e2e !== "object" || parsed.e2e === null || Array.isArray(parsed.e2e)) {
|
|
3209
|
+
throw new Error("Invalid e2e: must be an object.");
|
|
3210
|
+
}
|
|
3211
|
+
validateE2eConfig(parsed.e2e);
|
|
3212
|
+
}
|
|
3213
|
+
const mergedE2e = parsed.e2e === void 0 ? DEFAULT_SCHEMA.e2e : {
|
|
3214
|
+
...DEFAULT_SCHEMA.e2e,
|
|
3215
|
+
...parsed.e2e,
|
|
3216
|
+
runners: (parsed.e2e.runners ?? DEFAULT_SCHEMA.e2e?.runners ?? []).map((runner) => ({
|
|
3217
|
+
kind: "e2e",
|
|
3218
|
+
...runner
|
|
3219
|
+
}))
|
|
3220
|
+
};
|
|
3221
|
+
const merged = {
|
|
3068
3222
|
...DEFAULT_SCHEMA,
|
|
3069
3223
|
...parsed,
|
|
3070
3224
|
types: mergeArtifactTypes(DEFAULT_SCHEMA.types, parsed.types),
|
|
@@ -3073,8 +3227,101 @@ async function loadConfig(root) {
|
|
|
3073
3227
|
allowedEdges: parsed.allowedEdges ?? DEFAULT_SCHEMA.allowedEdges,
|
|
3074
3228
|
forbiddenEdges: parsed.forbiddenEdges ?? DEFAULT_SCHEMA.forbiddenEdges,
|
|
3075
3229
|
statuses: parsed.statuses ?? DEFAULT_SCHEMA.statuses,
|
|
3076
|
-
idRanges: mergeRecord(DEFAULT_SCHEMA.idRanges, parsed.idRanges)
|
|
3230
|
+
idRanges: mergeRecord(DEFAULT_SCHEMA.idRanges, parsed.idRanges),
|
|
3231
|
+
e2e: mergedE2e
|
|
3077
3232
|
};
|
|
3233
|
+
return merged;
|
|
3234
|
+
}
|
|
3235
|
+
function validateE2eConfig(e2e) {
|
|
3236
|
+
for (const field of ["report_uncovered_scenarios", "report_uncovered_features"]) {
|
|
3237
|
+
if (e2e[field] !== void 0 && typeof e2e[field] !== "boolean") {
|
|
3238
|
+
throw new Error(`Invalid e2e.${field}: must be boolean.`);
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
if (e2e.executable_ref_warning !== void 0) {
|
|
3242
|
+
if (typeof e2e.executable_ref_warning !== "number" || e2e.executable_ref_warning < 0 || e2e.executable_ref_warning > 1) {
|
|
3243
|
+
throw new Error(`Invalid e2e.executable_ref_warning: ${JSON.stringify(e2e.executable_ref_warning)}. Must be a number between 0 and 1.`);
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
if (e2e.executable_ref_error !== void 0) {
|
|
3247
|
+
if (typeof e2e.executable_ref_error !== "number" || e2e.executable_ref_error < 0 || e2e.executable_ref_error > 1) {
|
|
3248
|
+
throw new Error(`Invalid e2e.executable_ref_error: ${JSON.stringify(e2e.executable_ref_error)}. Must be a number between 0 and 1.`);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
const validateWaivers = (waivers, field) => {
|
|
3252
|
+
if (waivers === void 0) return;
|
|
3253
|
+
if (!Array.isArray(waivers)) {
|
|
3254
|
+
throw new Error(`Invalid ${field}: must be an array of {id, reason} objects.`);
|
|
3255
|
+
}
|
|
3256
|
+
for (const w of waivers) {
|
|
3257
|
+
if (typeof w !== "object" || w === null || !("id" in w) || !("reason" in w)) {
|
|
3258
|
+
throw new Error(`Invalid ${field} entry: ${JSON.stringify(w)}. Must be {id, reason} object.`);
|
|
3259
|
+
}
|
|
3260
|
+
if (typeof w.id !== "string" || !w.id.trim()) {
|
|
3261
|
+
throw new Error(`Invalid ${field} entry: id must be a non-empty string. Got: ${JSON.stringify(w.id)}`);
|
|
3262
|
+
}
|
|
3263
|
+
if (typeof w.reason !== "string" || !w.reason.trim()) {
|
|
3264
|
+
throw new Error(`Invalid ${field} entry: reason must be a non-empty string. Got: ${JSON.stringify(w.reason)}`);
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
};
|
|
3268
|
+
validateWaivers(e2e.scenario_waivers, "e2e.scenario_waivers");
|
|
3269
|
+
validateWaivers(e2e.feature_waivers, "e2e.feature_waivers");
|
|
3270
|
+
if (e2e.runners !== void 0) {
|
|
3271
|
+
if (!Array.isArray(e2e.runners)) {
|
|
3272
|
+
throw new Error(`Invalid e2e.runners: must be an array.`);
|
|
3273
|
+
}
|
|
3274
|
+
for (const runner of e2e.runners) {
|
|
3275
|
+
if (typeof runner !== "object" || runner === null) {
|
|
3276
|
+
throw new Error(`Invalid e2e.runners entry: must be an object.`);
|
|
3277
|
+
}
|
|
3278
|
+
if (typeof runner.name !== "string" || !runner.name.trim()) {
|
|
3279
|
+
throw new Error(`Invalid e2e.runners entry: name must be a non-empty string.`);
|
|
3280
|
+
}
|
|
3281
|
+
if (typeof runner.root !== "string" || !runner.root.trim()) {
|
|
3282
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].root: must be a non-empty string.`);
|
|
3283
|
+
}
|
|
3284
|
+
if (isAbsolute3(runner.root)) {
|
|
3285
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].root: "${runner.root}" must not be an absolute path.`);
|
|
3286
|
+
}
|
|
3287
|
+
if (runner.root.replace(/\\/g, "/").split("/").includes("..")) {
|
|
3288
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].root: "${runner.root}" must not contain ".." segments.`);
|
|
3289
|
+
}
|
|
3290
|
+
if (!Array.isArray(runner.include) || runner.include.length === 0) {
|
|
3291
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].include: must be a non-empty array of glob patterns.`);
|
|
3292
|
+
}
|
|
3293
|
+
for (const pattern of runner.include) {
|
|
3294
|
+
if (typeof pattern !== "string" || !pattern.trim()) {
|
|
3295
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].include: pattern must be a non-empty string.`);
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
if (runner.exclude !== void 0) {
|
|
3299
|
+
if (!Array.isArray(runner.exclude)) {
|
|
3300
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].exclude: must be an array.`);
|
|
3301
|
+
}
|
|
3302
|
+
for (const pattern of runner.exclude) {
|
|
3303
|
+
if (typeof pattern !== "string" || !pattern.trim()) {
|
|
3304
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].exclude: pattern must be a non-empty string.`);
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
if (runner.testIgnore !== void 0) {
|
|
3309
|
+
if (!Array.isArray(runner.testIgnore)) {
|
|
3310
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].testIgnore: must be an array.`);
|
|
3311
|
+
}
|
|
3312
|
+
for (const pattern of runner.testIgnore) {
|
|
3313
|
+
if (typeof pattern !== "string" || !pattern.trim()) {
|
|
3314
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].testIgnore: pattern must be a non-empty string.`);
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
if (runner.kind !== void 0) {
|
|
3319
|
+
if (!["unit", "integration", "e2e"].includes(runner.kind)) {
|
|
3320
|
+
throw new Error(`Invalid e2e.runners[${runner.name}].kind: "${runner.kind}". Must be unit, integration, or e2e.`);
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3078
3325
|
}
|
|
3079
3326
|
function buildGraph(nodes, edges, diagnostics = [], root) {
|
|
3080
3327
|
const graphNodes = nodes.map((node) => ({ ...node, uid: toUid(node.type, node.code) }));
|
|
@@ -5223,6 +5470,29 @@ function validateE2eTests(graph) {
|
|
|
5223
5470
|
issues.push(issue("E2E_AC_UNKNOWN", `${node.uid} references unknown AC ${reference.feature}(${reference.ac})`, node.path, node.line, { node: node.uid, severity: "warning" }));
|
|
5224
5471
|
}
|
|
5225
5472
|
}
|
|
5473
|
+
const tcStatus = String(fields["status"] ?? "").trim().toLowerCase();
|
|
5474
|
+
if (tcStatus && !VALID_TC_STATUSES.has(tcStatus)) {
|
|
5475
|
+
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" }));
|
|
5476
|
+
}
|
|
5477
|
+
if (tcStatus === "waived") {
|
|
5478
|
+
const reason = String(fields["waived_reason"] ?? "").trim();
|
|
5479
|
+
if (!reason) {
|
|
5480
|
+
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" }));
|
|
5481
|
+
}
|
|
5482
|
+
}
|
|
5483
|
+
const rawChainType = String(fields["chain_type"] ?? "").trim();
|
|
5484
|
+
const chainType = rawChainType.toLowerCase();
|
|
5485
|
+
if (rawChainType) {
|
|
5486
|
+
if (!VALID_CHAIN_TYPES.has(chainType) && !(chainType in DEPRECATED_CHAIN_TYPE_ALIASES)) {
|
|
5487
|
+
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" }));
|
|
5488
|
+
} else if (chainType in DEPRECATED_CHAIN_TYPE_ALIASES) {
|
|
5489
|
+
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" }));
|
|
5490
|
+
}
|
|
5491
|
+
}
|
|
5492
|
+
const rawAcCoverageRate = String(fields["ac_coverage_rate"] ?? "").trim();
|
|
5493
|
+
if (rawAcCoverageRate) {
|
|
5494
|
+
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" }));
|
|
5495
|
+
}
|
|
5226
5496
|
if (needsDesktopChainWarning(node)) {
|
|
5227
5497
|
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" }));
|
|
5228
5498
|
}
|
|
@@ -5281,10 +5551,10 @@ function validateE2eRegistry(graph) {
|
|
|
5281
5551
|
}
|
|
5282
5552
|
return issues;
|
|
5283
5553
|
}
|
|
5284
|
-
async function validateExecutableTraceability(root) {
|
|
5554
|
+
async function validateExecutableTraceability(root, config) {
|
|
5285
5555
|
const issues = [];
|
|
5556
|
+
const schema = config ?? await loadConfig(root);
|
|
5286
5557
|
const e2eDir = join5(root, "artifacts", "tests", "e2e");
|
|
5287
|
-
const specPatterns = ["heimdall/**/*.spec.ts", "heimdall/**/*.e2e.spec.ts"];
|
|
5288
5558
|
let e2eFiles;
|
|
5289
5559
|
try {
|
|
5290
5560
|
e2eFiles = (await readdir(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).map((name) => join5(e2eDir, name));
|
|
@@ -5327,9 +5597,16 @@ async function validateExecutableTraceability(root) {
|
|
|
5327
5597
|
}
|
|
5328
5598
|
const allFiles = await walk(root);
|
|
5329
5599
|
const specFiles = /* @__PURE__ */ new Set();
|
|
5330
|
-
|
|
5600
|
+
const configuredRunners = schema.e2e?.runners ?? [];
|
|
5601
|
+
if (configuredRunners.length > 0) {
|
|
5331
5602
|
for (const file of allFiles) {
|
|
5332
|
-
if (
|
|
5603
|
+
if (configuredRunners.some((runner) => isRunnerIncludeCandidate(file, runner))) {
|
|
5604
|
+
specFiles.add(file);
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
} else {
|
|
5608
|
+
for (const file of allFiles) {
|
|
5609
|
+
if (/\.(?:e2e\.)?spec\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(file)) {
|
|
5333
5610
|
specFiles.add(file);
|
|
5334
5611
|
}
|
|
5335
5612
|
}
|
|
@@ -5400,12 +5677,21 @@ async function validateExecutableTraceability(root) {
|
|
|
5400
5677
|
const refEntries = parseExecutableRefLines(ref);
|
|
5401
5678
|
const validFiles = [];
|
|
5402
5679
|
for (const entry of refEntries) {
|
|
5403
|
-
const normalizedRefFile =
|
|
5404
|
-
const fileExists = specFiles.has(normalizedRefFile);
|
|
5680
|
+
const normalizedRefFile = resolveExecutableRefFile(entry.file, allFiles);
|
|
5681
|
+
const fileExists = normalizedRefFile !== void 0 && specFiles.has(normalizedRefFile);
|
|
5405
5682
|
if (!fileExists) {
|
|
5406
5683
|
issues.push(issue("E2E-TRACE-001", `executable_ref target file not found: ${entry.file}`, path, line, { node: tcKey, severity: "warning" }));
|
|
5407
5684
|
continue;
|
|
5408
5685
|
}
|
|
5686
|
+
const runners = schema.e2e?.runners ?? [];
|
|
5687
|
+
if (runners.length > 0) {
|
|
5688
|
+
const acceptingRunners = await getAcceptingRunners(root, normalizedRefFile, runners);
|
|
5689
|
+
const hasE2eRunner = acceptingRunners.some((r) => r.kind === "e2e" || r.kind === "integration");
|
|
5690
|
+
const hasUnitRunner = acceptingRunners.some((r) => r.kind === "unit");
|
|
5691
|
+
if (hasUnitRunner && !hasE2eRunner && acceptingRunners.length > 0) {
|
|
5692
|
+
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" }));
|
|
5693
|
+
}
|
|
5694
|
+
}
|
|
5409
5695
|
if (entry.testId) {
|
|
5410
5696
|
let content;
|
|
5411
5697
|
try {
|
|
@@ -5420,10 +5706,7 @@ async function validateExecutableTraceability(root) {
|
|
|
5420
5706
|
}
|
|
5421
5707
|
}
|
|
5422
5708
|
const annotationsForTc = refToSource.get(tcKey);
|
|
5423
|
-
const hasAnnotationInFile = annotationsForTc?.some((ann) =>
|
|
5424
|
-
const normalizedAnnFile = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
|
|
5425
|
-
return normalizedAnnFile === normalizedRefFile;
|
|
5426
|
-
}) ?? false;
|
|
5709
|
+
const hasAnnotationInFile = annotationsForTc?.some((ann) => ann.file === normalizedRefFile) ?? false;
|
|
5427
5710
|
if (!hasAnnotationInFile) {
|
|
5428
5711
|
issues.push(issue("E2E-TRACE-003", `executable_ref target ${entry.file} has no E2E trace annotation ${tcKey}`, path, line, { node: tcKey, severity: "warning" }));
|
|
5429
5712
|
continue;
|
|
@@ -5461,24 +5744,37 @@ async function validateExecutableTraceability(root) {
|
|
|
5461
5744
|
if (matchesAnyRef) {
|
|
5462
5745
|
continue;
|
|
5463
5746
|
}
|
|
5464
|
-
const primaryRef = refEntries[0];
|
|
5465
|
-
const normalizedPrimary = primaryRef?.file.startsWith("heimdall/") ? primaryRef?.file : `heimdall/${primaryRef?.file ?? ""}`;
|
|
5466
5747
|
const detail = `file: MD refs=[${refEntries.map((e) => e.file).join(", ")}] vs source=${ann.file}`;
|
|
5467
5748
|
issues.push(issue("E2E-TRACE-003", `executable_ref \u2194 E2E trace annotation mismatch for ${tcKey}: ${detail}`, path, line, { node: tcKey, severity: "warning" }));
|
|
5468
5749
|
}
|
|
5469
5750
|
}
|
|
5470
|
-
for (const [tcKey, { chainType, path, line }] of
|
|
5471
|
-
|
|
5751
|
+
for (const [tcKey, { chainType, path, line }] of allMdTcInfo) {
|
|
5752
|
+
const tcFields = tcKeyToFields.get(tcKey);
|
|
5753
|
+
const explicitChainType = String(tcFields?.["chain_type"] ?? "").trim().toLowerCase();
|
|
5754
|
+
if (explicitChainType !== "desktop_chain") {
|
|
5472
5755
|
continue;
|
|
5473
5756
|
}
|
|
5757
|
+
if (!mdToRef.has(tcKey)) {
|
|
5758
|
+
issues.push(issue("E2E-DESKTOP-CHAIN-MISSING", `desktop_chain TC ${tcKey} has no executable_ref`, path, line, { node: tcKey, severity: "warning" }));
|
|
5759
|
+
}
|
|
5760
|
+
}
|
|
5761
|
+
for (const [tcKey, { chainType, path, line }] of mdToRef) {
|
|
5762
|
+
const normalizedDeclaredChainType = chainType.trim().toLowerCase();
|
|
5763
|
+
const hasLegalNonDesktopDeclaration = normalizedDeclaredChainType.length > 0 && VALID_CHAIN_TYPES.has(normalizedDeclaredChainType) && normalizedDeclaredChainType !== "desktop_chain";
|
|
5764
|
+
if (hasLegalNonDesktopDeclaration) continue;
|
|
5474
5765
|
const validFiles = new Set(tcValidRefFiles.get(tcKey) ?? []);
|
|
5475
5766
|
const sourceAnnotations = refToSource.get(tcKey);
|
|
5476
5767
|
if (!sourceAnnotations) {
|
|
5477
5768
|
continue;
|
|
5478
5769
|
}
|
|
5770
|
+
const tcFields = tcKeyToFields.get(tcKey);
|
|
5771
|
+
const explicitChainType = String(tcFields?.["chain_type"] ?? "").trim().toLowerCase();
|
|
5772
|
+
const hasExplicitDesktopChain = explicitChainType === "desktop_chain";
|
|
5773
|
+
if (hasExplicitDesktopChain) {
|
|
5774
|
+
continue;
|
|
5775
|
+
}
|
|
5479
5776
|
for (const ann of sourceAnnotations) {
|
|
5480
|
-
|
|
5481
|
-
if (!validFiles.has(normalizedAnnFile)) {
|
|
5777
|
+
if (!validFiles.has(ann.file)) {
|
|
5482
5778
|
continue;
|
|
5483
5779
|
}
|
|
5484
5780
|
if (ann.level === "mock_playwright") {
|
|
@@ -5504,10 +5800,7 @@ async function validateExecutableTraceability(root) {
|
|
|
5504
5800
|
continue;
|
|
5505
5801
|
}
|
|
5506
5802
|
const validFiles = new Set(tcValidRefFiles.get(tcKey) ?? []);
|
|
5507
|
-
const sourceAnnotations = refToSource.get(tcKey)?.filter((ann) =>
|
|
5508
|
-
const normalized = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
|
|
5509
|
-
return validFiles.has(normalized);
|
|
5510
|
-
});
|
|
5803
|
+
const sourceAnnotations = refToSource.get(tcKey)?.filter((ann) => validFiles.has(ann.file));
|
|
5511
5804
|
const hasDesktopChain = sourceAnnotations?.some((ann) => ann.level === "desktop_chain") ?? false;
|
|
5512
5805
|
const hasBridge = sourceAnnotations?.some((ann) => ann.level === "ui_sidecar_bridge") ?? false;
|
|
5513
5806
|
if (isComplete) {
|
|
@@ -5516,7 +5809,7 @@ async function validateExecutableTraceability(root) {
|
|
|
5516
5809
|
if (hasDesktopChain) {
|
|
5517
5810
|
hasValidEvidence = true;
|
|
5518
5811
|
} else if (hasBridge) {
|
|
5519
|
-
const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root);
|
|
5812
|
+
const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root, allFiles);
|
|
5520
5813
|
if (partialResult.hasValidPartialRust) {
|
|
5521
5814
|
hasValidEvidence = true;
|
|
5522
5815
|
} else {
|
|
@@ -5556,7 +5849,7 @@ async function validateExecutableTraceability(root) {
|
|
|
5556
5849
|
}
|
|
5557
5850
|
let partialDetail = "";
|
|
5558
5851
|
if (hasBridge) {
|
|
5559
|
-
const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root);
|
|
5852
|
+
const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root, allFiles);
|
|
5560
5853
|
if (partialResult.hasValidPartialRust) {
|
|
5561
5854
|
continue;
|
|
5562
5855
|
}
|
|
@@ -5573,6 +5866,287 @@ async function validateExecutableTraceability(root) {
|
|
|
5573
5866
|
}
|
|
5574
5867
|
return issues;
|
|
5575
5868
|
}
|
|
5869
|
+
async function computeE2eCoverageStats(graph, root, thresholds = {}) {
|
|
5870
|
+
const e2eNodes = graph.nodes.filter((n) => n.type === "e2e_test" && n.attrs?.fileLevelOnly !== true);
|
|
5871
|
+
const totalTestCases = e2eNodes.length;
|
|
5872
|
+
let withExecutableRef = 0;
|
|
5873
|
+
const statusBreakdown = {};
|
|
5874
|
+
const chainTypeBreakdown = {};
|
|
5875
|
+
const e2eDir = join5(root, "artifacts", "tests", "e2e");
|
|
5876
|
+
const tcFieldsMap = /* @__PURE__ */ new Map();
|
|
5877
|
+
let e2eFiles;
|
|
5878
|
+
try {
|
|
5879
|
+
e2eFiles = (await readdir(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).map((name) => join5(e2eDir, name));
|
|
5880
|
+
} catch {
|
|
5881
|
+
e2eFiles = [];
|
|
5882
|
+
}
|
|
5883
|
+
for (const filePath of e2eFiles) {
|
|
5884
|
+
const raw = await readFile2(filePath, "utf-8");
|
|
5885
|
+
const lines = raw.split(/\r?\n/);
|
|
5886
|
+
const tcStarts = [];
|
|
5887
|
+
lines.forEach((line, index) => {
|
|
5888
|
+
const match = /^#{2,3}\s+(TC-\d+[a-z]?)\s*[::]?\s*.*$/.exec(line);
|
|
5889
|
+
if (match) {
|
|
5890
|
+
tcStarts.push({ id: match[1], index });
|
|
5891
|
+
}
|
|
5892
|
+
});
|
|
5893
|
+
const parsed = matter(raw);
|
|
5894
|
+
const batch = String(parsed.data.test_batch ?? basename2(filePath, extname(filePath))).trim();
|
|
5895
|
+
for (let i = 0; i < tcStarts.length; i++) {
|
|
5896
|
+
const start = tcStarts[i];
|
|
5897
|
+
const end = tcStarts[i + 1]?.index ?? lines.length;
|
|
5898
|
+
const block = lines.slice(start.index, end);
|
|
5899
|
+
const fields = extractE2eTcFields(block);
|
|
5900
|
+
tcFieldsMap.set(`${batch}:${start.id}`, fields);
|
|
5901
|
+
}
|
|
5902
|
+
}
|
|
5903
|
+
for (const node of e2eNodes) {
|
|
5904
|
+
const tcKey = node.code;
|
|
5905
|
+
const fields = tcFieldsMap.get(tcKey) ?? asRecord(node.attrs?.tcFields);
|
|
5906
|
+
const execRef = String(fields["executable_ref"] ?? "").trim();
|
|
5907
|
+
if (execRef && !isPendingExecutableRef(execRef)) {
|
|
5908
|
+
withExecutableRef++;
|
|
5909
|
+
}
|
|
5910
|
+
const status = String(fields["status"] ?? "created").trim().toLowerCase();
|
|
5911
|
+
statusBreakdown[status] = (statusBreakdown[status] ?? 0) + 1;
|
|
5912
|
+
const chainType = String(fields["chain_type"] ?? "").trim().toLowerCase() || "unspecified";
|
|
5913
|
+
chainTypeBreakdown[chainType] = (chainTypeBreakdown[chainType] ?? 0) + 1;
|
|
5914
|
+
}
|
|
5915
|
+
const executableRefRate = totalTestCases > 0 ? `${withExecutableRef}/${totalTestCases} (${(withExecutableRef / totalTestCases * 100).toFixed(1)}%)` : "0/0";
|
|
5916
|
+
const scenarioNodes = graph.nodes.filter((n) => n.type === "scenario");
|
|
5917
|
+
const featureNodes = graph.nodes.filter((n) => n.type === "feature");
|
|
5918
|
+
const linkedScenarios = /* @__PURE__ */ new Set();
|
|
5919
|
+
const linkedFeatures = /* @__PURE__ */ new Set();
|
|
5920
|
+
const acCoveredScenarios = /* @__PURE__ */ new Set();
|
|
5921
|
+
const acCoveredFeatures = /* @__PURE__ */ new Set();
|
|
5922
|
+
const verifiedScenarios = /* @__PURE__ */ new Set();
|
|
5923
|
+
const verifiedFeatures = /* @__PURE__ */ new Set();
|
|
5924
|
+
for (const edge2 of graph.edges) {
|
|
5925
|
+
if (edge2.kind === "verifies" && edge2.from.startsWith("e2e_test:")) {
|
|
5926
|
+
if (edge2.to.startsWith("scenario:")) {
|
|
5927
|
+
linkedScenarios.add(edge2.to.replace("scenario:", ""));
|
|
5928
|
+
}
|
|
5929
|
+
if (edge2.to.startsWith("feature:")) {
|
|
5930
|
+
linkedFeatures.add(edge2.to.replace("feature:", ""));
|
|
5931
|
+
}
|
|
5932
|
+
}
|
|
5933
|
+
}
|
|
5934
|
+
for (const node of e2eNodes) {
|
|
5935
|
+
const fields = tcFieldsMap.get(node.code) ?? asRecord(node.attrs?.tcFields);
|
|
5936
|
+
const acCoverage = asRecord(fields["ac_coverage"] ?? node.attrs?.ac_coverage);
|
|
5937
|
+
for (const feature of Object.keys(acCoverage)) {
|
|
5938
|
+
acCoveredFeatures.add(feature);
|
|
5939
|
+
}
|
|
5940
|
+
const relatedScenarios = toArray(fields["related_scenarios"] ?? node.attrs?.related_scenarios).map(String);
|
|
5941
|
+
for (const scenario of relatedScenarios) {
|
|
5942
|
+
acCoveredScenarios.add(scenario);
|
|
5943
|
+
}
|
|
5944
|
+
}
|
|
5945
|
+
const runners = (await loadConfig(root)).e2e?.runners ?? [];
|
|
5946
|
+
const allProjectFiles = await walk(root);
|
|
5947
|
+
for (const node of e2eNodes) {
|
|
5948
|
+
const fields = tcFieldsMap.get(node.code) ?? asRecord(node.attrs?.tcFields);
|
|
5949
|
+
const status = String(fields["status"] ?? "").trim().toLowerCase();
|
|
5950
|
+
const execRef = String(fields["executable_ref"] ?? "").trim();
|
|
5951
|
+
if (status !== "verified") continue;
|
|
5952
|
+
if (!execRef || isPendingExecutableRef(execRef)) continue;
|
|
5953
|
+
if (runners.length === 0) continue;
|
|
5954
|
+
let hasActiveE2eRef = false;
|
|
5955
|
+
for (const entry of parseExecutableRefLines(execRef)) {
|
|
5956
|
+
const normalized = resolveExecutableRefFile(entry.file, allProjectFiles);
|
|
5957
|
+
if (!normalized || !existsSync2(join5(root, normalized))) continue;
|
|
5958
|
+
const accepting = await getAcceptingRunners(root, normalized, runners);
|
|
5959
|
+
if (accepting.some((runner) => runner.kind === "e2e")) {
|
|
5960
|
+
hasActiveE2eRef = true;
|
|
5961
|
+
break;
|
|
5962
|
+
}
|
|
5963
|
+
}
|
|
5964
|
+
if (!hasActiveE2eRef) continue;
|
|
5965
|
+
const relatedScenarios = toArray(fields["related_scenarios"] ?? node.attrs?.related_scenarios).map(String);
|
|
5966
|
+
for (const scenario of relatedScenarios) {
|
|
5967
|
+
verifiedScenarios.add(scenario);
|
|
5968
|
+
}
|
|
5969
|
+
const acCoverage = asRecord(fields["ac_coverage"] ?? node.attrs?.ac_coverage);
|
|
5970
|
+
for (const feature of Object.keys(acCoverage)) {
|
|
5971
|
+
verifiedFeatures.add(feature);
|
|
5972
|
+
}
|
|
5973
|
+
}
|
|
5974
|
+
const scenarioWaivers = new Set((thresholds.scenarioWaivers ?? []).map((w) => w.id));
|
|
5975
|
+
const featureWaivers = new Set((thresholds.featureWaivers ?? []).map((w) => w.id));
|
|
5976
|
+
const uncoveredScenarios = scenarioNodes.map((n) => n.code).filter((code) => !linkedScenarios.has(code) && !scenarioWaivers.has(code));
|
|
5977
|
+
const uncoveredFeatures = featureNodes.map((n) => n.code).filter((code) => !acCoveredFeatures.has(code) && !featureWaivers.has(code));
|
|
5978
|
+
const scenarioCoverage = {};
|
|
5979
|
+
for (const node of scenarioNodes) {
|
|
5980
|
+
scenarioCoverage[node.code] = {
|
|
5981
|
+
linked: linkedScenarios.has(node.code),
|
|
5982
|
+
acCovered: acCoveredScenarios.has(node.code),
|
|
5983
|
+
waived: scenarioWaivers.has(node.code),
|
|
5984
|
+
verified: !scenarioWaivers.has(node.code) && verifiedScenarios.has(node.code)
|
|
5985
|
+
};
|
|
5986
|
+
}
|
|
5987
|
+
const featureCoverage = {};
|
|
5988
|
+
for (const node of featureNodes) {
|
|
5989
|
+
featureCoverage[node.code] = {
|
|
5990
|
+
linked: linkedFeatures.has(node.code),
|
|
5991
|
+
acCovered: acCoveredFeatures.has(node.code),
|
|
5992
|
+
waived: featureWaivers.has(node.code),
|
|
5993
|
+
verified: !featureWaivers.has(node.code) && verifiedFeatures.has(node.code)
|
|
5994
|
+
};
|
|
5995
|
+
}
|
|
5996
|
+
const thresholdWarnings = [];
|
|
5997
|
+
const thresholdErrors = [];
|
|
5998
|
+
const warningRate = thresholds.executableRefWarning;
|
|
5999
|
+
const errorRate = thresholds.executableRefError;
|
|
6000
|
+
const actualRate = totalTestCases > 0 ? withExecutableRef / totalTestCases : 1;
|
|
6001
|
+
if (warningRate !== void 0 && actualRate < warningRate) {
|
|
6002
|
+
thresholdWarnings.push(`executable_ref coverage ${executableRefRate} < warning threshold ${(warningRate * 100).toFixed(0)}%`);
|
|
6003
|
+
}
|
|
6004
|
+
if (errorRate !== void 0 && actualRate < errorRate) {
|
|
6005
|
+
thresholdErrors.push(`executable_ref coverage ${executableRefRate} < error threshold ${(errorRate * 100).toFixed(0)}%`);
|
|
6006
|
+
}
|
|
6007
|
+
if (thresholds.reportUncoveredScenarios !== false && uncoveredScenarios.length > 0) {
|
|
6008
|
+
thresholdWarnings.push(`${uncoveredScenarios.length} scenario(s) have no E2E coverage: ${uncoveredScenarios.join(", ")}`);
|
|
6009
|
+
}
|
|
6010
|
+
if (thresholds.reportUncoveredFeatures !== false && uncoveredFeatures.length > 0) {
|
|
6011
|
+
thresholdWarnings.push(`${uncoveredFeatures.length} feature(s) have no E2E coverage: ${uncoveredFeatures.join(", ")}`);
|
|
6012
|
+
}
|
|
6013
|
+
const acCoverageRateByFeature = {};
|
|
6014
|
+
const featureAcMap = /* @__PURE__ */ new Map();
|
|
6015
|
+
for (const node of featureNodes) {
|
|
6016
|
+
const acs = parseAcceptanceCriteria(await readFile2(join5(root, node.path), "utf-8"));
|
|
6017
|
+
featureAcMap.set(node.code, new Set(acs));
|
|
6018
|
+
}
|
|
6019
|
+
const coveredAcByFeature = /* @__PURE__ */ new Map();
|
|
6020
|
+
for (const node of e2eNodes) {
|
|
6021
|
+
const fields = tcFieldsMap.get(node.code) ?? asRecord(node.attrs?.tcFields);
|
|
6022
|
+
const acCoverage = asRecord(fields["ac_coverage"] ?? node.attrs?.ac_coverage);
|
|
6023
|
+
for (const [feature, acs] of Object.entries(acCoverage)) {
|
|
6024
|
+
const existing = coveredAcByFeature.get(feature) ?? /* @__PURE__ */ new Set();
|
|
6025
|
+
for (const ac of toArray(acs)) {
|
|
6026
|
+
existing.add(String(ac));
|
|
6027
|
+
}
|
|
6028
|
+
coveredAcByFeature.set(feature, existing);
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
6031
|
+
for (const [feature, allAcs] of featureAcMap) {
|
|
6032
|
+
const denominator = allAcs.size;
|
|
6033
|
+
if (denominator === 0) continue;
|
|
6034
|
+
const coveredAcs = coveredAcByFeature.get(feature) ?? /* @__PURE__ */ new Set();
|
|
6035
|
+
const numerator = [...coveredAcs].filter((ac) => allAcs.has(ac)).length;
|
|
6036
|
+
acCoverageRateByFeature[feature] = {
|
|
6037
|
+
numerator,
|
|
6038
|
+
denominator,
|
|
6039
|
+
rate: denominator > 0 ? numerator / denominator : 0
|
|
6040
|
+
};
|
|
6041
|
+
}
|
|
6042
|
+
return {
|
|
6043
|
+
totalTestCases,
|
|
6044
|
+
withExecutableRef,
|
|
6045
|
+
executableRefRate,
|
|
6046
|
+
statusBreakdown,
|
|
6047
|
+
chainTypeBreakdown,
|
|
6048
|
+
uncoveredScenarios,
|
|
6049
|
+
uncoveredFeatures,
|
|
6050
|
+
thresholdWarnings,
|
|
6051
|
+
thresholdErrors,
|
|
6052
|
+
acCoverageRateByFeature,
|
|
6053
|
+
scenarioCoverage,
|
|
6054
|
+
featureCoverage
|
|
6055
|
+
};
|
|
6056
|
+
}
|
|
6057
|
+
async function generateE2eRegistry(root, opts) {
|
|
6058
|
+
const e2eDir = join5(root, "artifacts", "tests", "e2e");
|
|
6059
|
+
let files;
|
|
6060
|
+
try {
|
|
6061
|
+
files = (await readdir(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).sort();
|
|
6062
|
+
} catch {
|
|
6063
|
+
return {
|
|
6064
|
+
registry_version: "1.0",
|
|
6065
|
+
generated_at: opts?.deterministic ? "1970-01-01T00:00:00.000Z" : (/* @__PURE__ */ new Date()).toISOString(),
|
|
6066
|
+
total_batches: 0,
|
|
6067
|
+
total_test_cases: 0,
|
|
6068
|
+
batches: []
|
|
6069
|
+
};
|
|
6070
|
+
}
|
|
6071
|
+
const batches = [];
|
|
6072
|
+
let totalTestCases = 0;
|
|
6073
|
+
for (const file of files) {
|
|
6074
|
+
const filePath = join5(e2eDir, file);
|
|
6075
|
+
const raw = await readFile2(filePath, "utf-8");
|
|
6076
|
+
const parsed = matter(raw);
|
|
6077
|
+
const data = parsed.data;
|
|
6078
|
+
const batch = String(data.test_batch ?? basename2(file, extname(file))).trim();
|
|
6079
|
+
const relPath = `artifacts/tests/e2e/${file}`;
|
|
6080
|
+
const scope = String(data.scope ?? "").trim();
|
|
6081
|
+
const acCoverage = normalizeAcCoverageForRegistry(data.ac_coverage);
|
|
6082
|
+
const relatedScenarios = toArray(data.related_scenarios).map(String).filter(Boolean);
|
|
6083
|
+
const lines = raw.split(/\r?\n/);
|
|
6084
|
+
const tcStarts = [];
|
|
6085
|
+
lines.forEach((line, index) => {
|
|
6086
|
+
const match = /^#{2,3}\s+(TC-\d+[a-z]?)\s*[::]?\s*.*$/.exec(line);
|
|
6087
|
+
if (match) {
|
|
6088
|
+
tcStarts.push({ id: match[1], index });
|
|
6089
|
+
}
|
|
6090
|
+
});
|
|
6091
|
+
const statusSummary = {};
|
|
6092
|
+
const blockingReasons = {};
|
|
6093
|
+
const frontmatterFixesBlock = String(data.fixes_block ?? "").trim();
|
|
6094
|
+
if (frontmatterFixesBlock && /no\s+(test\s+file|e2e)/i.test(frontmatterFixesBlock)) {
|
|
6095
|
+
for (const tc of tcStarts) {
|
|
6096
|
+
blockingReasons[tc.id] = frontmatterFixesBlock;
|
|
6097
|
+
}
|
|
6098
|
+
}
|
|
6099
|
+
for (const start of tcStarts) {
|
|
6100
|
+
const end = tcStarts[tcStarts.indexOf(start) + 1]?.index ?? lines.length;
|
|
6101
|
+
const block = lines.slice(start.index, end);
|
|
6102
|
+
const fields = extractE2eTcFields(block);
|
|
6103
|
+
const status = String(fields["status"] ?? "created").trim().toLowerCase() || "created";
|
|
6104
|
+
statusSummary[status] = (statusSummary[status] ?? 0) + 1;
|
|
6105
|
+
if (status === "created" && !blockingReasons[start.id]) {
|
|
6106
|
+
const executableRef = String(fields["executable_ref"] ?? "").trim();
|
|
6107
|
+
const chainType = String(fields["chain_type"] ?? "").trim().toLowerCase();
|
|
6108
|
+
if (!executableRef && chainType === "desktop_chain") {
|
|
6109
|
+
blockingReasons[start.id] = "desktop_chain TC requires executable_ref";
|
|
6110
|
+
} else if (isPendingExecutableRef(executableRef)) {
|
|
6111
|
+
blockingReasons[start.id] = `pending: ${executableRef}`;
|
|
6112
|
+
}
|
|
6113
|
+
}
|
|
6114
|
+
}
|
|
6115
|
+
const testCaseCount = tcStarts.length;
|
|
6116
|
+
totalTestCases += testCaseCount;
|
|
6117
|
+
const batchStatus = Object.keys(blockingReasons).length > 0 ? "blocked" : void 0;
|
|
6118
|
+
batches.push({
|
|
6119
|
+
batch_id: batch,
|
|
6120
|
+
file: relPath,
|
|
6121
|
+
scope,
|
|
6122
|
+
ac_coverage: acCoverage,
|
|
6123
|
+
related_scenarios: relatedScenarios,
|
|
6124
|
+
test_case_count: testCaseCount,
|
|
6125
|
+
status_summary: statusSummary,
|
|
6126
|
+
status: batchStatus,
|
|
6127
|
+
blocking_reasons: Object.keys(blockingReasons).length > 0 ? blockingReasons : void 0
|
|
6128
|
+
});
|
|
6129
|
+
}
|
|
6130
|
+
return {
|
|
6131
|
+
registry_version: "1.0",
|
|
6132
|
+
generated_at: opts?.deterministic ? "1970-01-01T00:00:00.000Z" : (/* @__PURE__ */ new Date()).toISOString(),
|
|
6133
|
+
total_batches: batches.length,
|
|
6134
|
+
total_test_cases: totalTestCases,
|
|
6135
|
+
batches
|
|
6136
|
+
};
|
|
6137
|
+
}
|
|
6138
|
+
function normalizeAcCoverageForRegistry(value) {
|
|
6139
|
+
if (!value || typeof value !== "object") return {};
|
|
6140
|
+
const result = {};
|
|
6141
|
+
for (const [key, val] of Object.entries(value)) {
|
|
6142
|
+
if (Array.isArray(val)) {
|
|
6143
|
+
result[key] = val.map(String);
|
|
6144
|
+
} else if (typeof val === "string") {
|
|
6145
|
+
result[key] = val.split(",").map((s) => s.trim()).filter(Boolean);
|
|
6146
|
+
}
|
|
6147
|
+
}
|
|
6148
|
+
return result;
|
|
6149
|
+
}
|
|
5576
6150
|
function isPendingExecutableRef(ref) {
|
|
5577
6151
|
const stripped = ref.replace(/^[\s-*()]+/, "").trim();
|
|
5578
6152
|
return /^pending\b/i.test(stripped);
|
|
@@ -5590,6 +6164,20 @@ function parseExecutableRefLines(ref) {
|
|
|
5590
6164
|
}
|
|
5591
6165
|
return results;
|
|
5592
6166
|
}
|
|
6167
|
+
var VALID_TC_STATUSES = /* @__PURE__ */ new Set(["created", "automated", "verified", "waived"]);
|
|
6168
|
+
var VALID_CHAIN_TYPES = /* @__PURE__ */ new Set([
|
|
6169
|
+
"desktop_chain",
|
|
6170
|
+
"mock_playwright",
|
|
6171
|
+
"core_e2e",
|
|
6172
|
+
"cli_e2e",
|
|
6173
|
+
"ui_sidecar_bridge",
|
|
6174
|
+
"partial_sidecar",
|
|
6175
|
+
"partial_rust"
|
|
6176
|
+
]);
|
|
6177
|
+
var DEPRECATED_CHAIN_TYPE_ALIASES = {
|
|
6178
|
+
core_only: "core_e2e",
|
|
6179
|
+
frontend_only: "mock_playwright"
|
|
6180
|
+
};
|
|
5593
6181
|
function isDesktopChainType(chainType) {
|
|
5594
6182
|
const normalizedChainType = chainType.trim().toLowerCase();
|
|
5595
6183
|
return normalizedChainType === "desktop_chain" || normalizedChainType === "";
|
|
@@ -5597,7 +6185,7 @@ function isDesktopChainType(chainType) {
|
|
|
5597
6185
|
function parseChainCoverageStatus(chainCoverage) {
|
|
5598
6186
|
return chainCoverage.trim().toLowerCase().match(/^[a-z_]+/)?.[0] ?? "";
|
|
5599
6187
|
}
|
|
5600
|
-
async function validatePartialRustEvidence(tcFields, tcKey, root) {
|
|
6188
|
+
async function validatePartialRustEvidence(tcFields, tcKey, root, allFiles) {
|
|
5601
6189
|
const partialEvidence = String(tcFields["partial_evidence"] ?? "");
|
|
5602
6190
|
if (!partialEvidence.trim()) {
|
|
5603
6191
|
return { hasValidPartialRust: false, detail: "no partial_evidence field" };
|
|
@@ -5621,7 +6209,10 @@ async function validatePartialRustEvidence(tcFields, tcKey, root) {
|
|
|
5621
6209
|
return { hasValidPartialRust: false, detail: "no .rs file in partial_evidence" };
|
|
5622
6210
|
}
|
|
5623
6211
|
for (const ref of rustRefs) {
|
|
5624
|
-
const normalizedPath =
|
|
6212
|
+
const normalizedPath = resolveExecutableRefFile(ref.file, allFiles);
|
|
6213
|
+
if (!normalizedPath) {
|
|
6214
|
+
return { hasValidPartialRust: false, detail: `partial_rust file not found: ${ref.file}` };
|
|
6215
|
+
}
|
|
5625
6216
|
const fullPath = join5(root, normalizedPath);
|
|
5626
6217
|
let content;
|
|
5627
6218
|
try {
|
|
@@ -5657,6 +6248,46 @@ function detectTestLevel(specFile, content) {
|
|
|
5657
6248
|
}
|
|
5658
6249
|
return "desktop_chain";
|
|
5659
6250
|
}
|
|
6251
|
+
function resolveExecutableRefFile(refFile, allFiles) {
|
|
6252
|
+
const normalized = refFile.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
6253
|
+
if (!normalized || isAbsolute3(refFile) || normalized.split("/").includes("..")) {
|
|
6254
|
+
return void 0;
|
|
6255
|
+
}
|
|
6256
|
+
if (allFiles.includes(normalized)) {
|
|
6257
|
+
return normalized;
|
|
6258
|
+
}
|
|
6259
|
+
const suffix = `/${normalized}`;
|
|
6260
|
+
const matches = allFiles.filter((file) => file.endsWith(suffix));
|
|
6261
|
+
return matches.length === 1 ? matches[0] : void 0;
|
|
6262
|
+
}
|
|
6263
|
+
function isRunnerIncludeCandidate(filePath, runner) {
|
|
6264
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
6265
|
+
const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
|
|
6266
|
+
if (runnerRoot !== "." && !normalizedPath.startsWith(`${runnerRoot}/`)) {
|
|
6267
|
+
return false;
|
|
6268
|
+
}
|
|
6269
|
+
const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.slice(runnerRoot.length + 1);
|
|
6270
|
+
return runner.include.some((pattern) => matchesRunnerGlob(relativePath, pattern));
|
|
6271
|
+
}
|
|
6272
|
+
async function getAcceptingRunners(root, filePath, runners) {
|
|
6273
|
+
const accepting = [];
|
|
6274
|
+
for (const runner of runners) {
|
|
6275
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
6276
|
+
const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
|
|
6277
|
+
const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.startsWith(runnerRoot + "/") ? normalizedPath.slice(runnerRoot.length + 1) : normalizedPath;
|
|
6278
|
+
if (runnerRoot !== "." && !normalizedPath.startsWith(`${runnerRoot}/`)) {
|
|
6279
|
+
continue;
|
|
6280
|
+
}
|
|
6281
|
+
const matchesInclude = runner.include.some((p) => matchesRunnerGlob(relativePath, p));
|
|
6282
|
+
if (!matchesInclude) continue;
|
|
6283
|
+
const matchesExclude = (runner.exclude ?? []).some((p) => matchesRunnerGlob(relativePath, p));
|
|
6284
|
+
if (matchesExclude) continue;
|
|
6285
|
+
const matchesTestIgnore = (runner.testIgnore ?? []).some((p) => matchesRunnerGlob(relativePath, p));
|
|
6286
|
+
if (matchesTestIgnore) continue;
|
|
6287
|
+
accepting.push(runner);
|
|
6288
|
+
}
|
|
6289
|
+
return accepting;
|
|
6290
|
+
}
|
|
5660
6291
|
function splitMarkdownCells(line) {
|
|
5661
6292
|
const cells = [];
|
|
5662
6293
|
let current = "";
|
|
@@ -5810,7 +6441,7 @@ function flattenAcCoverage(value) {
|
|
|
5810
6441
|
function needsDesktopChainWarning(node) {
|
|
5811
6442
|
const tcFields = asRecord(node.attrs?.tcFields);
|
|
5812
6443
|
const chainType = String(tcFields["chain_type"] ?? "").trim().toLowerCase();
|
|
5813
|
-
if (chainType
|
|
6444
|
+
if (chainType && VALID_CHAIN_TYPES.has(chainType) && chainType !== "desktop_chain" || chainType in DEPRECATED_CHAIN_TYPE_ALIASES) {
|
|
5814
6445
|
return false;
|
|
5815
6446
|
}
|
|
5816
6447
|
const chainCoverage = String(tcFields["chain_coverage"] ?? "").trim().toLowerCase();
|
|
@@ -6481,12 +7112,14 @@ export {
|
|
|
6481
7112
|
buildGraph,
|
|
6482
7113
|
buildVersionIndex,
|
|
6483
7114
|
collectChangedPaths,
|
|
7115
|
+
computeE2eCoverageStats,
|
|
6484
7116
|
dirname3 as dirname,
|
|
6485
7117
|
discoverAndAuditPackets,
|
|
6486
7118
|
discoverTargets,
|
|
6487
7119
|
doctorArtifactChain,
|
|
6488
7120
|
extname,
|
|
6489
7121
|
formatContextMarkdown,
|
|
7122
|
+
generateE2eRegistry,
|
|
6490
7123
|
getArtifactTypeMetadata,
|
|
6491
7124
|
getTargetArtifactTypes,
|
|
6492
7125
|
installManagedHookBlock,
|