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.js CHANGED
@@ -2,8 +2,9 @@
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, existsSync as existsSync2, statSync } from "fs";
5
6
  import { mkdir as mkdir4, readFile as readFile2, readdir, writeFile as writeFile3 } from "fs/promises";
6
- import { basename as basename2, dirname as dirname3, extname, join as join5, relative as relative2 } from "path";
7
+ import { basename as basename2, dirname as dirname3, extname, isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve as resolve3 } from "path";
7
8
 
8
9
  // src/packet-constants.ts
9
10
  var ALWAYS_PRESENT_ITEMS = [
@@ -82,13 +83,76 @@ function validatePacket(packet, schema) {
82
83
  path: "target.id"
83
84
  });
84
85
  }
85
- if (packet.requiredBaseline.total !== BASELINE_ITEMS_COUNT) {
86
- issues.push({
87
- severity: "error",
88
- code: "PKT-004",
89
- message: `requiredBaseline.total must be ${BASELINE_ITEMS_COUNT}, got ${packet.requiredBaseline.total}`,
90
- path: "requiredBaseline.total"
91
- });
86
+ const isBaselineExplicitlyDisabled = packet.baselinePolicy === false;
87
+ const expectedPaths = new Set(ALWAYS_PRESENT_ITEMS.map((item) => item.path));
88
+ if (isBaselineExplicitlyDisabled) {
89
+ if (packet.requiredBaseline.total !== 0) {
90
+ issues.push({
91
+ severity: "error",
92
+ code: "PKT-004",
93
+ message: `baselinePolicy=false requires requiredBaseline.total=0, got ${packet.requiredBaseline.total}`,
94
+ path: "requiredBaseline.total"
95
+ });
96
+ }
97
+ if (packet.requiredBaseline.items.length !== 0) {
98
+ issues.push({
99
+ severity: "error",
100
+ code: "PKT-004",
101
+ message: `baselinePolicy=false requires requiredBaseline.items=[], got ${packet.requiredBaseline.items.length} item(s)`,
102
+ path: "requiredBaseline.items"
103
+ });
104
+ }
105
+ } else {
106
+ if (packet.requiredBaseline.total !== BASELINE_ITEMS_COUNT) {
107
+ issues.push({
108
+ severity: "error",
109
+ code: "PKT-004",
110
+ message: `requiredBaseline.total must be ${BASELINE_ITEMS_COUNT}, got ${packet.requiredBaseline.total}`,
111
+ path: "requiredBaseline.total"
112
+ });
113
+ }
114
+ if (packet.requiredBaseline.items.length !== BASELINE_ITEMS_COUNT) {
115
+ issues.push({
116
+ severity: "error",
117
+ code: "PKT-004",
118
+ message: `requiredBaseline.items.length must be ${BASELINE_ITEMS_COUNT}, got ${packet.requiredBaseline.items.length}`,
119
+ path: "requiredBaseline.items"
120
+ });
121
+ }
122
+ const actualPaths = packet.requiredBaseline.items.map((item) => item.path);
123
+ const actualPathSet = new Set(actualPaths);
124
+ if (actualPathSet.size !== actualPaths.length) {
125
+ issues.push({
126
+ severity: "error",
127
+ code: "PKT-004",
128
+ message: `requiredBaseline.items contains duplicate paths (${actualPaths.length} items, ${actualPathSet.size} unique)`,
129
+ path: "requiredBaseline.items"
130
+ });
131
+ }
132
+ const missingPaths = [];
133
+ for (const ep of expectedPaths) {
134
+ if (!actualPathSet.has(ep)) missingPaths.push(ep);
135
+ }
136
+ if (missingPaths.length > 0) {
137
+ issues.push({
138
+ severity: "error",
139
+ code: "PKT-004",
140
+ message: `requiredBaseline.items missing expected path(s): ${missingPaths.join(", ")}`,
141
+ path: "requiredBaseline.items"
142
+ });
143
+ }
144
+ const extraPaths = [];
145
+ for (const ap of actualPathSet) {
146
+ if (!expectedPaths.has(ap)) extraPaths.push(ap);
147
+ }
148
+ if (extraPaths.length > 0) {
149
+ issues.push({
150
+ severity: "error",
151
+ code: "PKT-004",
152
+ message: `requiredBaseline.items contains unexpected path(s): ${extraPaths.join(", ")}`,
153
+ path: "requiredBaseline.items"
154
+ });
155
+ }
92
156
  }
93
157
  const constraints = packet.implementationBlueprintDraft.constraints;
94
158
  if (constraints.length !== BASELINE_CONSTRAINTS_COUNT) {
@@ -195,6 +259,45 @@ function validatePacketMarkdown(markdown) {
195
259
  return { ok: !hasError, issues };
196
260
  }
197
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
+
198
301
  // src/target-selector.ts
199
302
  function parseTargetSelector(value) {
200
303
  const separator = value.indexOf(":");
@@ -515,7 +618,10 @@ function assemblePacket(manifest, options) {
515
618
  missing: [...manifest.missing],
516
619
  missingDetails: manifest.missingDetails ? [...manifest.missingDetails] : void 0,
517
620
  implementationBlueprintDraft: blueprintDraft,
518
- validationCommands
621
+ validationCommands,
622
+ // @feature ACA17
623
+ // @decision D-ACA-17
624
+ baselinePolicy: manifest.baselinePolicy
519
625
  };
520
626
  return packet;
521
627
  }
@@ -736,7 +842,9 @@ async function auditSingleTarget(target, graph, options) {
736
842
  const manifest = resolveArtifactContext(graph, {
737
843
  target: { type: target.type, id: target.id },
738
844
  mode: options.mode,
739
- maxPerCategory: options.maxPerCategory
845
+ maxPerCategory: options.maxPerCategory,
846
+ universalBaseline: options.universalBaseline,
847
+ root: options.root
740
848
  });
741
849
  const packet = assemblePacket(manifest, {
742
850
  mode: options.mode,
@@ -873,6 +981,7 @@ async function discoverAndAuditPackets(root, options) {
873
981
  type: d.type,
874
982
  id: d.id
875
983
  }));
984
+ const effectiveBaseline = options.universalBaseline ?? config.context?.universal_baseline;
876
985
  return auditPackets(root, targets, {
877
986
  root,
878
987
  outDir: options.outDir,
@@ -882,7 +991,8 @@ async function discoverAndAuditPackets(root, options) {
882
991
  summaryOnly: options.summaryOnly,
883
992
  sampleTargets: options.sampleTargets,
884
993
  summaryDetail: options.summaryDetail,
885
- schema: config
994
+ schema: config,
995
+ universalBaseline: effectiveBaseline
886
996
  }, graph);
887
997
  }
888
998
 
@@ -1205,6 +1315,7 @@ function validatePacketPrompt(prompt) {
1205
1315
 
1206
1316
  // src/versioned-traceability.ts
1207
1317
  import { createHash } from "crypto";
1318
+ import { existsSync } from "fs";
1208
1319
  import { mkdir as mkdir2, readFile, writeFile as writeFile2 } from "fs/promises";
1209
1320
  import { dirname, join as join2, relative } from "path";
1210
1321
  var VERSION_LOCK_PATH = "artifacts/traceability-version-lock.json";
@@ -1245,13 +1356,15 @@ async function buildVersionIndex(root, graph) {
1245
1356
  edges: sortBy(edges, (edge2) => `${edge2.from} ${edge2.to} ${edge2.kind} ${edge2.sourcePath} ${edge2.sourceLine}`)
1246
1357
  };
1247
1358
  }
1248
- async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph) {
1359
+ async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph, config) {
1249
1360
  const index = await buildVersionIndex(root, graph);
1361
+ const schema = config ?? await loadConfig(root);
1250
1362
  const safeLockPath = normalizeRelativePath(root, lockPath);
1251
1363
  const lock = await readVersionLock(root, safeLockPath);
1252
1364
  const nodeByArtifact = new Map(index.nodes.map((node) => [`${node.type}:${node.id}`, node]));
1253
1365
  const nodeByPath = new Map(index.nodes.map((node) => [node.path, node]));
1254
1366
  const currentEdges = implementationEdges(index);
1367
+ const lockableEdges = await lockableImplementationEdges(root, index, schema);
1255
1368
  const currentEdgeIds = new Set(currentEdges.map((edge2) => edge2.edgeId));
1256
1369
  const issues = [];
1257
1370
  let fresh = 0;
@@ -1339,8 +1452,29 @@ async function auditVersionLock(root, lockPath = VERSION_LOCK_PATH, graph) {
1339
1452
  issues.push(...entryIssues);
1340
1453
  }
1341
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
+ }
1342
1476
  const reportedMissingLocks = /* @__PURE__ */ new Set();
1343
- for (const edge2 of currentEdges) {
1477
+ for (const edge2 of lockableEdges) {
1344
1478
  const edgeId = edge2.edgeId;
1345
1479
  if (!lock.locks.some((entry) => entry.edgeId === edgeId)) {
1346
1480
  if (reportedMissingLocks.has(edgeId)) {
@@ -1422,6 +1556,7 @@ async function updateVersionLock(root, options) {
1422
1556
  }
1423
1557
  async function bootstrapVersionLock(root, options = {}) {
1424
1558
  const index = await buildVersionIndex(root);
1559
+ const config = await loadConfig(root);
1425
1560
  const lockPath = normalizeRelativePath(root, options.lockPath ?? VERSION_LOCK_PATH);
1426
1561
  if (!options.force) {
1427
1562
  const existing = await readVersionLock(root, lockPath);
@@ -1431,7 +1566,7 @@ async function bootstrapVersionLock(root, options = {}) {
1431
1566
  }
1432
1567
  const nodeByUid = new Map(index.nodes.map((node) => [node.uid, node]));
1433
1568
  const entries = /* @__PURE__ */ new Map();
1434
- for (const edge2 of implementationEdges(index)) {
1569
+ for (const edge2 of await lockableImplementationEdges(root, index, config)) {
1435
1570
  const source = nodeByUid.get(edge2.from);
1436
1571
  const artifact = nodeByUid.get(edge2.to);
1437
1572
  if (!source || !artifact) {
@@ -1466,10 +1601,11 @@ async function refreshVersionLock(root, options = {}) {
1466
1601
  throw new Error("Changed-only version-lock refresh includes artifact-graph.config.yaml and requires --all");
1467
1602
  }
1468
1603
  const index = await buildVersionIndex(root);
1604
+ const config = await loadConfig(root);
1469
1605
  const lock = await readVersionLock(root, lockPath);
1470
1606
  const nodeByUid = new Map(index.nodes.map((node) => [node.uid, node]));
1471
1607
  const nodeByPath = new Map(index.nodes.map((node) => [node.path, node]));
1472
- const currentImplementationEdges = implementationEdges(index);
1608
+ const currentImplementationEdges = await lockableImplementationEdges(root, index, config);
1473
1609
  const currentEdgePairs = new Set(currentImplementationEdges.map((edge2) => `${edge2.from} ${edge2.to}`));
1474
1610
  const currentEntries = /* @__PURE__ */ new Map();
1475
1611
  const changedPathSet = new Set(changedPaths);
@@ -1544,7 +1680,7 @@ async function refreshVersionLock(root, options = {}) {
1544
1680
  locks: sortBy([...nextLocks.values()], (item) => item.edgeId)
1545
1681
  };
1546
1682
  await writeVersionLock(root, lockPath, next);
1547
- const postAudit = await auditVersionLock(root, lockPath);
1683
+ const postAudit = await auditVersionLock(root, lockPath, void 0, config);
1548
1684
  return {
1549
1685
  schemaVersion: "1.0",
1550
1686
  root,
@@ -1563,7 +1699,8 @@ async function refreshVersionLock(root, options = {}) {
1563
1699
  async function traceVersion(root, target, lockPath = VERSION_LOCK_PATH) {
1564
1700
  const index = await buildVersionIndex(root);
1565
1701
  const safeLockPath = normalizeRelativePath(root, lockPath);
1566
- const audit = await auditVersionLock(root, safeLockPath);
1702
+ const config = await loadConfig(root);
1703
+ const audit = await auditVersionLock(root, safeLockPath, void 0, config);
1567
1704
  const targetUid = parseTarget(target);
1568
1705
  const lock = await readVersionLock(root, safeLockPath);
1569
1706
  const targetNode = index.nodes.find((node) => node.uid === targetUid);
@@ -1799,6 +1936,28 @@ function implementationEdges(index) {
1799
1936
  };
1800
1937
  });
1801
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
+ }
1802
1961
  function lockRefFromNode(node) {
1803
1962
  return {
1804
1963
  type: node.type,
@@ -1911,6 +2070,53 @@ function normalizeRelativePath(root, path) {
1911
2070
  function sortBy(items, keyFn) {
1912
2071
  return [...items].sort((left, right) => keyFn(left).localeCompare(keyFn(right)));
1913
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
+ }
1914
2120
  function sortUnique(items) {
1915
2121
  return [...new Set(items)].sort((left, right) => left.localeCompare(right));
1916
2122
  }
@@ -2597,12 +2803,35 @@ var VALID_DECISIONS = /* @__PURE__ */ new Set([
2597
2803
  var VALID_SEVERITIES = /* @__PURE__ */ new Set(["block", "warn", "info"]);
2598
2804
  var VALID_FINDING_STATUSES = /* @__PURE__ */ new Set(["open", "resolved", "accepted", "superseded"]);
2599
2805
  var VALID_EXECUTORS = /* @__PURE__ */ new Set(["script", "worker", "agent", "manual", "cli"]);
2806
+ var TOP_LEVEL_FIELDS = /* @__PURE__ */ new Set([
2807
+ "schema_version",
2808
+ "run_id",
2809
+ "stage_id",
2810
+ "attempt",
2811
+ "status",
2812
+ "decision",
2813
+ "summary",
2814
+ "outputs",
2815
+ "warnings",
2816
+ "blocking_reason",
2817
+ "degradation",
2818
+ "producer",
2819
+ "acceptance",
2820
+ "evidence",
2821
+ "review",
2822
+ "repair"
2823
+ ]);
2600
2824
  function validateReviewResult(input) {
2601
2825
  const errors = [];
2602
2826
  if (!input || typeof input !== "object" || Array.isArray(input)) {
2603
2827
  return [{ path: "$", message: "Root must be a non-null object" }];
2604
2828
  }
2605
2829
  const obj = input;
2830
+ for (const key of Object.keys(obj)) {
2831
+ if (!TOP_LEVEL_FIELDS.has(key)) {
2832
+ errors.push({ path: `$.${key}`, message: "Unknown top-level property" });
2833
+ }
2834
+ }
2606
2835
  if (obj.schema_version !== "1.0") {
2607
2836
  errors.push({ path: "$.schema_version", message: `Must be "1.0", got ${JSON.stringify(obj.schema_version)}` });
2608
2837
  }
@@ -2621,8 +2850,8 @@ function validateReviewResult(input) {
2621
2850
  if (obj.stage_id !== void 0 && typeof obj.stage_id !== "string") {
2622
2851
  errors.push({ path: "$.stage_id", message: "Must be a string if present" });
2623
2852
  }
2624
- if (obj.attempt !== void 0 && (!Number.isInteger(obj.attempt) || obj.attempt < 1)) {
2625
- errors.push({ path: "$.attempt", message: "Must be a positive integer if present" });
2853
+ if (obj.attempt !== void 0 && (!Number.isInteger(obj.attempt) || obj.attempt < 1 || obj.attempt > 3)) {
2854
+ errors.push({ path: "$.attempt", message: "Must be an integer from 1 through 3 if present" });
2626
2855
  }
2627
2856
  if (obj.outputs !== void 0) {
2628
2857
  checkStringArray(obj.outputs, "$.outputs", errors);
@@ -2633,20 +2862,14 @@ function validateReviewResult(input) {
2633
2862
  checkOptionalNullableString(obj.blocking_reason, "$.blocking_reason", errors);
2634
2863
  checkOptionalNullableString(obj.degradation, "$.degradation", errors);
2635
2864
  if (obj.producer !== void 0) {
2636
- if (!isPlainObject(obj.producer)) {
2637
- errors.push({ path: "$.producer", message: "Must be an object" });
2638
- } else {
2639
- const p = obj.producer;
2640
- if (typeof p.executor !== "string") {
2641
- errors.push({ path: "$.producer.executor", message: "Must be a string" });
2642
- } else if (!VALID_EXECUTORS.has(p.executor)) {
2643
- errors.push({ path: "$.producer.executor", message: `Must be one of ${[...VALID_EXECUTORS].join(", ")}; got ${JSON.stringify(p.executor)}` });
2644
- }
2645
- if (typeof p.name !== "string") {
2646
- errors.push({ path: "$.producer.name", message: "Must be a string" });
2647
- }
2648
- checkOptionalString(p.skill, "$.producer.skill", errors);
2649
- }
2865
+ validateProducer(obj.producer, "$.producer", errors);
2866
+ }
2867
+ const successfulAcceptance = obj.status === "SUCCEEDED" && (obj.decision === "PASS" || obj.decision === "PASS_WITH_RESIDUAL_MINOR");
2868
+ if (successfulAcceptance && obj.producer === void 0) {
2869
+ errors.push({ path: "$.producer", message: "Successful acceptance requires producer identity" });
2870
+ }
2871
+ if (obj.acceptance !== void 0) {
2872
+ validateAcceptance(obj.acceptance, obj.producer, "$.acceptance", errors);
2650
2873
  }
2651
2874
  if (obj.evidence !== void 0) {
2652
2875
  if (!Array.isArray(obj.evidence)) {
@@ -2672,6 +2895,17 @@ function validateReviewResult(input) {
2672
2895
  }
2673
2896
  if (obj.review !== void 0) {
2674
2897
  validateReviewData(obj.review, "$.review", errors);
2898
+ if (obj.decision === "PASS" || obj.decision === "PASS_WITH_RESIDUAL_MINOR") {
2899
+ const findings = isPlainObject(obj.review) && Array.isArray(obj.review.findings) ? obj.review.findings : [];
2900
+ findings.forEach((finding, index) => {
2901
+ if (isPlainObject(finding) && finding.severity === "block" && (finding.status === void 0 || finding.status === "open")) {
2902
+ errors.push({
2903
+ path: `$.review.findings[${index}]`,
2904
+ message: `${obj.decision} cannot contain an open block finding`
2905
+ });
2906
+ }
2907
+ });
2908
+ }
2675
2909
  }
2676
2910
  if (obj.repair !== void 0) {
2677
2911
  if (!isPlainObject(obj.repair)) {
@@ -2707,6 +2941,47 @@ function checkStringArray(val, path, errors) {
2707
2941
  function isPlainObject(val) {
2708
2942
  return typeof val === "object" && val !== null && !Array.isArray(val);
2709
2943
  }
2944
+ function validateProducer(val, path, errors) {
2945
+ if (!isPlainObject(val)) {
2946
+ errors.push({ path, message: "Must be an object" });
2947
+ return;
2948
+ }
2949
+ if (typeof val.executor !== "string") {
2950
+ errors.push({ path: `${path}.executor`, message: "Must be a string" });
2951
+ } else if (!VALID_EXECUTORS.has(val.executor)) {
2952
+ errors.push({ path: `${path}.executor`, message: `Must be one of ${[...VALID_EXECUTORS].join(", ")}; got ${JSON.stringify(val.executor)}` });
2953
+ }
2954
+ if (typeof val.name !== "string" || val.name.length === 0) {
2955
+ errors.push({ path: `${path}.name`, message: "Must be a non-empty string" });
2956
+ }
2957
+ checkOptionalString(val.skill, `${path}.skill`, errors);
2958
+ }
2959
+ function producerIdentity(val) {
2960
+ return JSON.stringify([val.executor, val.name]);
2961
+ }
2962
+ function validateAcceptance(val, resultProducer, path, errors) {
2963
+ if (!isPlainObject(val)) {
2964
+ errors.push({ path, message: "Must be an object" });
2965
+ return;
2966
+ }
2967
+ validateProducer(val.reviewer, `${path}.reviewer`, errors);
2968
+ if (!isPlainObject(val.source_result)) {
2969
+ errors.push({ path: `${path}.source_result`, message: "Must be an object" });
2970
+ return;
2971
+ }
2972
+ const source = val.source_result;
2973
+ if (typeof source.run_id !== "string" || source.run_id.length === 0) {
2974
+ errors.push({ path: `${path}.source_result.run_id`, message: "Must be a non-empty string" });
2975
+ }
2976
+ checkOptionalString(source.stage_id, `${path}.source_result.stage_id`, errors);
2977
+ validateProducer(source.producer, `${path}.source_result.producer`, errors);
2978
+ if (isPlainObject(val.reviewer) && isPlainObject(resultProducer) && producerIdentity(val.reviewer) !== producerIdentity(resultProducer)) {
2979
+ errors.push({ path: `${path}.reviewer`, message: "Acceptance reviewer must match the result producer" });
2980
+ }
2981
+ if (isPlainObject(val.reviewer) && isPlainObject(source.producer) && producerIdentity(val.reviewer) === producerIdentity(source.producer)) {
2982
+ errors.push({ path: `${path}.reviewer`, message: "Repair producer cannot accept its own result" });
2983
+ }
2984
+ }
2710
2985
  function checkOptionalString(val, path, errors) {
2711
2986
  if (val !== void 0 && typeof val !== "string") {
2712
2987
  errors.push({ path, message: "Must be a string if present" });
@@ -2875,7 +3150,7 @@ var DEFAULT_SCHEMA = {
2875
3150
  scenario: { paths: ["artifacts/scenarios/**/*.md"], displayName: "\u573A\u666F\u5267\u672C", role: "scenario", layer: "scenario", aliases: ["scenarios", "scenario-script"] },
2876
3151
  design: { paths: ["artifacts/design/**/*.md"], displayName: "\u8BBE\u8BA1\u89C4\u683C", role: "design", layer: "design", aliases: ["design-spec", "design_docs"] },
2877
3152
  test: { paths: ["heimdall/packages/**/*.test.ts"], displayName: "\u4EE3\u7801\u6CE8\u91CA\u8FFD\u6EAF", role: "context", layer: "implementation", aliases: ["code-test", "code-trace", "unit-test"] },
2878
- e2e_test: { paths: ["artifacts/tests/e2e/*.md"], displayName: "E2E \u6D4B\u8BD5\u89C4\u683C", role: "e2e_test", layer: "verification", aliases: ["e2e-test", "e2e_tests"] },
3153
+ e2e_test: { paths: ["artifacts/tests/e2e/*.md"], displayName: "E2E \u6D4B\u8BD5\u89C4\u683C", role: "e2e_test", layer: "verification", aliases: ["e2e-test", "e2e_tests", "tc"] },
2879
3154
  e2e_registry: { paths: ["artifacts/tests/e2e/e2e-test-registry.json"], displayName: "E2E \u6D4B\u8BD5\u6CE8\u518C\u8868", role: "context", layer: "verification", aliases: ["e2e-registry"] },
2880
3155
  "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"] },
2881
3156
  "test-strategy": { paths: ["artifacts/design/test-strategy.md"], displayName: "\u6D4B\u8BD5\u7B56\u7565", role: "context", layer: "verification", aliases: ["test_strategy"] },
@@ -2905,7 +3180,12 @@ var DEFAULT_SCHEMA = {
2905
3180
  allowedEdges: [],
2906
3181
  forbiddenEdges: [{ from: "scenario", to: "entity", kind: "references" }],
2907
3182
  statuses: ["planned", "active", "done", "deprecated"],
2908
- idRanges: {}
3183
+ idRanges: {},
3184
+ e2e: {
3185
+ report_uncovered_scenarios: true,
3186
+ report_uncovered_features: true,
3187
+ runners: []
3188
+ }
2909
3189
  };
2910
3190
  async function loadConfig(root) {
2911
3191
  const configPath = join5(root, "artifact-graph.config.yaml");
@@ -2918,7 +3198,27 @@ async function loadConfig(root) {
2918
3198
  throw error;
2919
3199
  }
2920
3200
  }
2921
- return {
3201
+ const ub = parsed.context?.universal_baseline;
3202
+ if (ub !== void 0 && typeof ub !== "boolean") {
3203
+ throw new Error(
3204
+ `Invalid context.universal_baseline: ${JSON.stringify(ub)}. Must be boolean (true or false).`
3205
+ );
3206
+ }
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 = {
2922
3222
  ...DEFAULT_SCHEMA,
2923
3223
  ...parsed,
2924
3224
  types: mergeArtifactTypes(DEFAULT_SCHEMA.types, parsed.types),
@@ -2927,10 +3227,103 @@ async function loadConfig(root) {
2927
3227
  allowedEdges: parsed.allowedEdges ?? DEFAULT_SCHEMA.allowedEdges,
2928
3228
  forbiddenEdges: parsed.forbiddenEdges ?? DEFAULT_SCHEMA.forbiddenEdges,
2929
3229
  statuses: parsed.statuses ?? DEFAULT_SCHEMA.statuses,
2930
- idRanges: mergeRecord(DEFAULT_SCHEMA.idRanges, parsed.idRanges)
3230
+ idRanges: mergeRecord(DEFAULT_SCHEMA.idRanges, parsed.idRanges),
3231
+ e2e: mergedE2e
2931
3232
  };
3233
+ return merged;
2932
3234
  }
2933
- function buildGraph(nodes, edges, diagnostics = []) {
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
+ }
3325
+ }
3326
+ function buildGraph(nodes, edges, diagnostics = [], root) {
2934
3327
  const graphNodes = nodes.map((node) => ({ ...node, uid: toUid(node.type, node.code) }));
2935
3328
  graphNodes.sort(compareNode);
2936
3329
  edges.sort(compareEdge);
@@ -2947,6 +3340,7 @@ function buildGraph(nodes, edges, diagnostics = []) {
2947
3340
  nodes: graphNodes,
2948
3341
  edges: dedupedEdges,
2949
3342
  generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
3343
+ ...root ? { root } : {},
2950
3344
  diagnostics: diagnostics.sort((left, right) => left.code.localeCompare(right.code) || left.path.localeCompare(right.path) || left.line - right.line)
2951
3345
  };
2952
3346
  }
@@ -2978,7 +3372,8 @@ async function scanArtifacts(root, schema) {
2978
3372
  scanDiagnostics.push(...parsed.diagnostics);
2979
3373
  }
2980
3374
  }
2981
- const graph = buildGraph(nodes, edges, scanDiagnostics);
3375
+ const absoluteRoot = isAbsolute3(root) ? root : resolve3(root);
3376
+ const graph = buildGraph(nodes, edges, scanDiagnostics, absoluteRoot);
2982
3377
  return resolveMatrixEdges(graph);
2983
3378
  }
2984
3379
  function artifactTypeEntriesBySpecificity(schema) {
@@ -3318,23 +3713,34 @@ async function validateScenarioPrdLinkIndex(root, graph) {
3318
3713
  function validateCodeCommentTraceabilityFormat(graph) {
3319
3714
  const issues = [];
3320
3715
  for (const node of graph.nodes) {
3321
- if (node.type !== "test") {
3716
+ if (node.type !== "test" && node.type !== "implementation") {
3322
3717
  continue;
3323
3718
  }
3324
3719
  const invalidComments = node.attrs?.invalidTraceabilityComments;
3325
- if (!Array.isArray(invalidComments)) {
3326
- continue;
3720
+ if (Array.isArray(invalidComments)) {
3721
+ for (const invalid of invalidComments) {
3722
+ const line = typeof invalid?.line === "number" ? invalid.line : node.line;
3723
+ const reason = typeof invalid?.reason === "string" ? invalid.reason : "traceability tags must use standalone // comments";
3724
+ issues.push(issue(
3725
+ "CODE_COMMENT_TRACEABILITY_FORMAT",
3726
+ `${reason}: ${typeof invalid?.text === "string" ? invalid.text : ""}`.trim(),
3727
+ node.path,
3728
+ line,
3729
+ { node: node.uid }
3730
+ ));
3731
+ }
3327
3732
  }
3328
- for (const invalid of invalidComments) {
3329
- const line = typeof invalid?.line === "number" ? invalid.line : node.line;
3330
- const reason = typeof invalid?.reason === "string" ? invalid.reason : "traceability tags must use standalone // comments";
3331
- issues.push(issue(
3332
- "CODE_COMMENT_TRACEABILITY_FORMAT",
3333
- `${reason}: ${typeof invalid?.text === "string" ? invalid.text : ""}`.trim(),
3334
- node.path,
3335
- line,
3336
- { node: node.uid }
3337
- ));
3733
+ const deprecatedComments = node.attrs?.deprecatedTraceabilityComments;
3734
+ if (Array.isArray(deprecatedComments)) {
3735
+ for (const deprecated of deprecatedComments) {
3736
+ issues.push(issue(
3737
+ "E2E-TRACE-007",
3738
+ "@tc is deprecated; use @e2e_test instead",
3739
+ node.path,
3740
+ typeof deprecated?.line === "number" ? deprecated.line : node.line,
3741
+ { node: node.uid, severity: "warning" }
3742
+ ));
3743
+ }
3338
3744
  }
3339
3745
  }
3340
3746
  return issues;
@@ -3816,13 +4222,16 @@ function parseTest(path, raw, schema = DEFAULT_SCHEMA) {
3816
4222
  const isTest = isTestFile(path);
3817
4223
  const nodeType = isTest ? "test" : "implementation";
3818
4224
  const edgeKind = isTest ? "verifies" : "implements";
4225
+ const attrs = {};
4226
+ if (traceabilityComments.invalid.length > 0) attrs.invalidTraceabilityComments = traceabilityComments.invalid;
4227
+ if (traceabilityComments.deprecated.length > 0) attrs.deprecatedTraceabilityComments = traceabilityComments.deprecated;
3819
4228
  const node = {
3820
4229
  type: nodeType,
3821
4230
  code: path,
3822
4231
  title: path.split("/").at(-1) ?? path,
3823
4232
  path,
3824
4233
  line: 1,
3825
- attrs: traceabilityComments.invalid.length > 0 ? { invalidTraceabilityComments: traceabilityComments.invalid } : {}
4234
+ attrs
3826
4235
  };
3827
4236
  let hasTags = false;
3828
4237
  for (const { tags, lineNumber } of traceabilityComments.canonical) {
@@ -3833,7 +4242,7 @@ function parseTest(path, raw, schema = DEFAULT_SCHEMA) {
3833
4242
  }
3834
4243
  }
3835
4244
  }
3836
- if (hasTags || traceabilityComments.invalid.length > 0) {
4245
+ if (hasTags || traceabilityComments.invalid.length > 0 || traceabilityComments.deprecated.length > 0) {
3837
4246
  nodes.push(node);
3838
4247
  }
3839
4248
  return { nodes, edges };
@@ -3841,6 +4250,7 @@ function parseTest(path, raw, schema = DEFAULT_SCHEMA) {
3841
4250
  function scanTraceabilityComments(raw, schema = DEFAULT_SCHEMA) {
3842
4251
  const canonical = [];
3843
4252
  const invalid = [];
4253
+ const deprecated = [];
3844
4254
  for (const comment of scanCodeComments(raw)) {
3845
4255
  if (!containsTraceabilityTag(comment.text, schema)) {
3846
4256
  continue;
@@ -3856,6 +4266,9 @@ function scanTraceabilityComments(raw, schema = DEFAULT_SCHEMA) {
3856
4266
  const parsed = parseTraceabilityTagLine(comment.text.trim(), schema);
3857
4267
  if (parsed.valid) {
3858
4268
  canonical.push({ tags: parsed.tags, lineNumber: comment.lineNumber });
4269
+ if (/(?:^|\s)@tc(?=\s|$)/.test(comment.text.trim())) {
4270
+ deprecated.push({ line: comment.lineNumber, text: comment.text.trim() });
4271
+ }
3859
4272
  } else {
3860
4273
  invalid.push({ line: comment.lineNumber, text: comment.text.trim(), reason: parsed.reason });
3861
4274
  }
@@ -3871,11 +4284,14 @@ function scanTraceabilityComments(raw, schema = DEFAULT_SCHEMA) {
3871
4284
  const parsed = parseTraceabilityTagLine(comment.text.trim(), schema);
3872
4285
  if (parsed.valid) {
3873
4286
  canonical.push({ tags: parsed.tags, lineNumber: comment.lineNumber });
4287
+ if (/(?:^|\s)@tc(?=\s|$)/.test(comment.text.trim())) {
4288
+ deprecated.push({ line: comment.lineNumber, text: comment.text.trim() });
4289
+ }
3874
4290
  } else {
3875
4291
  invalid.push({ line: comment.lineNumber, text: comment.text.trim(), reason: parsed.reason });
3876
4292
  }
3877
4293
  }
3878
- return { canonical, invalid };
4294
+ return { canonical, invalid, deprecated };
3879
4295
  }
3880
4296
  function scanCodeComments(raw) {
3881
4297
  const comments = [];
@@ -4042,7 +4458,14 @@ function expandCodeRange(value) {
4042
4458
  return Array.from({ length: end - start + 1 }, (_, index) => `${prefix}${String(start + index).padStart(width, "0")}`);
4043
4459
  }
4044
4460
  function containsTraceabilityTag(value, schema = DEFAULT_SCHEMA) {
4045
- return /@[\w][\w-]*\b/.test(value);
4461
+ const tokens = /* @__PURE__ */ new Set();
4462
+ for (const [type, definition] of Object.entries(schema.types)) {
4463
+ tokens.add(type);
4464
+ for (const alias of definition.aliases ?? []) tokens.add(alias);
4465
+ }
4466
+ if (tokens.size === 0) return false;
4467
+ const alternatives = [...tokens].sort((a, b) => b.length - a.length).map((token) => token.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")).join("|");
4468
+ return new RegExp(`(?:^|\\s)@(?:${alternatives})(?=\\s|$)`).test(value);
4046
4469
  }
4047
4470
  function parseDesign(path, raw) {
4048
4471
  const parsed = matter(raw);
@@ -5047,6 +5470,29 @@ function validateE2eTests(graph) {
5047
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" }));
5048
5471
  }
5049
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
+ }
5050
5496
  if (needsDesktopChainWarning(node)) {
5051
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" }));
5052
5498
  }
@@ -5105,10 +5551,10 @@ function validateE2eRegistry(graph) {
5105
5551
  }
5106
5552
  return issues;
5107
5553
  }
5108
- async function validateExecutableTraceability(root) {
5554
+ async function validateExecutableTraceability(root, config) {
5109
5555
  const issues = [];
5556
+ const schema = config ?? await loadConfig(root);
5110
5557
  const e2eDir = join5(root, "artifacts", "tests", "e2e");
5111
- const specPatterns = ["heimdall/**/*.spec.ts", "heimdall/**/*.e2e.spec.ts"];
5112
5558
  let e2eFiles;
5113
5559
  try {
5114
5560
  e2eFiles = (await readdir(e2eDir)).filter((name) => /^test-.*\.md$/.test(name)).map((name) => join5(e2eDir, name));
@@ -5151,16 +5597,23 @@ async function validateExecutableTraceability(root) {
5151
5597
  }
5152
5598
  const allFiles = await walk(root);
5153
5599
  const specFiles = /* @__PURE__ */ new Set();
5154
- for (const pattern of specPatterns) {
5600
+ const configuredRunners = schema.e2e?.runners ?? [];
5601
+ if (configuredRunners.length > 0) {
5155
5602
  for (const file of allFiles) {
5156
- 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)) {
5157
5610
  specFiles.add(file);
5158
5611
  }
5159
5612
  }
5160
5613
  }
5161
5614
  const refToSource = /* @__PURE__ */ new Map();
5162
- const tcAnnotationRegex = /\/\/!?\s*@tc\s+(\S+?)\s+\[(\w+)\]/;
5163
- const tcAnnotationNoLevelRegex = /\/\/!?\s*@tc\s+(\S+)/;
5615
+ const tcAnnotationRegex = /\/\/!?\s*@(?:e2e_test|tc)\s+(\S+?)\s+\[(\w+)\]/;
5616
+ const tcAnnotationNoLevelRegex = /\/\/!?\s*@(?:e2e_test|tc)\s+(\S+)/;
5164
5617
  for (const specFile of specFiles) {
5165
5618
  const fullSpecPath = join5(root, specFile);
5166
5619
  let content;
@@ -5224,12 +5677,21 @@ async function validateExecutableTraceability(root) {
5224
5677
  const refEntries = parseExecutableRefLines(ref);
5225
5678
  const validFiles = [];
5226
5679
  for (const entry of refEntries) {
5227
- const normalizedRefFile = entry.file.startsWith("heimdall/") ? entry.file : `heimdall/${entry.file}`;
5228
- const fileExists = specFiles.has(normalizedRefFile);
5680
+ const normalizedRefFile = resolveExecutableRefFile(entry.file, allFiles);
5681
+ const fileExists = normalizedRefFile !== void 0 && specFiles.has(normalizedRefFile);
5229
5682
  if (!fileExists) {
5230
5683
  issues.push(issue("E2E-TRACE-001", `executable_ref target file not found: ${entry.file}`, path, line, { node: tcKey, severity: "warning" }));
5231
5684
  continue;
5232
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
+ }
5233
5695
  if (entry.testId) {
5234
5696
  let content;
5235
5697
  try {
@@ -5244,12 +5706,9 @@ async function validateExecutableTraceability(root) {
5244
5706
  }
5245
5707
  }
5246
5708
  const annotationsForTc = refToSource.get(tcKey);
5247
- const hasAnnotationInFile = annotationsForTc?.some((ann) => {
5248
- const normalizedAnnFile = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
5249
- return normalizedAnnFile === normalizedRefFile;
5250
- }) ?? false;
5709
+ const hasAnnotationInFile = annotationsForTc?.some((ann) => ann.file === normalizedRefFile) ?? false;
5251
5710
  if (!hasAnnotationInFile) {
5252
- issues.push(issue("E2E-TRACE-003", `executable_ref target ${entry.file} has no // @tc ${tcKey} line-comment annotation`, path, line, { node: tcKey, severity: "warning" }));
5711
+ issues.push(issue("E2E-TRACE-003", `executable_ref target ${entry.file} has no E2E trace annotation ${tcKey}`, path, line, { node: tcKey, severity: "warning" }));
5253
5712
  continue;
5254
5713
  }
5255
5714
  validFiles.push(normalizedRefFile);
@@ -5260,13 +5719,13 @@ async function validateExecutableTraceability(root) {
5260
5719
  const batch = tcKey.split(":")[0];
5261
5720
  if (!mdBatches.has(batch)) {
5262
5721
  for (const ann of annotations) {
5263
- issues.push(issue("E2E-TRACE-002", `@tc annotation ${tcKey} references non-existent E2E batch "${batch}"`, ann.file, ann.line, { node: tcKey, severity: "warning" }));
5722
+ issues.push(issue("E2E-TRACE-002", `E2E trace annotation ${tcKey} references non-existent E2E batch "${batch}"`, ann.file, ann.line, { node: tcKey, severity: "warning" }));
5264
5723
  }
5265
5724
  continue;
5266
5725
  }
5267
5726
  if (!mdToRef.has(tcKey) && !await hasMarkdownTc(tcKey, e2eDir)) {
5268
5727
  for (const ann of annotations) {
5269
- issues.push(issue("E2E-TRACE-002", `@tc annotation ${tcKey} references non-existent Markdown TC`, ann.file, ann.line, { node: tcKey, severity: "warning" }));
5728
+ issues.push(issue("E2E-TRACE-002", `E2E trace annotation ${tcKey} references non-existent Markdown TC`, ann.file, ann.line, { node: tcKey, severity: "warning" }));
5270
5729
  }
5271
5730
  }
5272
5731
  }
@@ -5285,24 +5744,37 @@ async function validateExecutableTraceability(root) {
5285
5744
  if (matchesAnyRef) {
5286
5745
  continue;
5287
5746
  }
5288
- const primaryRef = refEntries[0];
5289
- const normalizedPrimary = primaryRef?.file.startsWith("heimdall/") ? primaryRef?.file : `heimdall/${primaryRef?.file ?? ""}`;
5290
5747
  const detail = `file: MD refs=[${refEntries.map((e) => e.file).join(", ")}] vs source=${ann.file}`;
5291
- issues.push(issue("E2E-TRACE-003", `executable_ref \u2194 @tc mismatch for ${tcKey}: ${detail}`, path, line, { node: tcKey, severity: "warning" }));
5748
+ issues.push(issue("E2E-TRACE-003", `executable_ref \u2194 E2E trace annotation mismatch for ${tcKey}: ${detail}`, path, line, { node: tcKey, severity: "warning" }));
5292
5749
  }
5293
5750
  }
5294
- for (const [tcKey, { chainType, path, line }] of mdToRef) {
5295
- 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") {
5296
5755
  continue;
5297
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;
5298
5765
  const validFiles = new Set(tcValidRefFiles.get(tcKey) ?? []);
5299
5766
  const sourceAnnotations = refToSource.get(tcKey);
5300
5767
  if (!sourceAnnotations) {
5301
5768
  continue;
5302
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
+ }
5303
5776
  for (const ann of sourceAnnotations) {
5304
- const normalizedAnnFile = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
5305
- if (!validFiles.has(normalizedAnnFile)) {
5777
+ if (!validFiles.has(ann.file)) {
5306
5778
  continue;
5307
5779
  }
5308
5780
  if (ann.level === "mock_playwright") {
@@ -5328,10 +5800,7 @@ async function validateExecutableTraceability(root) {
5328
5800
  continue;
5329
5801
  }
5330
5802
  const validFiles = new Set(tcValidRefFiles.get(tcKey) ?? []);
5331
- const sourceAnnotations = refToSource.get(tcKey)?.filter((ann) => {
5332
- const normalized = ann.file.startsWith("heimdall/") ? ann.file : `heimdall/${ann.file}`;
5333
- return validFiles.has(normalized);
5334
- });
5803
+ const sourceAnnotations = refToSource.get(tcKey)?.filter((ann) => validFiles.has(ann.file));
5335
5804
  const hasDesktopChain = sourceAnnotations?.some((ann) => ann.level === "desktop_chain") ?? false;
5336
5805
  const hasBridge = sourceAnnotations?.some((ann) => ann.level === "ui_sidecar_bridge") ?? false;
5337
5806
  if (isComplete) {
@@ -5340,7 +5809,7 @@ async function validateExecutableTraceability(root) {
5340
5809
  if (hasDesktopChain) {
5341
5810
  hasValidEvidence = true;
5342
5811
  } else if (hasBridge) {
5343
- const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root);
5812
+ const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root, allFiles);
5344
5813
  if (partialResult.hasValidPartialRust) {
5345
5814
  hasValidEvidence = true;
5346
5815
  } else {
@@ -5380,7 +5849,7 @@ async function validateExecutableTraceability(root) {
5380
5849
  }
5381
5850
  let partialDetail = "";
5382
5851
  if (hasBridge) {
5383
- const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root);
5852
+ const partialResult = await validatePartialRustEvidence(tcFields, tcKey, root, allFiles);
5384
5853
  if (partialResult.hasValidPartialRust) {
5385
5854
  continue;
5386
5855
  }
@@ -5397,6 +5866,287 @@ async function validateExecutableTraceability(root) {
5397
5866
  }
5398
5867
  return issues;
5399
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
+ }
5400
6150
  function isPendingExecutableRef(ref) {
5401
6151
  const stripped = ref.replace(/^[\s-*()]+/, "").trim();
5402
6152
  return /^pending\b/i.test(stripped);
@@ -5414,6 +6164,20 @@ function parseExecutableRefLines(ref) {
5414
6164
  }
5415
6165
  return results;
5416
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
+ };
5417
6181
  function isDesktopChainType(chainType) {
5418
6182
  const normalizedChainType = chainType.trim().toLowerCase();
5419
6183
  return normalizedChainType === "desktop_chain" || normalizedChainType === "";
@@ -5421,7 +6185,7 @@ function isDesktopChainType(chainType) {
5421
6185
  function parseChainCoverageStatus(chainCoverage) {
5422
6186
  return chainCoverage.trim().toLowerCase().match(/^[a-z_]+/)?.[0] ?? "";
5423
6187
  }
5424
- async function validatePartialRustEvidence(tcFields, tcKey, root) {
6188
+ async function validatePartialRustEvidence(tcFields, tcKey, root, allFiles) {
5425
6189
  const partialEvidence = String(tcFields["partial_evidence"] ?? "");
5426
6190
  if (!partialEvidence.trim()) {
5427
6191
  return { hasValidPartialRust: false, detail: "no partial_evidence field" };
@@ -5445,7 +6209,10 @@ async function validatePartialRustEvidence(tcFields, tcKey, root) {
5445
6209
  return { hasValidPartialRust: false, detail: "no .rs file in partial_evidence" };
5446
6210
  }
5447
6211
  for (const ref of rustRefs) {
5448
- 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
+ }
5449
6216
  const fullPath = join5(root, normalizedPath);
5450
6217
  let content;
5451
6218
  try {
@@ -5454,14 +6221,14 @@ async function validatePartialRustEvidence(tcFields, tcKey, root) {
5454
6221
  return { hasValidPartialRust: false, detail: `partial_rust file not found: ${ref.file}` };
5455
6222
  }
5456
6223
  const tcAnnotationPattern = new RegExp(
5457
- `//[/!]?\\s*@tc\\s+${escapeRegExp(tcKey)}\\s+\\[partial_rust\\]`
6224
+ `//[/!]?\\s*@(?:e2e_test|tc)\\s+${escapeRegExp(tcKey)}\\s+\\[partial_rust\\]`
5458
6225
  );
5459
6226
  if (!tcAnnotationPattern.test(content)) {
5460
- const noLevelPattern = new RegExp(`//[/!]?\\s*@tc\\s+${escapeRegExp(tcKey)}\\b`);
6227
+ const noLevelPattern = new RegExp(`//[/!]?\\s*@(?:e2e_test|tc)\\s+${escapeRegExp(tcKey)}\\b`);
5461
6228
  if (noLevelPattern.test(content)) {
5462
- return { hasValidPartialRust: false, detail: `partial_rust file ${ref.file} has @tc ${tcKey} but not tagged [partial_rust]` };
6229
+ return { hasValidPartialRust: false, detail: `partial_rust file ${ref.file} has E2E trace annotation ${tcKey} but not tagged [partial_rust]` };
5463
6230
  }
5464
- return { hasValidPartialRust: false, detail: `partial_rust file ${ref.file} has no @tc ${tcKey} annotation` };
6231
+ return { hasValidPartialRust: false, detail: `partial_rust file ${ref.file} has no E2E trace annotation ${tcKey}` };
5465
6232
  }
5466
6233
  }
5467
6234
  return { hasValidPartialRust: true, detail: "ok" };
@@ -5481,6 +6248,46 @@ function detectTestLevel(specFile, content) {
5481
6248
  }
5482
6249
  return "desktop_chain";
5483
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
+ }
5484
6291
  function splitMarkdownCells(line) {
5485
6292
  const cells = [];
5486
6293
  let current = "";
@@ -5634,7 +6441,7 @@ function flattenAcCoverage(value) {
5634
6441
  function needsDesktopChainWarning(node) {
5635
6442
  const tcFields = asRecord(node.attrs?.tcFields);
5636
6443
  const chainType = String(tcFields["chain_type"] ?? "").trim().toLowerCase();
5637
- if (chainType === "frontend_only" || chainType === "core_only") {
6444
+ if (chainType && VALID_CHAIN_TYPES.has(chainType) && chainType !== "desktop_chain" || chainType in DEPRECATED_CHAIN_TYPE_ALIASES) {
5638
6445
  return false;
5639
6446
  }
5640
6447
  const chainCoverage = String(tcFields["chain_coverage"] ?? "").trim().toLowerCase();
@@ -5788,9 +6595,14 @@ function mergeRecord(base, override) {
5788
6595
  function mergeArtifactTypes(base, override) {
5789
6596
  const result = { ...base };
5790
6597
  for (const [type, definition] of Object.entries(override ?? {})) {
6598
+ const aliases = [
6599
+ ...base[type]?.aliases ?? [],
6600
+ ...definition.aliases ?? []
6601
+ ].filter((alias, index, all) => all.indexOf(alias) === index);
5791
6602
  result[type] = {
5792
6603
  ...base[type] ?? {},
5793
- ...definition
6604
+ ...definition,
6605
+ ...aliases.length > 0 ? { aliases } : {}
5794
6606
  };
5795
6607
  }
5796
6608
  return result;
@@ -5884,6 +6696,8 @@ var TIER_ORDER = ["baseline", "target", "direct", "matrix", "transitive"];
5884
6696
  function resolveArtifactContext(graph, opts) {
5885
6697
  const mode = opts.mode ?? "full";
5886
6698
  const maxPerCategory = opts.maxPerCategory ?? 20;
6699
+ const universalBaseline = opts.universalBaseline ?? true;
6700
+ const root = opts.root ?? graph.root;
5887
6701
  const legacyCount = [opts.feature, opts.scenario, opts.decision, opts.design, opts.e2e_test].filter(Boolean).length;
5888
6702
  if (opts.target && legacyCount > 0) {
5889
6703
  return {
@@ -6036,12 +6850,80 @@ function resolveArtifactContext(graph, opts) {
6036
6850
  return "direct";
6037
6851
  }
6038
6852
  const pathMap = /* @__PURE__ */ new Map();
6039
- for (const ap of ALWAYS_PRESENT_ITEMS) {
6040
- const existing = pathMap.get(ap.path);
6041
- if (existing) {
6042
- if (!existing.reasons.includes(ap.reason)) existing.reasons.push(ap.reason);
6853
+ if (universalBaseline) {
6854
+ for (const ap of ALWAYS_PRESENT_ITEMS) {
6855
+ const existing = pathMap.get(ap.path);
6856
+ if (existing) {
6857
+ if (!existing.reasons.includes(ap.reason)) existing.reasons.push(ap.reason);
6858
+ } else {
6859
+ pathMap.set(ap.path, { path: ap.path, reasons: [ap.reason], category: "baseline", required: true, tier: "baseline" });
6860
+ }
6861
+ }
6862
+ if (root) {
6863
+ for (const ap of ALWAYS_PRESENT_ITEMS) {
6864
+ const fullPath = join5(root, ap.path);
6865
+ let stat;
6866
+ try {
6867
+ stat = statSync(fullPath);
6868
+ } catch {
6869
+ stat = null;
6870
+ }
6871
+ if (!stat) {
6872
+ const msg = `Required baseline artifact not found: ${ap.path}`;
6873
+ if (!missing.includes(msg)) {
6874
+ missing.push(msg);
6875
+ missingDetails.push({
6876
+ ref: ap.path,
6877
+ from: "baseline",
6878
+ kind: "missing-baseline",
6879
+ message: msg,
6880
+ suggestedAction: `\u521B\u5EFA\u6587\u4EF6 ${ap.path} \u6216\u914D\u7F6E\u8DF3\u8FC7 universal baseline`
6881
+ });
6882
+ }
6883
+ } else if (!stat.isFile()) {
6884
+ const msg = `Required baseline artifact is not a regular file: ${ap.path}`;
6885
+ if (!missing.includes(msg)) {
6886
+ missing.push(msg);
6887
+ missingDetails.push({
6888
+ ref: ap.path,
6889
+ from: "baseline",
6890
+ kind: "missing-baseline",
6891
+ message: msg,
6892
+ suggestedAction: `\u5C06 ${ap.path} \u4ECE\u76EE\u5F55\u6539\u4E3A\u6587\u4EF6\uFF0C\u6216\u914D\u7F6E\u8DF3\u8FC7 universal baseline`
6893
+ });
6894
+ }
6895
+ } else {
6896
+ try {
6897
+ accessSync(fullPath, fsConstants.R_OK);
6898
+ } catch {
6899
+ const msg = `Required baseline artifact is not readable: ${ap.path}`;
6900
+ if (!missing.includes(msg)) {
6901
+ missing.push(msg);
6902
+ missingDetails.push({
6903
+ ref: ap.path,
6904
+ from: "baseline",
6905
+ kind: "missing-baseline",
6906
+ message: msg,
6907
+ suggestedAction: `\u4FEE\u590D ${ap.path} \u7684\u6587\u4EF6\u6743\u9650\uFF0C\u6216\u914D\u7F6E\u8DF3\u8FC7 universal baseline`
6908
+ });
6909
+ }
6910
+ }
6911
+ }
6912
+ }
6043
6913
  } else {
6044
- pathMap.set(ap.path, { path: ap.path, reasons: [ap.reason], category: "baseline", required: true, tier: "baseline" });
6914
+ for (const ap of ALWAYS_PRESENT_ITEMS) {
6915
+ const msg = `Cannot verify baseline without root: ${ap.path}`;
6916
+ if (!missing.includes(msg)) {
6917
+ missing.push(msg);
6918
+ missingDetails.push({
6919
+ ref: ap.path,
6920
+ from: "baseline",
6921
+ kind: "missing-baseline",
6922
+ message: msg,
6923
+ suggestedAction: `\u4F20\u9012 root \u53C2\u6570\u6216\u914D\u7F6E\u8DF3\u8FC7 universal baseline`
6924
+ });
6925
+ }
6926
+ }
6045
6927
  }
6046
6928
  }
6047
6929
  pathMap.set(targetNode.path, {
@@ -6142,7 +7024,8 @@ function resolveArtifactContext(graph, opts) {
6142
7024
  context,
6143
7025
  missing,
6144
7026
  missingDetails,
6145
- omitted
7027
+ omitted,
7028
+ baselinePolicy: universalBaseline
6146
7029
  };
6147
7030
  }
6148
7031
  function formatContextMarkdown(manifest) {
@@ -6229,12 +7112,14 @@ export {
6229
7112
  buildGraph,
6230
7113
  buildVersionIndex,
6231
7114
  collectChangedPaths,
7115
+ computeE2eCoverageStats,
6232
7116
  dirname3 as dirname,
6233
7117
  discoverAndAuditPackets,
6234
7118
  discoverTargets,
6235
7119
  doctorArtifactChain,
6236
7120
  extname,
6237
7121
  formatContextMarkdown,
7122
+ generateE2eRegistry,
6238
7123
  getArtifactTypeMetadata,
6239
7124
  getTargetArtifactTypes,
6240
7125
  installManagedHookBlock,