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