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/dist/index.cjs CHANGED
@@ -50,12 +50,14 @@ __export(index_exports, {
50
50
  buildGraph: () => buildGraph,
51
51
  buildVersionIndex: () => buildVersionIndex,
52
52
  collectChangedPaths: () => collectChangedPaths,
53
+ computeE2eCoverageStats: () => computeE2eCoverageStats,
53
54
  dirname: () => import_node_path6.dirname,
54
55
  discoverAndAuditPackets: () => discoverAndAuditPackets,
55
56
  discoverTargets: () => discoverTargets,
56
57
  doctorArtifactChain: () => doctorArtifactChain,
57
58
  extname: () => import_node_path6.extname,
58
59
  formatContextMarkdown: () => formatContextMarkdown,
60
+ generateE2eRegistry: () => generateE2eRegistry,
59
61
  getArtifactTypeMetadata: () => getArtifactTypeMetadata,
60
62
  getTargetArtifactTypes: () => getTargetArtifactTypes,
61
63
  installManagedHookBlock: () => installManagedHookBlock,
@@ -99,7 +101,7 @@ module.exports = __toCommonJS(index_exports);
99
101
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
100
102
  var import_gray_matter = __toESM(require("gray-matter"), 1);
101
103
  var import_js_yaml = __toESM(require("js-yaml"), 1);
102
- var import_node_fs3 = require("fs");
104
+ var import_node_fs4 = require("fs");
103
105
  var import_promises5 = require("fs/promises");
104
106
  var import_node_path6 = require("path");
105
107
 
@@ -356,6 +358,45 @@ function validatePacketMarkdown(markdown) {
356
358
  return { ok: !hasError, issues };
357
359
  }
358
360
 
361
+ // src/glob-matcher.ts
362
+ function matchesRunnerGlob(filePath, pattern) {
363
+ const normalizedPath = normalizeGlobValue(filePath);
364
+ const normalizedPattern = normalizeGlobValue(pattern);
365
+ let expression = "^";
366
+ for (let index = 0; index < normalizedPattern.length; index += 1) {
367
+ const character = normalizedPattern[index];
368
+ if (character === "*") {
369
+ if (normalizedPattern[index + 1] === "*") {
370
+ while (normalizedPattern[index + 1] === "*") {
371
+ index += 1;
372
+ }
373
+ if (normalizedPattern[index + 1] === "/") {
374
+ index += 1;
375
+ expression += "(?:[^/]+/)*";
376
+ } else {
377
+ expression += ".*";
378
+ }
379
+ } else {
380
+ expression += "[^/]*";
381
+ }
382
+ continue;
383
+ }
384
+ if (character === "?") {
385
+ expression += "[^/]";
386
+ continue;
387
+ }
388
+ expression += escapeRegexCharacter(character);
389
+ }
390
+ expression += "$";
391
+ return new RegExp(expression).test(normalizedPath);
392
+ }
393
+ function normalizeGlobValue(value) {
394
+ return value.replace(/\\/g, "/").replace(/^\.\//, "");
395
+ }
396
+ function escapeRegexCharacter(character) {
397
+ return "\\^$+?.()|{}[]".includes(character) ? String.fromCharCode(92) + character : character;
398
+ }
399
+
359
400
  // src/target-selector.ts
360
401
  function parseTargetSelector(value) {
361
402
  const separator = value.indexOf(":");
@@ -1373,6 +1414,7 @@ function validatePacketPrompt(prompt) {
1373
1414
 
1374
1415
  // src/versioned-traceability.ts
1375
1416
  var import_node_crypto = require("crypto");
1417
+ var import_node_fs = require("fs");
1376
1418
  var import_promises2 = require("fs/promises");
1377
1419
  var import_node_path2 = require("path");
1378
1420
  var VERSION_LOCK_PATH = "artifacts/traceability-version-lock.json";
@@ -1413,13 +1455,15 @@ async function buildVersionIndex(root, graph) {
1413
1455
  edges: sortBy(edges, (edge2) => `${edge2.from} ${edge2.to} ${edge2.kind} ${edge2.sourcePath} ${edge2.sourceLine}`)
1414
1456
  };
1415
1457
  }
1416
- async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph) {
1458
+ async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph, config) {
1417
1459
  const index = await buildVersionIndex(root, graph);
1460
+ const schema = config ?? await loadConfig(root);
1418
1461
  const safeLockPath = normalizeRelativePath(root, lockPath);
1419
1462
  const lock = await readVersionLock(root, safeLockPath);
1420
1463
  const nodeByArtifact = new Map(index.nodes.map((node) => [`${node.type}:${node.id}`, node]));
1421
1464
  const nodeByPath = new Map(index.nodes.map((node) => [node.path, node]));
1422
1465
  const currentEdges = implementationEdges(index);
1466
+ const lockableEdges = await lockableImplementationEdges(root, index, schema);
1423
1467
  const currentEdgeIds = new Set(currentEdges.map((edge2) => edge2.edgeId));
1424
1468
  const issues = [];
1425
1469
  let fresh = 0;
@@ -1507,8 +1551,29 @@ async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph) {
1507
1551
  issues.push(...entryIssues);
1508
1552
  }
1509
1553
  }
1554
+ const livenessCache = /* @__PURE__ */ new Map();
1555
+ for (const entry of lock.locks) {
1556
+ if (entry.kind !== "verifies") continue;
1557
+ const sourcePath = entry.source.path;
1558
+ const fullSourcePath = (0, import_node_path2.join)(root, sourcePath);
1559
+ if (!(0, import_node_fs.existsSync)(fullSourcePath)) continue;
1560
+ let liveness = livenessCache.get(sourcePath);
1561
+ if (liveness === void 0) {
1562
+ liveness = await getTestFileRunnerLiveness(root, sourcePath, schema);
1563
+ livenessCache.set(sourcePath, liveness);
1564
+ }
1565
+ if (liveness === "inactive") {
1566
+ issues.push({
1567
+ status: "orphan_lock",
1568
+ edgeId: entry.edgeId,
1569
+ message: `Liveness: ${sourcePath} is not active in any configured runner \u2014 locked verifies edges may reference dead tests`,
1570
+ artifact: entry.artifact,
1571
+ source: entry.source
1572
+ });
1573
+ }
1574
+ }
1510
1575
  const reportedMissingLocks = /* @__PURE__ */ new Set();
1511
- for (const edge2 of currentEdges) {
1576
+ for (const edge2 of lockableEdges) {
1512
1577
  const edgeId = edge2.edgeId;
1513
1578
  if (!lock.locks.some((entry) => entry.edgeId === edgeId)) {
1514
1579
  if (reportedMissingLocks.has(edgeId)) {
@@ -1590,6 +1655,7 @@ async function updateVersionLock(root, options) {
1590
1655
  }
1591
1656
  async function bootstrapVersionLock(root, options = {}) {
1592
1657
  const index = await buildVersionIndex(root);
1658
+ const config = await loadConfig(root);
1593
1659
  const lockPath = normalizeRelativePath(root, options.lockPath ?? VERSION_LOCK_PATH);
1594
1660
  if (!options.force) {
1595
1661
  const existing = await readVersionLock(root, lockPath);
@@ -1599,7 +1665,7 @@ async function bootstrapVersionLock(root, options = {}) {
1599
1665
  }
1600
1666
  const nodeByUid = new Map(index.nodes.map((node) => [node.uid, node]));
1601
1667
  const entries = /* @__PURE__ */ new Map();
1602
- for (const edge2 of implementationEdges(index)) {
1668
+ for (const edge2 of await lockableImplementationEdges(root, index, config)) {
1603
1669
  const source = nodeByUid.get(edge2.from);
1604
1670
  const artifact = nodeByUid.get(edge2.to);
1605
1671
  if (!source || !artifact) {
@@ -1634,10 +1700,11 @@ async function refreshVersionLock(root, options = {}) {
1634
1700
  throw new Error("Changed-only version-lock refresh includes artifact-graph.config.yaml and requires --all");
1635
1701
  }
1636
1702
  const index = await buildVersionIndex(root);
1703
+ const config = await loadConfig(root);
1637
1704
  const lock = await readVersionLock(root, lockPath);
1638
1705
  const nodeByUid = new Map(index.nodes.map((node) => [node.uid, node]));
1639
1706
  const nodeByPath = new Map(index.nodes.map((node) => [node.path, node]));
1640
- const currentImplementationEdges = implementationEdges(index);
1707
+ const currentImplementationEdges = await lockableImplementationEdges(root, index, config);
1641
1708
  const currentEdgePairs = new Set(currentImplementationEdges.map((edge2) => `${edge2.from} ${edge2.to}`));
1642
1709
  const currentEntries = /* @__PURE__ */ new Map();
1643
1710
  const changedPathSet = new Set(changedPaths);
@@ -1712,7 +1779,7 @@ async function refreshVersionLock(root, options = {}) {
1712
1779
  locks: sortBy([...nextLocks.values()], (item) => item.edgeId)
1713
1780
  };
1714
1781
  await writeVersionLock(root, lockPath, next);
1715
- const postAudit = await auditVersionLock(root, lockPath);
1782
+ const postAudit = await auditVersionLock(root, lockPath, void 0, config);
1716
1783
  return {
1717
1784
  schemaVersion: "1.0",
1718
1785
  root,
@@ -1731,7 +1798,8 @@ async function refreshVersionLock(root, options = {}) {
1731
1798
  async function traceVersion(root, target, lockPath = VERSION_LOCK_PATH) {
1732
1799
  const index = await buildVersionIndex(root);
1733
1800
  const safeLockPath = normalizeRelativePath(root, lockPath);
1734
- const audit = await auditVersionLock(root, safeLockPath);
1801
+ const config = await loadConfig(root);
1802
+ const audit = await auditVersionLock(root, safeLockPath, void 0, config);
1735
1803
  const targetUid = parseTarget(target);
1736
1804
  const lock = await readVersionLock(root, safeLockPath);
1737
1805
  const targetNode = index.nodes.find((node) => node.uid === targetUid);
@@ -1967,6 +2035,28 @@ function implementationEdges(index) {
1967
2035
  };
1968
2036
  });
1969
2037
  }
2038
+ async function lockableImplementationEdges(root, index, config) {
2039
+ const edges = implementationEdges(index);
2040
+ const nodesByUid = new Map(index.nodes.map((node) => [node.uid, node]));
2041
+ const livenessByPath = /* @__PURE__ */ new Map();
2042
+ const result = [];
2043
+ for (const edge2 of edges) {
2044
+ const source = nodesByUid.get(edge2.from);
2045
+ if (source?.sourceKind !== "test") {
2046
+ result.push(edge2);
2047
+ continue;
2048
+ }
2049
+ let liveness = livenessByPath.get(source.path);
2050
+ if (liveness === void 0) {
2051
+ liveness = await getTestFileRunnerLiveness(root, source.path, config);
2052
+ livenessByPath.set(source.path, liveness);
2053
+ }
2054
+ if (liveness !== "inactive") {
2055
+ result.push(edge2);
2056
+ }
2057
+ }
2058
+ return result;
2059
+ }
1970
2060
  function lockRefFromNode(node) {
1971
2061
  return {
1972
2062
  type: node.type,
@@ -2079,13 +2169,60 @@ function normalizeRelativePath(root, path) {
2079
2169
  function sortBy(items, keyFn) {
2080
2170
  return [...items].sort((left, right) => keyFn(left).localeCompare(keyFn(right)));
2081
2171
  }
2172
+ async function getTestFileRunnerLiveness(root, filePath, config) {
2173
+ const schema = config ?? await loadConfig(root);
2174
+ const runners = schema.e2e?.runners ?? [];
2175
+ if (runners.length === 0) {
2176
+ if (!/e2e/i.test(filePath) && !/\.e2e\./i.test(filePath)) {
2177
+ return "active";
2178
+ }
2179
+ const fullSourcePath = (0, import_node_path2.join)(root, filePath);
2180
+ if (!(0, import_node_fs.existsSync)(fullSourcePath)) return "inactive";
2181
+ try {
2182
+ const content = await (0, import_promises2.readFile)(fullSourcePath, "utf-8");
2183
+ return /\/\/!?\s*@(?:e2e_test|tc)\s+/.test(content) ? "active" : "inactive";
2184
+ } catch {
2185
+ return "inactive";
2186
+ }
2187
+ }
2188
+ let inRunnerScope = false;
2189
+ for (const runner of runners) {
2190
+ if (!isFileIncludedByRunner(filePath, runner)) continue;
2191
+ inRunnerScope = true;
2192
+ const isActive = await isFileActiveInRunner(root, filePath, runner);
2193
+ if (isActive) return "active";
2194
+ }
2195
+ return inRunnerScope ? "inactive" : "unscoped";
2196
+ }
2197
+ function isFileIncludedByRunner(filePath, runner) {
2198
+ const normalizedPath = filePath.replace(/\\/g, "/");
2199
+ const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
2200
+ if (runnerRoot !== "." && !normalizedPath.startsWith(`${runnerRoot}/`)) return false;
2201
+ const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.slice(runnerRoot.length + 1);
2202
+ return runner.include.some((pattern) => matchesRunnerGlob(relativePath, pattern));
2203
+ }
2204
+ async function isFileActiveInRunner(root, filePath, runner) {
2205
+ if (!isFileIncludedByRunner(filePath, runner)) return false;
2206
+ const normalizedPath = filePath.replace(/\\/g, "/");
2207
+ const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
2208
+ const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.slice(runnerRoot.length).replace(/^\//, "");
2209
+ const matchesExclude = (runner.exclude ?? []).some(
2210
+ (pattern) => matchesRunnerGlob(relativePath, pattern)
2211
+ );
2212
+ if (matchesExclude) return false;
2213
+ const matchesTestIgnore = (runner.testIgnore ?? []).some(
2214
+ (pattern) => matchesRunnerGlob(relativePath, pattern)
2215
+ );
2216
+ if (matchesTestIgnore) return false;
2217
+ return true;
2218
+ }
2082
2219
  function sortUnique(items) {
2083
2220
  return [...new Set(items)].sort((left, right) => left.localeCompare(right));
2084
2221
  }
2085
2222
 
2086
2223
  // src/cli-resolver.ts
2087
2224
  var import_node_child_process = require("child_process");
2088
- var import_node_fs = require("fs");
2225
+ var import_node_fs2 = require("fs");
2089
2226
  var import_promises3 = require("fs/promises");
2090
2227
  var import_node_path3 = require("path");
2091
2228
  var import_node_util = require("util");
@@ -2223,7 +2360,7 @@ ${maybe.stderr ?? ""}`;
2223
2360
  }
2224
2361
  async function pathExists(path) {
2225
2362
  try {
2226
- await (0, import_promises3.access)(path, import_node_fs.constants.R_OK);
2363
+ await (0, import_promises3.access)(path, import_node_fs2.constants.R_OK);
2227
2364
  return true;
2228
2365
  } catch {
2229
2366
  return false;
@@ -2322,7 +2459,7 @@ async function resolveGitHookPath(root, hookName) {
2322
2459
  }
2323
2460
 
2324
2461
  // src/hook-installer.ts
2325
- var import_node_fs2 = require("fs");
2462
+ var import_node_fs3 = require("fs");
2326
2463
  var import_node_crypto2 = require("crypto");
2327
2464
  var import_promises4 = require("fs/promises");
2328
2465
  var import_node_path5 = require("path");
@@ -2636,7 +2773,7 @@ async function readHookSnapshot(hookPath) {
2636
2773
  }
2637
2774
  let handle;
2638
2775
  try {
2639
- handle = await (0, import_promises4.open)(hookPath, import_node_fs2.constants.O_RDONLY | (import_node_fs2.constants.O_NOFOLLOW ?? 0));
2776
+ handle = await (0, import_promises4.open)(hookPath, import_node_fs3.constants.O_RDONLY | (import_node_fs3.constants.O_NOFOLLOW ?? 0));
2640
2777
  const opened = await handle.stat({ bigint: true });
2641
2778
  if (opened.dev !== metadata.dev || opened.ino !== metadata.ino) {
2642
2779
  await handle.close();
@@ -3142,7 +3279,12 @@ var DEFAULT_SCHEMA = {
3142
3279
  allowedEdges: [],
3143
3280
  forbiddenEdges: [{ from: "scenario", to: "entity", kind: "references" }],
3144
3281
  statuses: ["planned", "active", "done", "deprecated"],
3145
- idRanges: {}
3282
+ idRanges: {},
3283
+ e2e: {
3284
+ report_uncovered_scenarios: true,
3285
+ report_uncovered_features: true,
3286
+ runners: []
3287
+ }
3146
3288
  };
3147
3289
  async function loadConfig(root) {
3148
3290
  const configPath = (0, import_node_path6.join)(root, "artifact-graph.config.yaml");
@@ -3161,7 +3303,21 @@ async function loadConfig(root) {
3161
3303
  `Invalid context.universal_baseline: ${JSON.stringify(ub)}. Must be boolean (true or false).`
3162
3304
  );
3163
3305
  }
3164
- return {
3306
+ if (parsed.e2e !== void 0) {
3307
+ if (typeof parsed.e2e !== "object" || parsed.e2e === null || Array.isArray(parsed.e2e)) {
3308
+ throw new Error("Invalid e2e: must be an object.");
3309
+ }
3310
+ validateE2eConfig(parsed.e2e);
3311
+ }
3312
+ const mergedE2e = parsed.e2e === void 0 ? DEFAULT_SCHEMA.e2e : {
3313
+ ...DEFAULT_SCHEMA.e2e,
3314
+ ...parsed.e2e,
3315
+ runners: (parsed.e2e.runners ?? DEFAULT_SCHEMA.e2e?.runners ?? []).map((runner) => ({
3316
+ kind: "e2e",
3317
+ ...runner
3318
+ }))
3319
+ };
3320
+ const merged = {
3165
3321
  ...DEFAULT_SCHEMA,
3166
3322
  ...parsed,
3167
3323
  types: mergeArtifactTypes(DEFAULT_SCHEMA.types, parsed.types),
@@ -3170,8 +3326,101 @@ async function loadConfig(root) {
3170
3326
  allowedEdges: parsed.allowedEdges ?? DEFAULT_SCHEMA.allowedEdges,
3171
3327
  forbiddenEdges: parsed.forbiddenEdges ?? DEFAULT_SCHEMA.forbiddenEdges,
3172
3328
  statuses: parsed.statuses ?? DEFAULT_SCHEMA.statuses,
3173
- idRanges: mergeRecord(DEFAULT_SCHEMA.idRanges, parsed.idRanges)
3329
+ idRanges: mergeRecord(DEFAULT_SCHEMA.idRanges, parsed.idRanges),
3330
+ e2e: mergedE2e
3331
+ };
3332
+ return merged;
3333
+ }
3334
+ function validateE2eConfig(e2e) {
3335
+ for (const field of ["report_uncovered_scenarios", "report_uncovered_features"]) {
3336
+ if (e2e[field] !== void 0 && typeof e2e[field] !== "boolean") {
3337
+ throw new Error(`Invalid e2e.${field}: must be boolean.`);
3338
+ }
3339
+ }
3340
+ if (e2e.executable_ref_warning !== void 0) {
3341
+ if (typeof e2e.executable_ref_warning !== "number" || e2e.executable_ref_warning < 0 || e2e.executable_ref_warning > 1) {
3342
+ throw new Error(`Invalid e2e.executable_ref_warning: ${JSON.stringify(e2e.executable_ref_warning)}. Must be a number between 0 and 1.`);
3343
+ }
3344
+ }
3345
+ if (e2e.executable_ref_error !== void 0) {
3346
+ if (typeof e2e.executable_ref_error !== "number" || e2e.executable_ref_error < 0 || e2e.executable_ref_error > 1) {
3347
+ throw new Error(`Invalid e2e.executable_ref_error: ${JSON.stringify(e2e.executable_ref_error)}. Must be a number between 0 and 1.`);
3348
+ }
3349
+ }
3350
+ const validateWaivers = (waivers, field) => {
3351
+ if (waivers === void 0) return;
3352
+ if (!Array.isArray(waivers)) {
3353
+ throw new Error(`Invalid ${field}: must be an array of {id, reason} objects.`);
3354
+ }
3355
+ for (const w of waivers) {
3356
+ if (typeof w !== "object" || w === null || !("id" in w) || !("reason" in w)) {
3357
+ throw new Error(`Invalid ${field} entry: ${JSON.stringify(w)}. Must be {id, reason} object.`);
3358
+ }
3359
+ if (typeof w.id !== "string" || !w.id.trim()) {
3360
+ throw new Error(`Invalid ${field} entry: id must be a non-empty string. Got: ${JSON.stringify(w.id)}`);
3361
+ }
3362
+ if (typeof w.reason !== "string" || !w.reason.trim()) {
3363
+ throw new Error(`Invalid ${field} entry: reason must be a non-empty string. Got: ${JSON.stringify(w.reason)}`);
3364
+ }
3365
+ }
3174
3366
  };
3367
+ validateWaivers(e2e.scenario_waivers, "e2e.scenario_waivers");
3368
+ validateWaivers(e2e.feature_waivers, "e2e.feature_waivers");
3369
+ if (e2e.runners !== void 0) {
3370
+ if (!Array.isArray(e2e.runners)) {
3371
+ throw new Error(`Invalid e2e.runners: must be an array.`);
3372
+ }
3373
+ for (const runner of e2e.runners) {
3374
+ if (typeof runner !== "object" || runner === null) {
3375
+ throw new Error(`Invalid e2e.runners entry: must be an object.`);
3376
+ }
3377
+ if (typeof runner.name !== "string" || !runner.name.trim()) {
3378
+ throw new Error(`Invalid e2e.runners entry: name must be a non-empty string.`);
3379
+ }
3380
+ if (typeof runner.root !== "string" || !runner.root.trim()) {
3381
+ throw new Error(`Invalid e2e.runners[${runner.name}].root: must be a non-empty string.`);
3382
+ }
3383
+ if ((0, import_node_path6.isAbsolute)(runner.root)) {
3384
+ throw new Error(`Invalid e2e.runners[${runner.name}].root: "${runner.root}" must not be an absolute path.`);
3385
+ }
3386
+ if (runner.root.replace(/\\/g, "/").split("/").includes("..")) {
3387
+ throw new Error(`Invalid e2e.runners[${runner.name}].root: "${runner.root}" must not contain ".." segments.`);
3388
+ }
3389
+ if (!Array.isArray(runner.include) || runner.include.length === 0) {
3390
+ throw new Error(`Invalid e2e.runners[${runner.name}].include: must be a non-empty array of glob patterns.`);
3391
+ }
3392
+ for (const pattern of runner.include) {
3393
+ if (typeof pattern !== "string" || !pattern.trim()) {
3394
+ throw new Error(`Invalid e2e.runners[${runner.name}].include: pattern must be a non-empty string.`);
3395
+ }
3396
+ }
3397
+ if (runner.exclude !== void 0) {
3398
+ if (!Array.isArray(runner.exclude)) {
3399
+ throw new Error(`Invalid e2e.runners[${runner.name}].exclude: must be an array.`);
3400
+ }
3401
+ for (const pattern of runner.exclude) {
3402
+ if (typeof pattern !== "string" || !pattern.trim()) {
3403
+ throw new Error(`Invalid e2e.runners[${runner.name}].exclude: pattern must be a non-empty string.`);
3404
+ }
3405
+ }
3406
+ }
3407
+ if (runner.testIgnore !== void 0) {
3408
+ if (!Array.isArray(runner.testIgnore)) {
3409
+ throw new Error(`Invalid e2e.runners[${runner.name}].testIgnore: must be an array.`);
3410
+ }
3411
+ for (const pattern of runner.testIgnore) {
3412
+ if (typeof pattern !== "string" || !pattern.trim()) {
3413
+ throw new Error(`Invalid e2e.runners[${runner.name}].testIgnore: pattern must be a non-empty string.`);
3414
+ }
3415
+ }
3416
+ }
3417
+ if (runner.kind !== void 0) {
3418
+ if (!["unit", "integration", "e2e"].includes(runner.kind)) {
3419
+ throw new Error(`Invalid e2e.runners[${runner.name}].kind: "${runner.kind}". Must be unit, integration, or e2e.`);
3420
+ }
3421
+ }
3422
+ }
3423
+ }
3175
3424
  }
3176
3425
  function buildGraph(nodes, edges, diagnostics = [], root) {
3177
3426
  const graphNodes = nodes.map((node) => ({ ...node, uid: toUid(node.type, node.code) }));
@@ -5320,6 +5569,29 @@ function validateE2eTests(graph) {
5320
5569
  issues.push(issue("E2E_AC_UNKNOWN", `${node.uid} references unknown AC ${reference.feature}(${reference.ac})`, node.path, node.line, { node: node.uid, severity: "warning" }));
5321
5570
  }
5322
5571
  }
5572
+ const tcStatus = String(fields["status"] ?? "").trim().toLowerCase();
5573
+ if (tcStatus && !VALID_TC_STATUSES.has(tcStatus)) {
5574
+ 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" }));
5575
+ }
5576
+ if (tcStatus === "waived") {
5577
+ const reason = String(fields["waived_reason"] ?? "").trim();
5578
+ if (!reason) {
5579
+ 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" }));
5580
+ }
5581
+ }
5582
+ const rawChainType = String(fields["chain_type"] ?? "").trim();
5583
+ const chainType = rawChainType.toLowerCase();
5584
+ if (rawChainType) {
5585
+ if (!VALID_CHAIN_TYPES.has(chainType) && !(chainType in DEPRECATED_CHAIN_TYPE_ALIASES)) {
5586
+ 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" }));
5587
+ } else if (chainType in DEPRECATED_CHAIN_TYPE_ALIASES) {
5588
+ 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" }));
5589
+ }
5590
+ }
5591
+ const rawAcCoverageRate = String(fields["ac_coverage_rate"] ?? "").trim();
5592
+ if (rawAcCoverageRate) {
5593
+ 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" }));
5594
+ }
5323
5595
  if (needsDesktopChainWarning(node)) {
5324
5596
  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" }));
5325
5597
  }
@@ -5378,10 +5650,10 @@ function validateE2eRegistry(graph) {
5378
5650
  }
5379
5651
  return issues;
5380
5652
  }
5381
- async function validateExecutableTraceability(root) {
5653
+ async function validateExecutableTraceability(root, config) {
5382
5654
  const issues = [];
5655
+ const schema = config ?? await loadConfig(root);
5383
5656
  const e2eDir = (0, import_node_path6.join)(root, "artifacts", "tests", "e2e");
5384
- const specPatterns = ["heimdall/**/*.spec.ts", "heimdall/**/*.e2e.spec.ts"];
5385
5657
  let e2eFiles;
5386
5658
  try {
5387
5659
  e2eFiles = (await (0, import_promises5.readdir)(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).map((name) => (0, import_node_path6.join)(e2eDir, name));
@@ -5424,9 +5696,16 @@ async function validateExecutableTraceability(root) {
5424
5696
  }
5425
5697
  const allFiles = await walk(root);
5426
5698
  const specFiles = /* @__PURE__ */ new Set();
5427
- for (const pattern of specPatterns) {
5699
+ const configuredRunners = schema.e2e?.runners ?? [];
5700
+ if (configuredRunners.length > 0) {
5428
5701
  for (const file of allFiles) {
5429
- if (matchesPattern(file, pattern)) {
5702
+ if (configuredRunners.some((runner) => isRunnerIncludeCandidate(file, runner))) {
5703
+ specFiles.add(file);
5704
+ }
5705
+ }
5706
+ } else {
5707
+ for (const file of allFiles) {
5708
+ if (/\.(?:e2e\.)?spec\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(file)) {
5430
5709
  specFiles.add(file);
5431
5710
  }
5432
5711
  }
@@ -5497,12 +5776,21 @@ async function validateExecutableTraceability(root) {
5497
5776
  const refEntries = parseExecutableRefLines(ref);
5498
5777
  const validFiles = [];
5499
5778
  for (const entry of refEntries) {
5500
- const normalizedRefFile = entry.file.startsWith("heimdall/") ? entry.file : `heimdall/${entry.file}`;
5501
- const fileExists = specFiles.has(normalizedRefFile);
5779
+ const normalizedRefFile = resolveExecutableRefFile(entry.file, allFiles);
5780
+ const fileExists = normalizedRefFile !== void 0 && specFiles.has(normalizedRefFile);
5502
5781
  if (!fileExists) {
5503
5782
  issues.push(issue("E2E-TRACE-001", `executable_ref target file not found: ${entry.file}`, path, line, { node: tcKey, severity: "warning" }));
5504
5783
  continue;
5505
5784
  }
5785
+ const runners = schema.e2e?.runners ?? [];
5786
+ if (runners.length > 0) {
5787
+ const acceptingRunners = await getAcceptingRunners(root, normalizedRefFile, runners);
5788
+ const hasE2eRunner = acceptingRunners.some((r) => r.kind === "e2e" || r.kind === "integration");
5789
+ const hasUnitRunner = acceptingRunners.some((r) => r.kind === "unit");
5790
+ if (hasUnitRunner && !hasE2eRunner && acceptingRunners.length > 0) {
5791
+ 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" }));
5792
+ }
5793
+ }
5506
5794
  if (entry.testId) {
5507
5795
  let content;
5508
5796
  try {
@@ -5517,10 +5805,7 @@ async function validateExecutableTraceability(root) {
5517
5805
  }
5518
5806
  }
5519
5807
  const annotationsForTc = refToSource.get(tcKey);
5520
- const hasAnnotationInFile = annotationsForTc?.some((ann) => {
5521
- const normalizedAnnFile = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
5522
- return normalizedAnnFile === normalizedRefFile;
5523
- }) ?? false;
5808
+ const hasAnnotationInFile = annotationsForTc?.some((ann) => ann.file === normalizedRefFile) ?? false;
5524
5809
  if (!hasAnnotationInFile) {
5525
5810
  issues.push(issue("E2E-TRACE-003", `executable_ref target ${entry.file} has no E2E trace annotation ${tcKey}`, path, line, { node: tcKey, severity: "warning" }));
5526
5811
  continue;
@@ -5558,24 +5843,37 @@ async function validateExecutableTraceability(root) {
5558
5843
  if (matchesAnyRef) {
5559
5844
  continue;
5560
5845
  }
5561
- const primaryRef = refEntries[0];
5562
- const normalizedPrimary = primaryRef?.file.startsWith("heimdall/") ? primaryRef?.file : `heimdall/${primaryRef?.file ?? ""}`;
5563
5846
  const detail = `file: MD refs=[${refEntries.map((e) => e.file).join(", ")}] vs source=${ann.file}`;
5564
5847
  issues.push(issue("E2E-TRACE-003", `executable_ref \u2194 E2E trace annotation mismatch for ${tcKey}: ${detail}`, path, line, { node: tcKey, severity: "warning" }));
5565
5848
  }
5566
5849
  }
5567
- for (const [tcKey, { chainType, path, line }] of mdToRef) {
5568
- if (!isDesktopChainType(chainType)) {
5850
+ for (const [tcKey, { chainType, path, line }] of allMdTcInfo) {
5851
+ const tcFields = tcKeyToFields.get(tcKey);
5852
+ const explicitChainType = String(tcFields?.["chain_type"] ?? "").trim().toLowerCase();
5853
+ if (explicitChainType !== "desktop_chain") {
5569
5854
  continue;
5570
5855
  }
5856
+ if (!mdToRef.has(tcKey)) {
5857
+ issues.push(issue("E2E-DESKTOP-CHAIN-MISSING", `desktop_chain TC ${tcKey} has no executable_ref`, path, line, { node: tcKey, severity: "warning" }));
5858
+ }
5859
+ }
5860
+ for (const [tcKey, { chainType, path, line }] of mdToRef) {
5861
+ const normalizedDeclaredChainType = chainType.trim().toLowerCase();
5862
+ const hasLegalNonDesktopDeclaration = normalizedDeclaredChainType.length > 0 && VALID_CHAIN_TYPES.has(normalizedDeclaredChainType) && normalizedDeclaredChainType !== "desktop_chain";
5863
+ if (hasLegalNonDesktopDeclaration) continue;
5571
5864
  const validFiles = new Set(tcValidRefFiles.get(tcKey) ?? []);
5572
5865
  const sourceAnnotations = refToSource.get(tcKey);
5573
5866
  if (!sourceAnnotations) {
5574
5867
  continue;
5575
5868
  }
5869
+ const tcFields = tcKeyToFields.get(tcKey);
5870
+ const explicitChainType = String(tcFields?.["chain_type"] ?? "").trim().toLowerCase();
5871
+ const hasExplicitDesktopChain = explicitChainType === "desktop_chain";
5872
+ if (hasExplicitDesktopChain) {
5873
+ continue;
5874
+ }
5576
5875
  for (const ann of sourceAnnotations) {
5577
- const normalizedAnnFile = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
5578
- if (!validFiles.has(normalizedAnnFile)) {
5876
+ if (!validFiles.has(ann.file)) {
5579
5877
  continue;
5580
5878
  }
5581
5879
  if (ann.level === "mock_playwright") {
@@ -5601,10 +5899,7 @@ async function validateExecutableTraceability(root) {
5601
5899
  continue;
5602
5900
  }
5603
5901
  const validFiles = new Set(tcValidRefFiles.get(tcKey) ?? []);
5604
- const sourceAnnotations = refToSource.get(tcKey)?.filter((ann) => {
5605
- const normalized = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
5606
- return validFiles.has(normalized);
5607
- });
5902
+ const sourceAnnotations = refToSource.get(tcKey)?.filter((ann) => validFiles.has(ann.file));
5608
5903
  const hasDesktopChain = sourceAnnotations?.some((ann) => ann.level === "desktop_chain") ?? false;
5609
5904
  const hasBridge = sourceAnnotations?.some((ann) => ann.level === "ui_sidecar_bridge") ?? false;
5610
5905
  if (isComplete) {
@@ -5613,7 +5908,7 @@ async function validateExecutableTraceability(root) {
5613
5908
  if (hasDesktopChain) {
5614
5909
  hasValidEvidence = true;
5615
5910
  } else if (hasBridge) {
5616
- const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root);
5911
+ const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root, allFiles);
5617
5912
  if (partialResult.hasValidPartialRust) {
5618
5913
  hasValidEvidence = true;
5619
5914
  } else {
@@ -5653,7 +5948,7 @@ async function validateExecutableTraceability(root) {
5653
5948
  }
5654
5949
  let partialDetail = "";
5655
5950
  if (hasBridge) {
5656
- const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root);
5951
+ const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root, allFiles);
5657
5952
  if (partialResult.hasValidPartialRust) {
5658
5953
  continue;
5659
5954
  }
@@ -5670,6 +5965,287 @@ async function validateExecutableTraceability(root) {
5670
5965
  }
5671
5966
  return issues;
5672
5967
  }
5968
+ async function computeE2eCoverageStats(graph, root, thresholds = {}) {
5969
+ const e2eNodes = graph.nodes.filter((n) => n.type === "e2e_test" && n.attrs?.fileLevelOnly !== true);
5970
+ const totalTestCases = e2eNodes.length;
5971
+ let withExecutableRef = 0;
5972
+ const statusBreakdown = {};
5973
+ const chainTypeBreakdown = {};
5974
+ const e2eDir = (0, import_node_path6.join)(root, "artifacts", "tests", "e2e");
5975
+ const tcFieldsMap = /* @__PURE__ */ new Map();
5976
+ let e2eFiles;
5977
+ try {
5978
+ e2eFiles = (await (0, import_promises5.readdir)(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).map((name) => (0, import_node_path6.join)(e2eDir, name));
5979
+ } catch {
5980
+ e2eFiles = [];
5981
+ }
5982
+ for (const filePath of e2eFiles) {
5983
+ const raw = await (0, import_promises5.readFile)(filePath, "utf-8");
5984
+ const lines = raw.split(/\r?\n/);
5985
+ const tcStarts = [];
5986
+ lines.forEach((line, index) => {
5987
+ const match = /^#{2,3}\s+(TC-\d+[a-z]?)\s*[::]?\s*.*$/.exec(line);
5988
+ if (match) {
5989
+ tcStarts.push({ id: match[1], index });
5990
+ }
5991
+ });
5992
+ const parsed = (0, import_gray_matter.default)(raw);
5993
+ const batch = String(parsed.data.test_batch ?? (0, import_node_path6.basename)(filePath, (0, import_node_path6.extname)(filePath))).trim();
5994
+ for (let i = 0; i < tcStarts.length; i++) {
5995
+ const start = tcStarts[i];
5996
+ const end = tcStarts[i + 1]?.index ?? lines.length;
5997
+ const block = lines.slice(start.index, end);
5998
+ const fields = extractE2eTcFields(block);
5999
+ tcFieldsMap.set(`${batch}:${start.id}`, fields);
6000
+ }
6001
+ }
6002
+ for (const node of e2eNodes) {
6003
+ const tcKey = node.code;
6004
+ const fields = tcFieldsMap.get(tcKey) ?? asRecord(node.attrs?.tcFields);
6005
+ const execRef = String(fields["executable_ref"] ?? "").trim();
6006
+ if (execRef && !isPendingExecutableRef(execRef)) {
6007
+ withExecutableRef++;
6008
+ }
6009
+ const status = String(fields["status"] ?? "created").trim().toLowerCase();
6010
+ statusBreakdown[status] = (statusBreakdown[status] ?? 0) + 1;
6011
+ const chainType = String(fields["chain_type"] ?? "").trim().toLowerCase() || "unspecified";
6012
+ chainTypeBreakdown[chainType] = (chainTypeBreakdown[chainType] ?? 0) + 1;
6013
+ }
6014
+ const executableRefRate = totalTestCases > 0 ? `${withExecutableRef}/${totalTestCases} (${(withExecutableRef / totalTestCases * 100).toFixed(1)}%)` : "0/0";
6015
+ const scenarioNodes = graph.nodes.filter((n) => n.type === "scenario");
6016
+ const featureNodes = graph.nodes.filter((n) => n.type === "feature");
6017
+ const linkedScenarios = /* @__PURE__ */ new Set();
6018
+ const linkedFeatures = /* @__PURE__ */ new Set();
6019
+ const acCoveredScenarios = /* @__PURE__ */ new Set();
6020
+ const acCoveredFeatures = /* @__PURE__ */ new Set();
6021
+ const verifiedScenarios = /* @__PURE__ */ new Set();
6022
+ const verifiedFeatures = /* @__PURE__ */ new Set();
6023
+ for (const edge2 of graph.edges) {
6024
+ if (edge2.kind === "verifies" && edge2.from.startsWith("e2e_test:")) {
6025
+ if (edge2.to.startsWith("scenario:")) {
6026
+ linkedScenarios.add(edge2.to.replace("scenario:", ""));
6027
+ }
6028
+ if (edge2.to.startsWith("feature:")) {
6029
+ linkedFeatures.add(edge2.to.replace("feature:", ""));
6030
+ }
6031
+ }
6032
+ }
6033
+ for (const node of e2eNodes) {
6034
+ const fields = tcFieldsMap.get(node.code) ?? asRecord(node.attrs?.tcFields);
6035
+ const acCoverage = asRecord(fields["ac_coverage"] ?? node.attrs?.ac_coverage);
6036
+ for (const feature of Object.keys(acCoverage)) {
6037
+ acCoveredFeatures.add(feature);
6038
+ }
6039
+ const relatedScenarios = toArray(fields["related_scenarios"] ?? node.attrs?.related_scenarios).map(String);
6040
+ for (const scenario of relatedScenarios) {
6041
+ acCoveredScenarios.add(scenario);
6042
+ }
6043
+ }
6044
+ const runners = (await loadConfig(root)).e2e?.runners ?? [];
6045
+ const allProjectFiles = await walk(root);
6046
+ for (const node of e2eNodes) {
6047
+ const fields = tcFieldsMap.get(node.code) ?? asRecord(node.attrs?.tcFields);
6048
+ const status = String(fields["status"] ?? "").trim().toLowerCase();
6049
+ const execRef = String(fields["executable_ref"] ?? "").trim();
6050
+ if (status !== "verified") continue;
6051
+ if (!execRef || isPendingExecutableRef(execRef)) continue;
6052
+ if (runners.length === 0) continue;
6053
+ let hasActiveE2eRef = false;
6054
+ for (const entry of parseExecutableRefLines(execRef)) {
6055
+ const normalized = resolveExecutableRefFile(entry.file, allProjectFiles);
6056
+ if (!normalized || !(0, import_node_fs4.existsSync)((0, import_node_path6.join)(root, normalized))) continue;
6057
+ const accepting = await getAcceptingRunners(root, normalized, runners);
6058
+ if (accepting.some((runner) => runner.kind === "e2e")) {
6059
+ hasActiveE2eRef = true;
6060
+ break;
6061
+ }
6062
+ }
6063
+ if (!hasActiveE2eRef) continue;
6064
+ const relatedScenarios = toArray(fields["related_scenarios"] ?? node.attrs?.related_scenarios).map(String);
6065
+ for (const scenario of relatedScenarios) {
6066
+ verifiedScenarios.add(scenario);
6067
+ }
6068
+ const acCoverage = asRecord(fields["ac_coverage"] ?? node.attrs?.ac_coverage);
6069
+ for (const feature of Object.keys(acCoverage)) {
6070
+ verifiedFeatures.add(feature);
6071
+ }
6072
+ }
6073
+ const scenarioWaivers = new Set((thresholds.scenarioWaivers ?? []).map((w) => w.id));
6074
+ const featureWaivers = new Set((thresholds.featureWaivers ?? []).map((w) => w.id));
6075
+ const uncoveredScenarios = scenarioNodes.map((n) => n.code).filter((code) => !linkedScenarios.has(code) && !scenarioWaivers.has(code));
6076
+ const uncoveredFeatures = featureNodes.map((n) => n.code).filter((code) => !acCoveredFeatures.has(code) && !featureWaivers.has(code));
6077
+ const scenarioCoverage = {};
6078
+ for (const node of scenarioNodes) {
6079
+ scenarioCoverage[node.code] = {
6080
+ linked: linkedScenarios.has(node.code),
6081
+ acCovered: acCoveredScenarios.has(node.code),
6082
+ waived: scenarioWaivers.has(node.code),
6083
+ verified: !scenarioWaivers.has(node.code) && verifiedScenarios.has(node.code)
6084
+ };
6085
+ }
6086
+ const featureCoverage = {};
6087
+ for (const node of featureNodes) {
6088
+ featureCoverage[node.code] = {
6089
+ linked: linkedFeatures.has(node.code),
6090
+ acCovered: acCoveredFeatures.has(node.code),
6091
+ waived: featureWaivers.has(node.code),
6092
+ verified: !featureWaivers.has(node.code) && verifiedFeatures.has(node.code)
6093
+ };
6094
+ }
6095
+ const thresholdWarnings = [];
6096
+ const thresholdErrors = [];
6097
+ const warningRate = thresholds.executableRefWarning;
6098
+ const errorRate = thresholds.executableRefError;
6099
+ const actualRate = totalTestCases > 0 ? withExecutableRef / totalTestCases : 1;
6100
+ if (warningRate !== void 0 && actualRate < warningRate) {
6101
+ thresholdWarnings.push(`executable_ref coverage ${executableRefRate} < warning threshold ${(warningRate * 100).toFixed(0)}%`);
6102
+ }
6103
+ if (errorRate !== void 0 && actualRate < errorRate) {
6104
+ thresholdErrors.push(`executable_ref coverage ${executableRefRate} < error threshold ${(errorRate * 100).toFixed(0)}%`);
6105
+ }
6106
+ if (thresholds.reportUncoveredScenarios !== false && uncoveredScenarios.length > 0) {
6107
+ thresholdWarnings.push(`${uncoveredScenarios.length} scenario(s) have no E2E coverage: ${uncoveredScenarios.join(", ")}`);
6108
+ }
6109
+ if (thresholds.reportUncoveredFeatures !== false && uncoveredFeatures.length > 0) {
6110
+ thresholdWarnings.push(`${uncoveredFeatures.length} feature(s) have no E2E coverage: ${uncoveredFeatures.join(", ")}`);
6111
+ }
6112
+ const acCoverageRateByFeature = {};
6113
+ const featureAcMap = /* @__PURE__ */ new Map();
6114
+ for (const node of featureNodes) {
6115
+ const acs = parseAcceptanceCriteria(await (0, import_promises5.readFile)((0, import_node_path6.join)(root, node.path), "utf-8"));
6116
+ featureAcMap.set(node.code, new Set(acs));
6117
+ }
6118
+ const coveredAcByFeature = /* @__PURE__ */ new Map();
6119
+ for (const node of e2eNodes) {
6120
+ const fields = tcFieldsMap.get(node.code) ?? asRecord(node.attrs?.tcFields);
6121
+ const acCoverage = asRecord(fields["ac_coverage"] ?? node.attrs?.ac_coverage);
6122
+ for (const [feature, acs] of Object.entries(acCoverage)) {
6123
+ const existing = coveredAcByFeature.get(feature) ?? /* @__PURE__ */ new Set();
6124
+ for (const ac of toArray(acs)) {
6125
+ existing.add(String(ac));
6126
+ }
6127
+ coveredAcByFeature.set(feature, existing);
6128
+ }
6129
+ }
6130
+ for (const [feature, allAcs] of featureAcMap) {
6131
+ const denominator = allAcs.size;
6132
+ if (denominator === 0) continue;
6133
+ const coveredAcs = coveredAcByFeature.get(feature) ?? /* @__PURE__ */ new Set();
6134
+ const numerator = [...coveredAcs].filter((ac) => allAcs.has(ac)).length;
6135
+ acCoverageRateByFeature[feature] = {
6136
+ numerator,
6137
+ denominator,
6138
+ rate: denominator > 0 ? numerator / denominator : 0
6139
+ };
6140
+ }
6141
+ return {
6142
+ totalTestCases,
6143
+ withExecutableRef,
6144
+ executableRefRate,
6145
+ statusBreakdown,
6146
+ chainTypeBreakdown,
6147
+ uncoveredScenarios,
6148
+ uncoveredFeatures,
6149
+ thresholdWarnings,
6150
+ thresholdErrors,
6151
+ acCoverageRateByFeature,
6152
+ scenarioCoverage,
6153
+ featureCoverage
6154
+ };
6155
+ }
6156
+ async function generateE2eRegistry(root, opts) {
6157
+ const e2eDir = (0, import_node_path6.join)(root, "artifacts", "tests", "e2e");
6158
+ let files;
6159
+ try {
6160
+ files = (await (0, import_promises5.readdir)(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).sort();
6161
+ } catch {
6162
+ return {
6163
+ registry_version: "1.0",
6164
+ generated_at: opts?.deterministic ? "1970-01-01T00:00:00.000Z" : (/* @__PURE__ */ new Date()).toISOString(),
6165
+ total_batches: 0,
6166
+ total_test_cases: 0,
6167
+ batches: []
6168
+ };
6169
+ }
6170
+ const batches = [];
6171
+ let totalTestCases = 0;
6172
+ for (const file of files) {
6173
+ const filePath = (0, import_node_path6.join)(e2eDir, file);
6174
+ const raw = await (0, import_promises5.readFile)(filePath, "utf-8");
6175
+ const parsed = (0, import_gray_matter.default)(raw);
6176
+ const data = parsed.data;
6177
+ const batch = String(data.test_batch ?? (0, import_node_path6.basename)(file, (0, import_node_path6.extname)(file))).trim();
6178
+ const relPath = `artifacts/tests/e2e/${file}`;
6179
+ const scope = String(data.scope ?? "").trim();
6180
+ const acCoverage = normalizeAcCoverageForRegistry(data.ac_coverage);
6181
+ const relatedScenarios = toArray(data.related_scenarios).map(String).filter(Boolean);
6182
+ const lines = raw.split(/\r?\n/);
6183
+ const tcStarts = [];
6184
+ lines.forEach((line, index) => {
6185
+ const match = /^#{2,3}\s+(TC-\d+[a-z]?)\s*[::]?\s*.*$/.exec(line);
6186
+ if (match) {
6187
+ tcStarts.push({ id: match[1], index });
6188
+ }
6189
+ });
6190
+ const statusSummary = {};
6191
+ const blockingReasons = {};
6192
+ const frontmatterFixesBlock = String(data.fixes_block ?? "").trim();
6193
+ if (frontmatterFixesBlock && /no\s+(test\s+file|e2e)/i.test(frontmatterFixesBlock)) {
6194
+ for (const tc of tcStarts) {
6195
+ blockingReasons[tc.id] = frontmatterFixesBlock;
6196
+ }
6197
+ }
6198
+ for (const start of tcStarts) {
6199
+ const end = tcStarts[tcStarts.indexOf(start) + 1]?.index ?? lines.length;
6200
+ const block = lines.slice(start.index, end);
6201
+ const fields = extractE2eTcFields(block);
6202
+ const status = String(fields["status"] ?? "created").trim().toLowerCase() || "created";
6203
+ statusSummary[status] = (statusSummary[status] ?? 0) + 1;
6204
+ if (status === "created" && !blockingReasons[start.id]) {
6205
+ const executableRef = String(fields["executable_ref"] ?? "").trim();
6206
+ const chainType = String(fields["chain_type"] ?? "").trim().toLowerCase();
6207
+ if (!executableRef && chainType === "desktop_chain") {
6208
+ blockingReasons[start.id] = "desktop_chain TC requires executable_ref";
6209
+ } else if (isPendingExecutableRef(executableRef)) {
6210
+ blockingReasons[start.id] = `pending: ${executableRef}`;
6211
+ }
6212
+ }
6213
+ }
6214
+ const testCaseCount = tcStarts.length;
6215
+ totalTestCases += testCaseCount;
6216
+ const batchStatus = Object.keys(blockingReasons).length > 0 ? "blocked" : void 0;
6217
+ batches.push({
6218
+ batch_id: batch,
6219
+ file: relPath,
6220
+ scope,
6221
+ ac_coverage: acCoverage,
6222
+ related_scenarios: relatedScenarios,
6223
+ test_case_count: testCaseCount,
6224
+ status_summary: statusSummary,
6225
+ status: batchStatus,
6226
+ blocking_reasons: Object.keys(blockingReasons).length > 0 ? blockingReasons : void 0
6227
+ });
6228
+ }
6229
+ return {
6230
+ registry_version: "1.0",
6231
+ generated_at: opts?.deterministic ? "1970-01-01T00:00:00.000Z" : (/* @__PURE__ */ new Date()).toISOString(),
6232
+ total_batches: batches.length,
6233
+ total_test_cases: totalTestCases,
6234
+ batches
6235
+ };
6236
+ }
6237
+ function normalizeAcCoverageForRegistry(value) {
6238
+ if (!value || typeof value !== "object") return {};
6239
+ const result = {};
6240
+ for (const [key, val] of Object.entries(value)) {
6241
+ if (Array.isArray(val)) {
6242
+ result[key] = val.map(String);
6243
+ } else if (typeof val === "string") {
6244
+ result[key] = val.split(",").map((s) => s.trim()).filter(Boolean);
6245
+ }
6246
+ }
6247
+ return result;
6248
+ }
5673
6249
  function isPendingExecutableRef(ref) {
5674
6250
  const stripped = ref.replace(/^[\s-*()]+/, "").trim();
5675
6251
  return /^pending\b/i.test(stripped);
@@ -5687,6 +6263,20 @@ function parseExecutableRefLines(ref) {
5687
6263
  }
5688
6264
  return results;
5689
6265
  }
6266
+ var VALID_TC_STATUSES = /* @__PURE__ */ new Set(["created", "automated", "verified", "waived"]);
6267
+ var VALID_CHAIN_TYPES = /* @__PURE__ */ new Set([
6268
+ "desktop_chain",
6269
+ "mock_playwright",
6270
+ "core_e2e",
6271
+ "cli_e2e",
6272
+ "ui_sidecar_bridge",
6273
+ "partial_sidecar",
6274
+ "partial_rust"
6275
+ ]);
6276
+ var DEPRECATED_CHAIN_TYPE_ALIASES = {
6277
+ core_only: "core_e2e",
6278
+ frontend_only: "mock_playwright"
6279
+ };
5690
6280
  function isDesktopChainType(chainType) {
5691
6281
  const normalizedChainType = chainType.trim().toLowerCase();
5692
6282
  return normalizedChainType === "desktop_chain" || normalizedChainType === "";
@@ -5694,7 +6284,7 @@ function isDesktopChainType(chainType) {
5694
6284
  function parseChainCoverageStatus(chainCoverage) {
5695
6285
  return chainCoverage.trim().toLowerCase().match(/^[a-z_]+/)?.[0] ?? "";
5696
6286
  }
5697
- async function validatePartialRustEvidence(tcFields, tcKey, root) {
6287
+ async function validatePartialRustEvidence(tcFields, tcKey, root, allFiles) {
5698
6288
  const partialEvidence = String(tcFields["partial_evidence"] ?? "");
5699
6289
  if (!partialEvidence.trim()) {
5700
6290
  return { hasValidPartialRust: false, detail: "no partial_evidence field" };
@@ -5718,7 +6308,10 @@ async function validatePartialRustEvidence(tcFields, tcKey, root) {
5718
6308
  return { hasValidPartialRust: false, detail: "no .rs file in partial_evidence" };
5719
6309
  }
5720
6310
  for (const ref of rustRefs) {
5721
- const normalizedPath = ref.file.startsWith("heimdall/") ? ref.file : `heimdall/${ref.file}`;
6311
+ const normalizedPath = resolveExecutableRefFile(ref.file, allFiles);
6312
+ if (!normalizedPath) {
6313
+ return { hasValidPartialRust: false, detail: `partial_rust file not found: ${ref.file}` };
6314
+ }
5722
6315
  const fullPath = (0, import_node_path6.join)(root, normalizedPath);
5723
6316
  let content;
5724
6317
  try {
@@ -5754,6 +6347,46 @@ function detectTestLevel(specFile, content) {
5754
6347
  }
5755
6348
  return "desktop_chain";
5756
6349
  }
6350
+ function resolveExecutableRefFile(refFile, allFiles) {
6351
+ const normalized = refFile.replace(/\\/g, "/").replace(/^\.\//, "");
6352
+ if (!normalized || (0, import_node_path6.isAbsolute)(refFile) || normalized.split("/").includes("..")) {
6353
+ return void 0;
6354
+ }
6355
+ if (allFiles.includes(normalized)) {
6356
+ return normalized;
6357
+ }
6358
+ const suffix = `/${normalized}`;
6359
+ const matches = allFiles.filter((file) => file.endsWith(suffix));
6360
+ return matches.length === 1 ? matches[0] : void 0;
6361
+ }
6362
+ function isRunnerIncludeCandidate(filePath, runner) {
6363
+ const normalizedPath = filePath.replace(/\\/g, "/");
6364
+ const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
6365
+ if (runnerRoot !== "." && !normalizedPath.startsWith(`${runnerRoot}/`)) {
6366
+ return false;
6367
+ }
6368
+ const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.slice(runnerRoot.length + 1);
6369
+ return runner.include.some((pattern) => matchesRunnerGlob(relativePath, pattern));
6370
+ }
6371
+ async function getAcceptingRunners(root, filePath, runners) {
6372
+ const accepting = [];
6373
+ for (const runner of runners) {
6374
+ const normalizedPath = filePath.replace(/\\/g, "/");
6375
+ const runnerRoot = runner.root.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") || ".";
6376
+ const relativePath = runnerRoot === "." ? normalizedPath : normalizedPath.startsWith(runnerRoot + "/") ? normalizedPath.slice(runnerRoot.length + 1) : normalizedPath;
6377
+ if (runnerRoot !== "." && !normalizedPath.startsWith(`${runnerRoot}/`)) {
6378
+ continue;
6379
+ }
6380
+ const matchesInclude = runner.include.some((p) => matchesRunnerGlob(relativePath, p));
6381
+ if (!matchesInclude) continue;
6382
+ const matchesExclude = (runner.exclude ?? []).some((p) => matchesRunnerGlob(relativePath, p));
6383
+ if (matchesExclude) continue;
6384
+ const matchesTestIgnore = (runner.testIgnore ?? []).some((p) => matchesRunnerGlob(relativePath, p));
6385
+ if (matchesTestIgnore) continue;
6386
+ accepting.push(runner);
6387
+ }
6388
+ return accepting;
6389
+ }
5757
6390
  function splitMarkdownCells(line) {
5758
6391
  const cells = [];
5759
6392
  let current = "";
@@ -5907,7 +6540,7 @@ function flattenAcCoverage(value) {
5907
6540
  function needsDesktopChainWarning(node) {
5908
6541
  const tcFields = asRecord(node.attrs?.tcFields);
5909
6542
  const chainType = String(tcFields["chain_type"] ?? "").trim().toLowerCase();
5910
- if (chainType === "frontend_only" || chainType === "core_only") {
6543
+ if (chainType && VALID_CHAIN_TYPES.has(chainType) && chainType !== "desktop_chain" || chainType in DEPRECATED_CHAIN_TYPE_ALIASES) {
5911
6544
  return false;
5912
6545
  }
5913
6546
  const chainCoverage = String(tcFields["chain_coverage"] ?? "").trim().toLowerCase();
@@ -6330,7 +6963,7 @@ function resolveArtifactContext(graph, opts) {
6330
6963
  const fullPath = (0, import_node_path6.join)(root, ap.path);
6331
6964
  let stat;
6332
6965
  try {
6333
- stat = (0, import_node_fs3.statSync)(fullPath);
6966
+ stat = (0, import_node_fs4.statSync)(fullPath);
6334
6967
  } catch {
6335
6968
  stat = null;
6336
6969
  }
@@ -6360,7 +6993,7 @@ function resolveArtifactContext(graph, opts) {
6360
6993
  }
6361
6994
  } else {
6362
6995
  try {
6363
- (0, import_node_fs3.accessSync)(fullPath, import_node_fs3.constants.R_OK);
6996
+ (0, import_node_fs4.accessSync)(fullPath, import_node_fs4.constants.R_OK);
6364
6997
  } catch {
6365
6998
  const msg = `Required baseline artifact is not readable: ${ap.path}`;
6366
6999
  if (!missing.includes(msg)) {
@@ -6579,12 +7212,14 @@ function formatContextMarkdown(manifest) {
6579
7212
  buildGraph,
6580
7213
  buildVersionIndex,
6581
7214
  collectChangedPaths,
7215
+ computeE2eCoverageStats,
6582
7216
  dirname,
6583
7217
  discoverAndAuditPackets,
6584
7218
  discoverTargets,
6585
7219
  doctorArtifactChain,
6586
7220
  extname,
6587
7221
  formatContextMarkdown,
7222
+ generateE2eRegistry,
6588
7223
  getArtifactTypeMetadata,
6589
7224
  getTargetArtifactTypes,
6590
7225
  installManagedHookBlock,