artifact-graph 0.4.1 → 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,6 +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);
104
+ var import_node_fs4 = require("fs");
102
105
  var import_promises5 = require("fs/promises");
103
106
  var import_node_path6 = require("path");
104
107
 
@@ -179,13 +182,76 @@ function validatePacket(packet, schema) {
179
182
  path: "target.id"
180
183
  });
181
184
  }
182
- if (packet.requiredBaseline.total !== BASELINE_ITEMS_COUNT) {
183
- issues.push({
184
- severity: "error",
185
- code: "PKT-004",
186
- message: `requiredBaseline.total must be ${BASELINE_ITEMS_COUNT}, got ${packet.requiredBaseline.total}`,
187
- path: "requiredBaseline.total"
188
- });
185
+ const isBaselineExplicitlyDisabled = packet.baselinePolicy === false;
186
+ const expectedPaths = new Set(ALWAYS_PRESENT_ITEMS.map((item) => item.path));
187
+ if (isBaselineExplicitlyDisabled) {
188
+ if (packet.requiredBaseline.total !== 0) {
189
+ issues.push({
190
+ severity: "error",
191
+ code: "PKT-004",
192
+ message: `baselinePolicy=false requires requiredBaseline.total=0, got ${packet.requiredBaseline.total}`,
193
+ path: "requiredBaseline.total"
194
+ });
195
+ }
196
+ if (packet.requiredBaseline.items.length !== 0) {
197
+ issues.push({
198
+ severity: "error",
199
+ code: "PKT-004",
200
+ message: `baselinePolicy=false requires requiredBaseline.items=[], got ${packet.requiredBaseline.items.length} item(s)`,
201
+ path: "requiredBaseline.items"
202
+ });
203
+ }
204
+ } else {
205
+ if (packet.requiredBaseline.total !== BASELINE_ITEMS_COUNT) {
206
+ issues.push({
207
+ severity: "error",
208
+ code: "PKT-004",
209
+ message: `requiredBaseline.total must be ${BASELINE_ITEMS_COUNT}, got ${packet.requiredBaseline.total}`,
210
+ path: "requiredBaseline.total"
211
+ });
212
+ }
213
+ if (packet.requiredBaseline.items.length !== BASELINE_ITEMS_COUNT) {
214
+ issues.push({
215
+ severity: "error",
216
+ code: "PKT-004",
217
+ message: `requiredBaseline.items.length must be ${BASELINE_ITEMS_COUNT}, got ${packet.requiredBaseline.items.length}`,
218
+ path: "requiredBaseline.items"
219
+ });
220
+ }
221
+ const actualPaths = packet.requiredBaseline.items.map((item) => item.path);
222
+ const actualPathSet = new Set(actualPaths);
223
+ if (actualPathSet.size !== actualPaths.length) {
224
+ issues.push({
225
+ severity: "error",
226
+ code: "PKT-004",
227
+ message: `requiredBaseline.items contains duplicate paths (${actualPaths.length} items, ${actualPathSet.size} unique)`,
228
+ path: "requiredBaseline.items"
229
+ });
230
+ }
231
+ const missingPaths = [];
232
+ for (const ep of expectedPaths) {
233
+ if (!actualPathSet.has(ep)) missingPaths.push(ep);
234
+ }
235
+ if (missingPaths.length > 0) {
236
+ issues.push({
237
+ severity: "error",
238
+ code: "PKT-004",
239
+ message: `requiredBaseline.items missing expected path(s): ${missingPaths.join(", ")}`,
240
+ path: "requiredBaseline.items"
241
+ });
242
+ }
243
+ const extraPaths = [];
244
+ for (const ap of actualPathSet) {
245
+ if (!expectedPaths.has(ap)) extraPaths.push(ap);
246
+ }
247
+ if (extraPaths.length > 0) {
248
+ issues.push({
249
+ severity: "error",
250
+ code: "PKT-004",
251
+ message: `requiredBaseline.items contains unexpected path(s): ${extraPaths.join(", ")}`,
252
+ path: "requiredBaseline.items"
253
+ });
254
+ }
189
255
  }
190
256
  const constraints = packet.implementationBlueprintDraft.constraints;
191
257
  if (constraints.length !== BASELINE_CONSTRAINTS_COUNT) {
@@ -292,6 +358,45 @@ function validatePacketMarkdown(markdown) {
292
358
  return { ok: !hasError, issues };
293
359
  }
294
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
+
295
400
  // src/target-selector.ts
296
401
  function parseTargetSelector(value) {
297
402
  const separator = value.indexOf(":");
@@ -612,7 +717,10 @@ function assemblePacket(manifest, options) {
612
717
  missing: [...manifest.missing],
613
718
  missingDetails: manifest.missingDetails ? [...manifest.missingDetails] : void 0,
614
719
  implementationBlueprintDraft: blueprintDraft,
615
- validationCommands
720
+ validationCommands,
721
+ // @feature ACA17
722
+ // @decision D-ACA-17
723
+ baselinePolicy: manifest.baselinePolicy
616
724
  };
617
725
  return packet;
618
726
  }
@@ -833,7 +941,9 @@ async function auditSingleTarget(target, graph, options) {
833
941
  const manifest = resolveArtifactContext(graph, {
834
942
  target: { type: target.type, id: target.id },
835
943
  mode: options.mode,
836
- maxPerCategory: options.maxPerCategory
944
+ maxPerCategory: options.maxPerCategory,
945
+ universalBaseline: options.universalBaseline,
946
+ root: options.root
837
947
  });
838
948
  const packet = assemblePacket(manifest, {
839
949
  mode: options.mode,
@@ -970,6 +1080,7 @@ async function discoverAndAuditPackets(root, options) {
970
1080
  type: d.type,
971
1081
  id: d.id
972
1082
  }));
1083
+ const effectiveBaseline = options.universalBaseline ?? config.context?.universal_baseline;
973
1084
  return auditPackets(root, targets, {
974
1085
  root,
975
1086
  outDir: options.outDir,
@@ -979,7 +1090,8 @@ async function discoverAndAuditPackets(root, options) {
979
1090
  summaryOnly: options.summaryOnly,
980
1091
  sampleTargets: options.sampleTargets,
981
1092
  summaryDetail: options.summaryDetail,
982
- schema: config
1093
+ schema: config,
1094
+ universalBaseline: effectiveBaseline
983
1095
  }, graph);
984
1096
  }
985
1097
 
@@ -1302,6 +1414,7 @@ function validatePacketPrompt(prompt) {
1302
1414
 
1303
1415
  // src/versioned-traceability.ts
1304
1416
  var import_node_crypto = require("crypto");
1417
+ var import_node_fs = require("fs");
1305
1418
  var import_promises2 = require("fs/promises");
1306
1419
  var import_node_path2 = require("path");
1307
1420
  var VERSION_LOCK_PATH = "artifacts/traceability-version-lock.json";
@@ -1342,13 +1455,15 @@ async function buildVersionIndex(root, graph) {
1342
1455
  edges: sortBy(edges, (edge2) => `${edge2.from} ${edge2.to} ${edge2.kind} ${edge2.sourcePath} ${edge2.sourceLine}`)
1343
1456
  };
1344
1457
  }
1345
- async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph) {
1458
+ async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph, config) {
1346
1459
  const index = await buildVersionIndex(root, graph);
1460
+ const schema = config ?? await loadConfig(root);
1347
1461
  const safeLockPath = normalizeRelativePath(root, lockPath);
1348
1462
  const lock = await readVersionLock(root, safeLockPath);
1349
1463
  const nodeByArtifact = new Map(index.nodes.map((node) => [`${node.type}:${node.id}`, node]));
1350
1464
  const nodeByPath = new Map(index.nodes.map((node) => [node.path, node]));
1351
1465
  const currentEdges = implementationEdges(index);
1466
+ const lockableEdges = await lockableImplementationEdges(root, index, schema);
1352
1467
  const currentEdgeIds = new Set(currentEdges.map((edge2) => edge2.edgeId));
1353
1468
  const issues = [];
1354
1469
  let fresh = 0;
@@ -1436,8 +1551,29 @@ async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph) {
1436
1551
  issues.push(...entryIssues);
1437
1552
  }
1438
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
+ }
1439
1575
  const reportedMissingLocks = /* @__PURE__ */ new Set();
1440
- for (const edge2 of currentEdges) {
1576
+ for (const edge2 of lockableEdges) {
1441
1577
  const edgeId = edge2.edgeId;
1442
1578
  if (!lock.locks.some((entry) => entry.edgeId === edgeId)) {
1443
1579
  if (reportedMissingLocks.has(edgeId)) {
@@ -1519,6 +1655,7 @@ async function updateVersionLock(root, options) {
1519
1655
  }
1520
1656
  async function bootstrapVersionLock(root, options = {}) {
1521
1657
  const index = await buildVersionIndex(root);
1658
+ const config = await loadConfig(root);
1522
1659
  const lockPath = normalizeRelativePath(root, options.lockPath ?? VERSION_LOCK_PATH);
1523
1660
  if (!options.force) {
1524
1661
  const existing = await readVersionLock(root, lockPath);
@@ -1528,7 +1665,7 @@ async function bootstrapVersionLock(root, options = {}) {
1528
1665
  }
1529
1666
  const nodeByUid = new Map(index.nodes.map((node) => [node.uid, node]));
1530
1667
  const entries = /* @__PURE__ */ new Map();
1531
- for (const edge2 of implementationEdges(index)) {
1668
+ for (const edge2 of await lockableImplementationEdges(root, index, config)) {
1532
1669
  const source = nodeByUid.get(edge2.from);
1533
1670
  const artifact = nodeByUid.get(edge2.to);
1534
1671
  if (!source || !artifact) {
@@ -1563,10 +1700,11 @@ async function refreshVersionLock(root, options = {}) {
1563
1700
  throw new Error("Changed-only version-lock refresh includes artifact-graph.config.yaml and requires --all");
1564
1701
  }
1565
1702
  const index = await buildVersionIndex(root);
1703
+ const config = await loadConfig(root);
1566
1704
  const lock = await readVersionLock(root, lockPath);
1567
1705
  const nodeByUid = new Map(index.nodes.map((node) => [node.uid, node]));
1568
1706
  const nodeByPath = new Map(index.nodes.map((node) => [node.path, node]));
1569
- const currentImplementationEdges = implementationEdges(index);
1707
+ const currentImplementationEdges = await lockableImplementationEdges(root, index, config);
1570
1708
  const currentEdgePairs = new Set(currentImplementationEdges.map((edge2) => `${edge2.from} ${edge2.to}`));
1571
1709
  const currentEntries = /* @__PURE__ */ new Map();
1572
1710
  const changedPathSet = new Set(changedPaths);
@@ -1641,7 +1779,7 @@ async function refreshVersionLock(root, options = {}) {
1641
1779
  locks: sortBy([...nextLocks.values()], (item) => item.edgeId)
1642
1780
  };
1643
1781
  await writeVersionLock(root, lockPath, next);
1644
- const postAudit = await auditVersionLock(root, lockPath);
1782
+ const postAudit = await auditVersionLock(root, lockPath, void 0, config);
1645
1783
  return {
1646
1784
  schemaVersion: "1.0",
1647
1785
  root,
@@ -1660,7 +1798,8 @@ async function refreshVersionLock(root, options = {}) {
1660
1798
  async function traceVersion(root, target, lockPath = VERSION_LOCK_PATH) {
1661
1799
  const index = await buildVersionIndex(root);
1662
1800
  const safeLockPath = normalizeRelativePath(root, lockPath);
1663
- const audit = await auditVersionLock(root, safeLockPath);
1801
+ const config = await loadConfig(root);
1802
+ const audit = await auditVersionLock(root, safeLockPath, void 0, config);
1664
1803
  const targetUid = parseTarget(target);
1665
1804
  const lock = await readVersionLock(root, safeLockPath);
1666
1805
  const targetNode = index.nodes.find((node) => node.uid === targetUid);
@@ -1896,6 +2035,28 @@ function implementationEdges(index) {
1896
2035
  };
1897
2036
  });
1898
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
+ }
1899
2060
  function lockRefFromNode(node) {
1900
2061
  return {
1901
2062
  type: node.type,
@@ -2008,13 +2169,60 @@ function normalizeRelativePath(root, path) {
2008
2169
  function sortBy(items, keyFn) {
2009
2170
  return [...items].sort((left, right) => keyFn(left).localeCompare(keyFn(right)));
2010
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
+ }
2011
2219
  function sortUnique(items) {
2012
2220
  return [...new Set(items)].sort((left, right) => left.localeCompare(right));
2013
2221
  }
2014
2222
 
2015
2223
  // src/cli-resolver.ts
2016
2224
  var import_node_child_process = require("child_process");
2017
- var import_node_fs = require("fs");
2225
+ var import_node_fs2 = require("fs");
2018
2226
  var import_promises3 = require("fs/promises");
2019
2227
  var import_node_path3 = require("path");
2020
2228
  var import_node_util = require("util");
@@ -2152,7 +2360,7 @@ ${maybe.stderr ?? ""}`;
2152
2360
  }
2153
2361
  async function pathExists(path) {
2154
2362
  try {
2155
- 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);
2156
2364
  return true;
2157
2365
  } catch {
2158
2366
  return false;
@@ -2251,7 +2459,7 @@ async function resolveGitHookPath(root, hookName) {
2251
2459
  }
2252
2460
 
2253
2461
  // src/hook-installer.ts
2254
- var import_node_fs2 = require("fs");
2462
+ var import_node_fs3 = require("fs");
2255
2463
  var import_node_crypto2 = require("crypto");
2256
2464
  var import_promises4 = require("fs/promises");
2257
2465
  var import_node_path5 = require("path");
@@ -2565,7 +2773,7 @@ async function readHookSnapshot(hookPath) {
2565
2773
  }
2566
2774
  let handle;
2567
2775
  try {
2568
- 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));
2569
2777
  const opened = await handle.stat({ bigint: true });
2570
2778
  if (opened.dev !== metadata.dev || opened.ino !== metadata.ino) {
2571
2779
  await handle.close();
@@ -2694,12 +2902,35 @@ var VALID_DECISIONS = /* @__PURE__ */ new Set([
2694
2902
  var VALID_SEVERITIES = /* @__PURE__ */ new Set(["block", "warn", "info"]);
2695
2903
  var VALID_FINDING_STATUSES = /* @__PURE__ */ new Set(["open", "resolved", "accepted", "superseded"]);
2696
2904
  var VALID_EXECUTORS = /* @__PURE__ */ new Set(["script", "worker", "agent", "manual", "cli"]);
2905
+ var TOP_LEVEL_FIELDS = /* @__PURE__ */ new Set([
2906
+ "schema_version",
2907
+ "run_id",
2908
+ "stage_id",
2909
+ "attempt",
2910
+ "status",
2911
+ "decision",
2912
+ "summary",
2913
+ "outputs",
2914
+ "warnings",
2915
+ "blocking_reason",
2916
+ "degradation",
2917
+ "producer",
2918
+ "acceptance",
2919
+ "evidence",
2920
+ "review",
2921
+ "repair"
2922
+ ]);
2697
2923
  function validateReviewResult(input) {
2698
2924
  const errors = [];
2699
2925
  if (!input || typeof input !== "object" || Array.isArray(input)) {
2700
2926
  return [{ path: "$", message: "Root must be a non-null object" }];
2701
2927
  }
2702
2928
  const obj = input;
2929
+ for (const key of Object.keys(obj)) {
2930
+ if (!TOP_LEVEL_FIELDS.has(key)) {
2931
+ errors.push({ path: `$.${key}`, message: "Unknown top-level property" });
2932
+ }
2933
+ }
2703
2934
  if (obj.schema_version !== "1.0") {
2704
2935
  errors.push({ path: "$.schema_version", message: `Must be "1.0", got ${JSON.stringify(obj.schema_version)}` });
2705
2936
  }
@@ -2718,8 +2949,8 @@ function validateReviewResult(input) {
2718
2949
  if (obj.stage_id !== void 0 && typeof obj.stage_id !== "string") {
2719
2950
  errors.push({ path: "$.stage_id", message: "Must be a string if present" });
2720
2951
  }
2721
- if (obj.attempt !== void 0 && (!Number.isInteger(obj.attempt) || obj.attempt < 1)) {
2722
- errors.push({ path: "$.attempt", message: "Must be a positive integer if present" });
2952
+ if (obj.attempt !== void 0 && (!Number.isInteger(obj.attempt) || obj.attempt < 1 || obj.attempt > 3)) {
2953
+ errors.push({ path: "$.attempt", message: "Must be an integer from 1 through 3 if present" });
2723
2954
  }
2724
2955
  if (obj.outputs !== void 0) {
2725
2956
  checkStringArray(obj.outputs, "$.outputs", errors);
@@ -2730,20 +2961,14 @@ function validateReviewResult(input) {
2730
2961
  checkOptionalNullableString(obj.blocking_reason, "$.blocking_reason", errors);
2731
2962
  checkOptionalNullableString(obj.degradation, "$.degradation", errors);
2732
2963
  if (obj.producer !== void 0) {
2733
- if (!isPlainObject(obj.producer)) {
2734
- errors.push({ path: "$.producer", message: "Must be an object" });
2735
- } else {
2736
- const p = obj.producer;
2737
- if (typeof p.executor !== "string") {
2738
- errors.push({ path: "$.producer.executor", message: "Must be a string" });
2739
- } else if (!VALID_EXECUTORS.has(p.executor)) {
2740
- errors.push({ path: "$.producer.executor", message: `Must be one of ${[...VALID_EXECUTORS].join(", ")}; got ${JSON.stringify(p.executor)}` });
2741
- }
2742
- if (typeof p.name !== "string") {
2743
- errors.push({ path: "$.producer.name", message: "Must be a string" });
2744
- }
2745
- checkOptionalString(p.skill, "$.producer.skill", errors);
2746
- }
2964
+ validateProducer(obj.producer, "$.producer", errors);
2965
+ }
2966
+ const successfulAcceptance = obj.status === "SUCCEEDED" && (obj.decision === "PASS" || obj.decision === "PASS_WITH_RESIDUAL_MINOR");
2967
+ if (successfulAcceptance && obj.producer === void 0) {
2968
+ errors.push({ path: "$.producer", message: "Successful acceptance requires producer identity" });
2969
+ }
2970
+ if (obj.acceptance !== void 0) {
2971
+ validateAcceptance(obj.acceptance, obj.producer, "$.acceptance", errors);
2747
2972
  }
2748
2973
  if (obj.evidence !== void 0) {
2749
2974
  if (!Array.isArray(obj.evidence)) {
@@ -2769,6 +2994,17 @@ function validateReviewResult(input) {
2769
2994
  }
2770
2995
  if (obj.review !== void 0) {
2771
2996
  validateReviewData(obj.review, "$.review", errors);
2997
+ if (obj.decision === "PASS" || obj.decision === "PASS_WITH_RESIDUAL_MINOR") {
2998
+ const findings = isPlainObject(obj.review) && Array.isArray(obj.review.findings) ? obj.review.findings : [];
2999
+ findings.forEach((finding, index) => {
3000
+ if (isPlainObject(finding) && finding.severity === "block" && (finding.status === void 0 || finding.status === "open")) {
3001
+ errors.push({
3002
+ path: `$.review.findings[${index}]`,
3003
+ message: `${obj.decision} cannot contain an open block finding`
3004
+ });
3005
+ }
3006
+ });
3007
+ }
2772
3008
  }
2773
3009
  if (obj.repair !== void 0) {
2774
3010
  if (!isPlainObject(obj.repair)) {
@@ -2804,6 +3040,47 @@ function checkStringArray(val, path, errors) {
2804
3040
  function isPlainObject(val) {
2805
3041
  return typeof val === "object" && val !== null && !Array.isArray(val);
2806
3042
  }
3043
+ function validateProducer(val, path, errors) {
3044
+ if (!isPlainObject(val)) {
3045
+ errors.push({ path, message: "Must be an object" });
3046
+ return;
3047
+ }
3048
+ if (typeof val.executor !== "string") {
3049
+ errors.push({ path: `${path}.executor`, message: "Must be a string" });
3050
+ } else if (!VALID_EXECUTORS.has(val.executor)) {
3051
+ errors.push({ path: `${path}.executor`, message: `Must be one of ${[...VALID_EXECUTORS].join(", ")}; got ${JSON.stringify(val.executor)}` });
3052
+ }
3053
+ if (typeof val.name !== "string" || val.name.length === 0) {
3054
+ errors.push({ path: `${path}.name`, message: "Must be a non-empty string" });
3055
+ }
3056
+ checkOptionalString(val.skill, `${path}.skill`, errors);
3057
+ }
3058
+ function producerIdentity(val) {
3059
+ return JSON.stringify([val.executor, val.name]);
3060
+ }
3061
+ function validateAcceptance(val, resultProducer, path, errors) {
3062
+ if (!isPlainObject(val)) {
3063
+ errors.push({ path, message: "Must be an object" });
3064
+ return;
3065
+ }
3066
+ validateProducer(val.reviewer, `${path}.reviewer`, errors);
3067
+ if (!isPlainObject(val.source_result)) {
3068
+ errors.push({ path: `${path}.source_result`, message: "Must be an object" });
3069
+ return;
3070
+ }
3071
+ const source = val.source_result;
3072
+ if (typeof source.run_id !== "string" || source.run_id.length === 0) {
3073
+ errors.push({ path: `${path}.source_result.run_id`, message: "Must be a non-empty string" });
3074
+ }
3075
+ checkOptionalString(source.stage_id, `${path}.source_result.stage_id`, errors);
3076
+ validateProducer(source.producer, `${path}.source_result.producer`, errors);
3077
+ if (isPlainObject(val.reviewer) && isPlainObject(resultProducer) && producerIdentity(val.reviewer) !== producerIdentity(resultProducer)) {
3078
+ errors.push({ path: `${path}.reviewer`, message: "Acceptance reviewer must match the result producer" });
3079
+ }
3080
+ if (isPlainObject(val.reviewer) && isPlainObject(source.producer) && producerIdentity(val.reviewer) === producerIdentity(source.producer)) {
3081
+ errors.push({ path: `${path}.reviewer`, message: "Repair producer cannot accept its own result" });
3082
+ }
3083
+ }
2807
3084
  function checkOptionalString(val, path, errors) {
2808
3085
  if (val !== void 0 && typeof val !== "string") {
2809
3086
  errors.push({ path, message: "Must be a string if present" });
@@ -2972,7 +3249,7 @@ var DEFAULT_SCHEMA = {
2972
3249
  scenario: { paths: ["artifacts/scenarios/**/*.md"], displayName: "\u573A\u666F\u5267\u672C", role: "scenario", layer: "scenario", aliases: ["scenarios", "scenario-script"] },
2973
3250
  design: { paths: ["artifacts/design/**/*.md"], displayName: "\u8BBE\u8BA1\u89C4\u683C", role: "design", layer: "design", aliases: ["design-spec", "design_docs"] },
2974
3251
  test: { paths: ["heimdall/packages/**/*.test.ts"], displayName: "\u4EE3\u7801\u6CE8\u91CA\u8FFD\u6EAF", role: "context", layer: "implementation", aliases: ["code-test", "code-trace", "unit-test"] },
2975
- e2e_test: { paths: ["artifacts/tests/e2e/*.md"], displayName: "E2E \u6D4B\u8BD5\u89C4\u683C", role: "e2e_test", layer: "verification", aliases: ["e2e-test", "e2e_tests"] },
3252
+ e2e_test: { paths: ["artifacts/tests/e2e/*.md"], displayName: "E2E \u6D4B\u8BD5\u89C4\u683C", role: "e2e_test", layer: "verification", aliases: ["e2e-test", "e2e_tests", "tc"] },
2976
3253
  e2e_registry: { paths: ["artifacts/tests/e2e/e2e-test-registry.json"], displayName: "E2E \u6D4B\u8BD5\u6CE8\u518C\u8868", role: "context", layer: "verification", aliases: ["e2e-registry"] },
2977
3254
  "rule-golden-cases": { paths: ["artifacts/tests/rule-golden-cases.md"], displayName: "\u89C4\u5219\u9EC4\u91D1\u6D4B\u8BD5\u7528\u4F8B", role: "context", layer: "verification", aliases: ["rule_golden_cases"] },
2978
3255
  "test-strategy": { paths: ["artifacts/design/test-strategy.md"], displayName: "\u6D4B\u8BD5\u7B56\u7565", role: "context", layer: "verification", aliases: ["test_strategy"] },
@@ -3002,7 +3279,12 @@ var DEFAULT_SCHEMA = {
3002
3279
  allowedEdges: [],
3003
3280
  forbiddenEdges: [{ from: "scenario", to: "entity", kind: "references" }],
3004
3281
  statuses: ["planned", "active", "done", "deprecated"],
3005
- idRanges: {}
3282
+ idRanges: {},
3283
+ e2e: {
3284
+ report_uncovered_scenarios: true,
3285
+ report_uncovered_features: true,
3286
+ runners: []
3287
+ }
3006
3288
  };
3007
3289
  async function loadConfig(root) {
3008
3290
  const configPath = (0, import_node_path6.join)(root, "artifact-graph.config.yaml");
@@ -3015,7 +3297,27 @@ async function loadConfig(root) {
3015
3297
  throw error;
3016
3298
  }
3017
3299
  }
3018
- return {
3300
+ const ub = parsed.context?.universal_baseline;
3301
+ if (ub !== void 0 && typeof ub !== "boolean") {
3302
+ throw new Error(
3303
+ `Invalid context.universal_baseline: ${JSON.stringify(ub)}. Must be boolean (true or false).`
3304
+ );
3305
+ }
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 = {
3019
3321
  ...DEFAULT_SCHEMA,
3020
3322
  ...parsed,
3021
3323
  types: mergeArtifactTypes(DEFAULT_SCHEMA.types, parsed.types),
@@ -3024,10 +3326,103 @@ async function loadConfig(root) {
3024
3326
  allowedEdges: parsed.allowedEdges ?? DEFAULT_SCHEMA.allowedEdges,
3025
3327
  forbiddenEdges: parsed.forbiddenEdges ?? DEFAULT_SCHEMA.forbiddenEdges,
3026
3328
  statuses: parsed.statuses ?? DEFAULT_SCHEMA.statuses,
3027
- idRanges: mergeRecord(DEFAULT_SCHEMA.idRanges, parsed.idRanges)
3329
+ idRanges: mergeRecord(DEFAULT_SCHEMA.idRanges, parsed.idRanges),
3330
+ e2e: mergedE2e
3028
3331
  };
3332
+ return merged;
3029
3333
  }
3030
- function buildGraph(nodes, edges, diagnostics = []) {
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
+ }
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
+ }
3424
+ }
3425
+ function buildGraph(nodes, edges, diagnostics = [], root) {
3031
3426
  const graphNodes = nodes.map((node) => ({ ...node, uid: toUid(node.type, node.code) }));
3032
3427
  graphNodes.sort(compareNode);
3033
3428
  edges.sort(compareEdge);
@@ -3044,6 +3439,7 @@ function buildGraph(nodes, edges, diagnostics = []) {
3044
3439
  nodes: graphNodes,
3045
3440
  edges: dedupedEdges,
3046
3441
  generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
3442
+ ...root ? { root } : {},
3047
3443
  diagnostics: diagnostics.sort((left, right) => left.code.localeCompare(right.code) || left.path.localeCompare(right.path) || left.line - right.line)
3048
3444
  };
3049
3445
  }
@@ -3075,7 +3471,8 @@ async function scanArtifacts(root, schema) {
3075
3471
  scanDiagnostics.push(...parsed.diagnostics);
3076
3472
  }
3077
3473
  }
3078
- const graph = buildGraph(nodes, edges, scanDiagnostics);
3474
+ const absoluteRoot = (0, import_node_path6.isAbsolute)(root) ? root : (0, import_node_path6.resolve)(root);
3475
+ const graph = buildGraph(nodes, edges, scanDiagnostics, absoluteRoot);
3079
3476
  return resolveMatrixEdges(graph);
3080
3477
  }
3081
3478
  function artifactTypeEntriesBySpecificity(schema) {
@@ -3415,23 +3812,34 @@ async function validateScenarioPrdLinkIndex(root, graph) {
3415
3812
  function validateCodeCommentTraceabilityFormat(graph) {
3416
3813
  const issues = [];
3417
3814
  for (const node of graph.nodes) {
3418
- if (node.type !== "test") {
3815
+ if (node.type !== "test" && node.type !== "implementation") {
3419
3816
  continue;
3420
3817
  }
3421
3818
  const invalidComments = node.attrs?.invalidTraceabilityComments;
3422
- if (!Array.isArray(invalidComments)) {
3423
- continue;
3819
+ if (Array.isArray(invalidComments)) {
3820
+ for (const invalid of invalidComments) {
3821
+ const line = typeof invalid?.line === "number" ? invalid.line : node.line;
3822
+ const reason = typeof invalid?.reason === "string" ? invalid.reason : "traceability tags must use standalone // comments";
3823
+ issues.push(issue(
3824
+ "CODE_COMMENT_TRACEABILITY_FORMAT",
3825
+ `${reason}: ${typeof invalid?.text === "string" ? invalid.text : ""}`.trim(),
3826
+ node.path,
3827
+ line,
3828
+ { node: node.uid }
3829
+ ));
3830
+ }
3424
3831
  }
3425
- for (const invalid of invalidComments) {
3426
- const line = typeof invalid?.line === "number" ? invalid.line : node.line;
3427
- const reason = typeof invalid?.reason === "string" ? invalid.reason : "traceability tags must use standalone // comments";
3428
- issues.push(issue(
3429
- "CODE_COMMENT_TRACEABILITY_FORMAT",
3430
- `${reason}: ${typeof invalid?.text === "string" ? invalid.text : ""}`.trim(),
3431
- node.path,
3432
- line,
3433
- { node: node.uid }
3434
- ));
3832
+ const deprecatedComments = node.attrs?.deprecatedTraceabilityComments;
3833
+ if (Array.isArray(deprecatedComments)) {
3834
+ for (const deprecated of deprecatedComments) {
3835
+ issues.push(issue(
3836
+ "E2E-TRACE-007",
3837
+ "@tc is deprecated; use @e2e_test instead",
3838
+ node.path,
3839
+ typeof deprecated?.line === "number" ? deprecated.line : node.line,
3840
+ { node: node.uid, severity: "warning" }
3841
+ ));
3842
+ }
3435
3843
  }
3436
3844
  }
3437
3845
  return issues;
@@ -3913,13 +4321,16 @@ function parseTest(path, raw, schema = DEFAULT_SCHEMA) {
3913
4321
  const isTest = isTestFile(path);
3914
4322
  const nodeType = isTest ? "test" : "implementation";
3915
4323
  const edgeKind = isTest ? "verifies" : "implements";
4324
+ const attrs = {};
4325
+ if (traceabilityComments.invalid.length > 0) attrs.invalidTraceabilityComments = traceabilityComments.invalid;
4326
+ if (traceabilityComments.deprecated.length > 0) attrs.deprecatedTraceabilityComments = traceabilityComments.deprecated;
3916
4327
  const node = {
3917
4328
  type: nodeType,
3918
4329
  code: path,
3919
4330
  title: path.split("/").at(-1) ?? path,
3920
4331
  path,
3921
4332
  line: 1,
3922
- attrs: traceabilityComments.invalid.length > 0 ? { invalidTraceabilityComments: traceabilityComments.invalid } : {}
4333
+ attrs
3923
4334
  };
3924
4335
  let hasTags = false;
3925
4336
  for (const { tags, lineNumber } of traceabilityComments.canonical) {
@@ -3930,7 +4341,7 @@ function parseTest(path, raw, schema = DEFAULT_SCHEMA) {
3930
4341
  }
3931
4342
  }
3932
4343
  }
3933
- if (hasTags || traceabilityComments.invalid.length > 0) {
4344
+ if (hasTags || traceabilityComments.invalid.length > 0 || traceabilityComments.deprecated.length > 0) {
3934
4345
  nodes.push(node);
3935
4346
  }
3936
4347
  return { nodes, edges };
@@ -3938,6 +4349,7 @@ function parseTest(path, raw, schema = DEFAULT_SCHEMA) {
3938
4349
  function scanTraceabilityComments(raw, schema = DEFAULT_SCHEMA) {
3939
4350
  const canonical = [];
3940
4351
  const invalid = [];
4352
+ const deprecated = [];
3941
4353
  for (const comment of scanCodeComments(raw)) {
3942
4354
  if (!containsTraceabilityTag(comment.text, schema)) {
3943
4355
  continue;
@@ -3953,6 +4365,9 @@ function scanTraceabilityComments(raw, schema = DEFAULT_SCHEMA) {
3953
4365
  const parsed = parseTraceabilityTagLine(comment.text.trim(), schema);
3954
4366
  if (parsed.valid) {
3955
4367
  canonical.push({ tags: parsed.tags, lineNumber: comment.lineNumber });
4368
+ if (/(?:^|\s)@tc(?=\s|$)/.test(comment.text.trim())) {
4369
+ deprecated.push({ line: comment.lineNumber, text: comment.text.trim() });
4370
+ }
3956
4371
  } else {
3957
4372
  invalid.push({ line: comment.lineNumber, text: comment.text.trim(), reason: parsed.reason });
3958
4373
  }
@@ -3968,11 +4383,14 @@ function scanTraceabilityComments(raw, schema = DEFAULT_SCHEMA) {
3968
4383
  const parsed = parseTraceabilityTagLine(comment.text.trim(), schema);
3969
4384
  if (parsed.valid) {
3970
4385
  canonical.push({ tags: parsed.tags, lineNumber: comment.lineNumber });
4386
+ if (/(?:^|\s)@tc(?=\s|$)/.test(comment.text.trim())) {
4387
+ deprecated.push({ line: comment.lineNumber, text: comment.text.trim() });
4388
+ }
3971
4389
  } else {
3972
4390
  invalid.push({ line: comment.lineNumber, text: comment.text.trim(), reason: parsed.reason });
3973
4391
  }
3974
4392
  }
3975
- return { canonical, invalid };
4393
+ return { canonical, invalid, deprecated };
3976
4394
  }
3977
4395
  function scanCodeComments(raw) {
3978
4396
  const comments = [];
@@ -4139,7 +4557,14 @@ function expandCodeRange(value) {
4139
4557
  return Array.from({ length: end - start + 1 }, (_, index) => `${prefix}${String(start + index).padStart(width, "0")}`);
4140
4558
  }
4141
4559
  function containsTraceabilityTag(value, schema = DEFAULT_SCHEMA) {
4142
- return /@[\w][\w-]*\b/.test(value);
4560
+ const tokens = /* @__PURE__ */ new Set();
4561
+ for (const [type, definition] of Object.entries(schema.types)) {
4562
+ tokens.add(type);
4563
+ for (const alias of definition.aliases ?? []) tokens.add(alias);
4564
+ }
4565
+ if (tokens.size === 0) return false;
4566
+ const alternatives = [...tokens].sort((a, b) => b.length - a.length).map((token) => token.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")).join("|");
4567
+ return new RegExp(`(?:^|\\s)@(?:${alternatives})(?=\\s|$)`).test(value);
4143
4568
  }
4144
4569
  function parseDesign(path, raw) {
4145
4570
  const parsed = (0, import_gray_matter.default)(raw);
@@ -5144,6 +5569,29 @@ function validateE2eTests(graph) {
5144
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" }));
5145
5570
  }
5146
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
+ }
5147
5595
  if (needsDesktopChainWarning(node)) {
5148
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" }));
5149
5597
  }
@@ -5202,10 +5650,10 @@ function validateE2eRegistry(graph) {
5202
5650
  }
5203
5651
  return issues;
5204
5652
  }
5205
- async function validateExecutableTraceability(root) {
5653
+ async function validateExecutableTraceability(root, config) {
5206
5654
  const issues = [];
5655
+ const schema = config ?? await loadConfig(root);
5207
5656
  const e2eDir = (0, import_node_path6.join)(root, "artifacts", "tests", "e2e");
5208
- const specPatterns = ["heimdall/**/*.spec.ts", "heimdall/**/*.e2e.spec.ts"];
5209
5657
  let e2eFiles;
5210
5658
  try {
5211
5659
  e2eFiles = (await (0, import_promises5.readdir)(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).map((name) => (0, import_node_path6.join)(e2eDir, name));
@@ -5248,16 +5696,23 @@ async function validateExecutableTraceability(root) {
5248
5696
  }
5249
5697
  const allFiles = await walk(root);
5250
5698
  const specFiles = /* @__PURE__ */ new Set();
5251
- for (const pattern of specPatterns) {
5699
+ const configuredRunners = schema.e2e?.runners ?? [];
5700
+ if (configuredRunners.length > 0) {
5252
5701
  for (const file of allFiles) {
5253
- 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)) {
5254
5709
  specFiles.add(file);
5255
5710
  }
5256
5711
  }
5257
5712
  }
5258
5713
  const refToSource = /* @__PURE__ */ new Map();
5259
- const tcAnnotationRegex = /\/\/!?\s*@tc\s+(\S+?)\s+\[(\w+)\]/;
5260
- const tcAnnotationNoLevelRegex = /\/\/!?\s*@tc\s+(\S+)/;
5714
+ const tcAnnotationRegex = /\/\/!?\s*@(?:e2e_test|tc)\s+(\S+?)\s+\[(\w+)\]/;
5715
+ const tcAnnotationNoLevelRegex = /\/\/!?\s*@(?:e2e_test|tc)\s+(\S+)/;
5261
5716
  for (const specFile of specFiles) {
5262
5717
  const fullSpecPath = (0, import_node_path6.join)(root, specFile);
5263
5718
  let content;
@@ -5321,12 +5776,21 @@ async function validateExecutableTraceability(root) {
5321
5776
  const refEntries = parseExecutableRefLines(ref);
5322
5777
  const validFiles = [];
5323
5778
  for (const entry of refEntries) {
5324
- const normalizedRefFile = entry.file.startsWith("heimdall/") ? entry.file : `heimdall/${entry.file}`;
5325
- const fileExists = specFiles.has(normalizedRefFile);
5779
+ const normalizedRefFile = resolveExecutableRefFile(entry.file, allFiles);
5780
+ const fileExists = normalizedRefFile !== void 0 && specFiles.has(normalizedRefFile);
5326
5781
  if (!fileExists) {
5327
5782
  issues.push(issue("E2E-TRACE-001", `executable_ref target file not found: ${entry.file}`, path, line, { node: tcKey, severity: "warning" }));
5328
5783
  continue;
5329
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
+ }
5330
5794
  if (entry.testId) {
5331
5795
  let content;
5332
5796
  try {
@@ -5341,12 +5805,9 @@ async function validateExecutableTraceability(root) {
5341
5805
  }
5342
5806
  }
5343
5807
  const annotationsForTc = refToSource.get(tcKey);
5344
- const hasAnnotationInFile = annotationsForTc?.some((ann) => {
5345
- const normalizedAnnFile = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
5346
- return normalizedAnnFile === normalizedRefFile;
5347
- }) ?? false;
5808
+ const hasAnnotationInFile = annotationsForTc?.some((ann) => ann.file === normalizedRefFile) ?? false;
5348
5809
  if (!hasAnnotationInFile) {
5349
- issues.push(issue("E2E-TRACE-003", `executable_ref target ${entry.file} has no // @tc ${tcKey} line-comment annotation`, path, line, { node: tcKey, severity: "warning" }));
5810
+ issues.push(issue("E2E-TRACE-003", `executable_ref target ${entry.file} has no E2E trace annotation ${tcKey}`, path, line, { node: tcKey, severity: "warning" }));
5350
5811
  continue;
5351
5812
  }
5352
5813
  validFiles.push(normalizedRefFile);
@@ -5357,13 +5818,13 @@ async function validateExecutableTraceability(root) {
5357
5818
  const batch = tcKey.split(":")[0];
5358
5819
  if (!mdBatches.has(batch)) {
5359
5820
  for (const ann of annotations) {
5360
- issues.push(issue("E2E-TRACE-002", `@tc annotation ${tcKey} references non-existent E2E batch "${batch}"`, ann.file, ann.line, { node: tcKey, severity: "warning" }));
5821
+ issues.push(issue("E2E-TRACE-002", `E2E trace annotation ${tcKey} references non-existent E2E batch "${batch}"`, ann.file, ann.line, { node: tcKey, severity: "warning" }));
5361
5822
  }
5362
5823
  continue;
5363
5824
  }
5364
5825
  if (!mdToRef.has(tcKey) && !await hasMarkdownTc(tcKey, e2eDir)) {
5365
5826
  for (const ann of annotations) {
5366
- issues.push(issue("E2E-TRACE-002", `@tc annotation ${tcKey} references non-existent Markdown TC`, ann.file, ann.line, { node: tcKey, severity: "warning" }));
5827
+ issues.push(issue("E2E-TRACE-002", `E2E trace annotation ${tcKey} references non-existent Markdown TC`, ann.file, ann.line, { node: tcKey, severity: "warning" }));
5367
5828
  }
5368
5829
  }
5369
5830
  }
@@ -5382,24 +5843,37 @@ async function validateExecutableTraceability(root) {
5382
5843
  if (matchesAnyRef) {
5383
5844
  continue;
5384
5845
  }
5385
- const primaryRef = refEntries[0];
5386
- const normalizedPrimary = primaryRef?.file.startsWith("heimdall/") ? primaryRef?.file : `heimdall/${primaryRef?.file ?? ""}`;
5387
5846
  const detail = `file: MD refs=[${refEntries.map((e) => e.file).join(", ")}] vs source=${ann.file}`;
5388
- issues.push(issue("E2E-TRACE-003", `executable_ref \u2194 @tc mismatch for ${tcKey}: ${detail}`, path, line, { node: tcKey, severity: "warning" }));
5847
+ issues.push(issue("E2E-TRACE-003", `executable_ref \u2194 E2E trace annotation mismatch for ${tcKey}: ${detail}`, path, line, { node: tcKey, severity: "warning" }));
5389
5848
  }
5390
5849
  }
5391
- for (const [tcKey, { chainType, path, line }] of mdToRef) {
5392
- 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") {
5393
5854
  continue;
5394
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;
5395
5864
  const validFiles = new Set(tcValidRefFiles.get(tcKey) ?? []);
5396
5865
  const sourceAnnotations = refToSource.get(tcKey);
5397
5866
  if (!sourceAnnotations) {
5398
5867
  continue;
5399
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
+ }
5400
5875
  for (const ann of sourceAnnotations) {
5401
- const normalizedAnnFile = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
5402
- if (!validFiles.has(normalizedAnnFile)) {
5876
+ if (!validFiles.has(ann.file)) {
5403
5877
  continue;
5404
5878
  }
5405
5879
  if (ann.level === "mock_playwright") {
@@ -5425,10 +5899,7 @@ async function validateExecutableTraceability(root) {
5425
5899
  continue;
5426
5900
  }
5427
5901
  const validFiles = new Set(tcValidRefFiles.get(tcKey) ?? []);
5428
- const sourceAnnotations = refToSource.get(tcKey)?.filter((ann) => {
5429
- const normalized = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
5430
- return validFiles.has(normalized);
5431
- });
5902
+ const sourceAnnotations = refToSource.get(tcKey)?.filter((ann) => validFiles.has(ann.file));
5432
5903
  const hasDesktopChain = sourceAnnotations?.some((ann) => ann.level === "desktop_chain") ?? false;
5433
5904
  const hasBridge = sourceAnnotations?.some((ann) => ann.level === "ui_sidecar_bridge") ?? false;
5434
5905
  if (isComplete) {
@@ -5437,7 +5908,7 @@ async function validateExecutableTraceability(root) {
5437
5908
  if (hasDesktopChain) {
5438
5909
  hasValidEvidence = true;
5439
5910
  } else if (hasBridge) {
5440
- const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root);
5911
+ const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root, allFiles);
5441
5912
  if (partialResult.hasValidPartialRust) {
5442
5913
  hasValidEvidence = true;
5443
5914
  } else {
@@ -5477,7 +5948,7 @@ async function validateExecutableTraceability(root) {
5477
5948
  }
5478
5949
  let partialDetail = "";
5479
5950
  if (hasBridge) {
5480
- const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root);
5951
+ const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root, allFiles);
5481
5952
  if (partialResult.hasValidPartialRust) {
5482
5953
  continue;
5483
5954
  }
@@ -5494,6 +5965,287 @@ async function validateExecutableTraceability(root) {
5494
5965
  }
5495
5966
  return issues;
5496
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
+ }
5497
6249
  function isPendingExecutableRef(ref) {
5498
6250
  const stripped = ref.replace(/^[\s-*()]+/, "").trim();
5499
6251
  return /^pending\b/i.test(stripped);
@@ -5511,6 +6263,20 @@ function parseExecutableRefLines(ref) {
5511
6263
  }
5512
6264
  return results;
5513
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
+ };
5514
6280
  function isDesktopChainType(chainType) {
5515
6281
  const normalizedChainType = chainType.trim().toLowerCase();
5516
6282
  return normalizedChainType === "desktop_chain" || normalizedChainType === "";
@@ -5518,7 +6284,7 @@ function isDesktopChainType(chainType) {
5518
6284
  function parseChainCoverageStatus(chainCoverage) {
5519
6285
  return chainCoverage.trim().toLowerCase().match(/^[a-z_]+/)?.[0] ?? "";
5520
6286
  }
5521
- async function validatePartialRustEvidence(tcFields, tcKey, root) {
6287
+ async function validatePartialRustEvidence(tcFields, tcKey, root, allFiles) {
5522
6288
  const partialEvidence = String(tcFields["partial_evidence"] ?? "");
5523
6289
  if (!partialEvidence.trim()) {
5524
6290
  return { hasValidPartialRust: false, detail: "no partial_evidence field" };
@@ -5542,7 +6308,10 @@ async function validatePartialRustEvidence(tcFields, tcKey, root) {
5542
6308
  return { hasValidPartialRust: false, detail: "no .rs file in partial_evidence" };
5543
6309
  }
5544
6310
  for (const ref of rustRefs) {
5545
- 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
+ }
5546
6315
  const fullPath = (0, import_node_path6.join)(root, normalizedPath);
5547
6316
  let content;
5548
6317
  try {
@@ -5551,14 +6320,14 @@ async function validatePartialRustEvidence(tcFields, tcKey, root) {
5551
6320
  return { hasValidPartialRust: false, detail: `partial_rust file not found: ${ref.file}` };
5552
6321
  }
5553
6322
  const tcAnnotationPattern = new RegExp(
5554
- `//[/!]?\\s*@tc\\s+${escapeRegExp(tcKey)}\\s+\\[partial_rust\\]`
6323
+ `//[/!]?\\s*@(?:e2e_test|tc)\\s+${escapeRegExp(tcKey)}\\s+\\[partial_rust\\]`
5555
6324
  );
5556
6325
  if (!tcAnnotationPattern.test(content)) {
5557
- const noLevelPattern = new RegExp(`//[/!]?\\s*@tc\\s+${escapeRegExp(tcKey)}\\b`);
6326
+ const noLevelPattern = new RegExp(`//[/!]?\\s*@(?:e2e_test|tc)\\s+${escapeRegExp(tcKey)}\\b`);
5558
6327
  if (noLevelPattern.test(content)) {
5559
- return { hasValidPartialRust: false, detail: `partial_rust file ${ref.file} has @tc ${tcKey} but not tagged [partial_rust]` };
6328
+ return { hasValidPartialRust: false, detail: `partial_rust file ${ref.file} has E2E trace annotation ${tcKey} but not tagged [partial_rust]` };
5560
6329
  }
5561
- return { hasValidPartialRust: false, detail: `partial_rust file ${ref.file} has no @tc ${tcKey} annotation` };
6330
+ return { hasValidPartialRust: false, detail: `partial_rust file ${ref.file} has no E2E trace annotation ${tcKey}` };
5562
6331
  }
5563
6332
  }
5564
6333
  return { hasValidPartialRust: true, detail: "ok" };
@@ -5578,6 +6347,46 @@ function detectTestLevel(specFile, content) {
5578
6347
  }
5579
6348
  return "desktop_chain";
5580
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
+ }
5581
6390
  function splitMarkdownCells(line) {
5582
6391
  const cells = [];
5583
6392
  let current = "";
@@ -5731,7 +6540,7 @@ function flattenAcCoverage(value) {
5731
6540
  function needsDesktopChainWarning(node) {
5732
6541
  const tcFields = asRecord(node.attrs?.tcFields);
5733
6542
  const chainType = String(tcFields["chain_type"] ?? "").trim().toLowerCase();
5734
- if (chainType === "frontend_only" || chainType === "core_only") {
6543
+ if (chainType && VALID_CHAIN_TYPES.has(chainType) && chainType !== "desktop_chain" || chainType in DEPRECATED_CHAIN_TYPE_ALIASES) {
5735
6544
  return false;
5736
6545
  }
5737
6546
  const chainCoverage = String(tcFields["chain_coverage"] ?? "").trim().toLowerCase();
@@ -5885,9 +6694,14 @@ function mergeRecord(base, override) {
5885
6694
  function mergeArtifactTypes(base, override) {
5886
6695
  const result = { ...base };
5887
6696
  for (const [type, definition] of Object.entries(override ?? {})) {
6697
+ const aliases = [
6698
+ ...base[type]?.aliases ?? [],
6699
+ ...definition.aliases ?? []
6700
+ ].filter((alias, index, all) => all.indexOf(alias) === index);
5888
6701
  result[type] = {
5889
6702
  ...base[type] ?? {},
5890
- ...definition
6703
+ ...definition,
6704
+ ...aliases.length > 0 ? { aliases } : {}
5891
6705
  };
5892
6706
  }
5893
6707
  return result;
@@ -5981,6 +6795,8 @@ var TIER_ORDER = ["baseline", "target", "direct", "matrix", "transitive"];
5981
6795
  function resolveArtifactContext(graph, opts) {
5982
6796
  const mode = opts.mode ?? "full";
5983
6797
  const maxPerCategory = opts.maxPerCategory ?? 20;
6798
+ const universalBaseline = opts.universalBaseline ?? true;
6799
+ const root = opts.root ?? graph.root;
5984
6800
  const legacyCount = [opts.feature, opts.scenario, opts.decision, opts.design, opts.e2e_test].filter(Boolean).length;
5985
6801
  if (opts.target && legacyCount > 0) {
5986
6802
  return {
@@ -6133,12 +6949,80 @@ function resolveArtifactContext(graph, opts) {
6133
6949
  return "direct";
6134
6950
  }
6135
6951
  const pathMap = /* @__PURE__ */ new Map();
6136
- for (const ap of ALWAYS_PRESENT_ITEMS) {
6137
- const existing = pathMap.get(ap.path);
6138
- if (existing) {
6139
- if (!existing.reasons.includes(ap.reason)) existing.reasons.push(ap.reason);
6952
+ if (universalBaseline) {
6953
+ for (const ap of ALWAYS_PRESENT_ITEMS) {
6954
+ const existing = pathMap.get(ap.path);
6955
+ if (existing) {
6956
+ if (!existing.reasons.includes(ap.reason)) existing.reasons.push(ap.reason);
6957
+ } else {
6958
+ pathMap.set(ap.path, { path: ap.path, reasons: [ap.reason], category: "baseline", required: true, tier: "baseline" });
6959
+ }
6960
+ }
6961
+ if (root) {
6962
+ for (const ap of ALWAYS_PRESENT_ITEMS) {
6963
+ const fullPath = (0, import_node_path6.join)(root, ap.path);
6964
+ let stat;
6965
+ try {
6966
+ stat = (0, import_node_fs4.statSync)(fullPath);
6967
+ } catch {
6968
+ stat = null;
6969
+ }
6970
+ if (!stat) {
6971
+ const msg = `Required baseline artifact not found: ${ap.path}`;
6972
+ if (!missing.includes(msg)) {
6973
+ missing.push(msg);
6974
+ missingDetails.push({
6975
+ ref: ap.path,
6976
+ from: "baseline",
6977
+ kind: "missing-baseline",
6978
+ message: msg,
6979
+ suggestedAction: `\u521B\u5EFA\u6587\u4EF6 ${ap.path} \u6216\u914D\u7F6E\u8DF3\u8FC7 universal baseline`
6980
+ });
6981
+ }
6982
+ } else if (!stat.isFile()) {
6983
+ const msg = `Required baseline artifact is not a regular file: ${ap.path}`;
6984
+ if (!missing.includes(msg)) {
6985
+ missing.push(msg);
6986
+ missingDetails.push({
6987
+ ref: ap.path,
6988
+ from: "baseline",
6989
+ kind: "missing-baseline",
6990
+ message: msg,
6991
+ suggestedAction: `\u5C06 ${ap.path} \u4ECE\u76EE\u5F55\u6539\u4E3A\u6587\u4EF6\uFF0C\u6216\u914D\u7F6E\u8DF3\u8FC7 universal baseline`
6992
+ });
6993
+ }
6994
+ } else {
6995
+ try {
6996
+ (0, import_node_fs4.accessSync)(fullPath, import_node_fs4.constants.R_OK);
6997
+ } catch {
6998
+ const msg = `Required baseline artifact is not readable: ${ap.path}`;
6999
+ if (!missing.includes(msg)) {
7000
+ missing.push(msg);
7001
+ missingDetails.push({
7002
+ ref: ap.path,
7003
+ from: "baseline",
7004
+ kind: "missing-baseline",
7005
+ message: msg,
7006
+ suggestedAction: `\u4FEE\u590D ${ap.path} \u7684\u6587\u4EF6\u6743\u9650\uFF0C\u6216\u914D\u7F6E\u8DF3\u8FC7 universal baseline`
7007
+ });
7008
+ }
7009
+ }
7010
+ }
7011
+ }
6140
7012
  } else {
6141
- pathMap.set(ap.path, { path: ap.path, reasons: [ap.reason], category: "baseline", required: true, tier: "baseline" });
7013
+ for (const ap of ALWAYS_PRESENT_ITEMS) {
7014
+ const msg = `Cannot verify baseline without root: ${ap.path}`;
7015
+ if (!missing.includes(msg)) {
7016
+ missing.push(msg);
7017
+ missingDetails.push({
7018
+ ref: ap.path,
7019
+ from: "baseline",
7020
+ kind: "missing-baseline",
7021
+ message: msg,
7022
+ suggestedAction: `\u4F20\u9012 root \u53C2\u6570\u6216\u914D\u7F6E\u8DF3\u8FC7 universal baseline`
7023
+ });
7024
+ }
7025
+ }
6142
7026
  }
6143
7027
  }
6144
7028
  pathMap.set(targetNode.path, {
@@ -6239,7 +7123,8 @@ function resolveArtifactContext(graph, opts) {
6239
7123
  context,
6240
7124
  missing,
6241
7125
  missingDetails,
6242
- omitted
7126
+ omitted,
7127
+ baselinePolicy: universalBaseline
6243
7128
  };
6244
7129
  }
6245
7130
  function formatContextMarkdown(manifest) {
@@ -6327,12 +7212,14 @@ function formatContextMarkdown(manifest) {
6327
7212
  buildGraph,
6328
7213
  buildVersionIndex,
6329
7214
  collectChangedPaths,
7215
+ computeE2eCoverageStats,
6330
7216
  dirname,
6331
7217
  discoverAndAuditPackets,
6332
7218
  discoverTargets,
6333
7219
  doctorArtifactChain,
6334
7220
  extname,
6335
7221
  formatContextMarkdown,
7222
+ generateE2eRegistry,
6336
7223
  getArtifactTypeMetadata,
6337
7224
  getTargetArtifactTypes,
6338
7225
  installManagedHookBlock,