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/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 currentEdges) {
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 implementationEdges(index)) {
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 = implementationEdges(index);
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 audit = await auditVersionLock(root, safeLockPath);
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
- return {
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
- for (const pattern of specPatterns) {
5580
+ const configuredRunners = schema.e2e?.runners ?? [];
5581
+ if (configuredRunners.length > 0) {
5311
5582
  for (const file of allFiles) {
5312
- if (matchesPattern(file, pattern)) {
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 = entry.file.startsWith("heimdall/") ? entry.file : `heimdall/${entry.file}`;
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 mdToRef) {
5451
- if (!isDesktopChainType(chainType)) {
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
- const normalizedAnnFile = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
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 = ref.file.startsWith("heimdall/") ? ref.file : `heimdall/${ref.file}`;
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 === "frontend_only" || chainType === "core_only") {
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
- out(`${JSON.stringify(issues, null, 2)}
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
- } else if (issues.length === 0) {
6671
- out("No validation issues\n");
7351
+ }
6672
7352
  } else {
6673
- out(issues.map((issue2) => `${issue2.code} ${issue2.path}:${issue2.line} ${issue2.message}`).join("\n") + "\n");
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 result = await auditVersionLock(root, lockPath);
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) {