artifact-graph 0.9.0 → 0.9.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2
4
+
5
+ ### Fixed
6
+
7
+ - Scenario explicit relation field recognition is tightened to whole-line field syntax matching:
8
+ plain prose mentions of `关联功能` / `关联决策` / `关联实体` no longer produce edges or
9
+ spurious `FORMAT_ERROR` diagnostics.
10
+ - An invalid explicit relation reference is no longer reported twice across the scan and
11
+ validation stages: `validateGraph` now returns exactly one `FORMAT_ERROR` carrying the file,
12
+ line number, node, invalid value, and the expected rule.
13
+ - Behavior tightening: Markdown list-item forms such as `- **关联决策**: ADR-0001` are no longer
14
+ recognized as explicit relation fields (0.9.1 misidentified them via substring matching);
15
+ standard line-start field syntax is unaffected.
16
+
17
+ ## 0.9.1
18
+
19
+ ### Fixed
20
+
21
+ - Scenario explicit relation fields (`关联决策`, `关联功能`, `关联实体`) and design-document
22
+ frontmatter `related_decisions` now validate each reference against the project's configured
23
+ `idPatterns`, so custom identifier schemes such as `ADR-0002` or `FEAT-12` produce graph edges.
24
+ Invalid identifiers emit diagnostics with file and line numbers instead of being silently
25
+ dropped, while explicit empty markers such as `无` produce neither edges nor diagnostics.
26
+ - Design documents now read `related_decisions` as a structured frontmatter field (edge source
27
+ `frontmatter`), scan only the body for prose references, and never emit duplicate edges for
28
+ the same relation.
29
+ - `query --from` and `render --from` now resolve a bare identifier to its unique graph node
30
+ regardless of the configured `idPatterns`, and report an ambiguity diagnostic asking for a
31
+ full `type:id` when multiple artifact types share the same bare identifier.
32
+
3
33
  ## 0.9.0
4
34
 
5
35
  ### Changed
package/dist/cli.js CHANGED
@@ -5072,6 +5072,7 @@ function validateScenarioPrdLinks(graph, schema = DEFAULT_SCHEMA) {
5072
5072
  }
5073
5073
  pushDuplicateIssues(issues, scenarioUid, validRefs, "feature");
5074
5074
  for (const ref of refs) {
5075
+ if (ref.valid === false) continue;
5075
5076
  if (!featurePattern.test(ref.target)) {
5076
5077
  issues.push(issue("FORMAT_ERROR", `scenario ${scenarioUid} has invalid feature reference ${ref.target}`, ref.path, ref.line, { node: scenarioUid }));
5077
5078
  }
@@ -5268,7 +5269,7 @@ function validateCodeCommentScenarioFeatureConsistency(graph) {
5268
5269
  }
5269
5270
  function queryGraph(graph, options) {
5270
5271
  const depth = options.depth ?? 1;
5271
- const start = normalizeUid(options.from ?? options.to ?? "");
5272
+ const start = resolveQueryStartUid(graph, options.from ?? options.to ?? "");
5272
5273
  const reverse = Boolean(options.to && !options.from);
5273
5274
  const selected = /* @__PURE__ */ new Set([start]);
5274
5275
  let frontier = /* @__PURE__ */ new Set([start]);
@@ -5370,7 +5371,7 @@ function parseFile(type, path, raw, schema = DEFAULT_SCHEMA) {
5370
5371
  return { ...parseFeature(path, raw), diagnostics: [] };
5371
5372
  }
5372
5373
  if (type === "scenario") {
5373
- return { ...parseScenarios(path, raw, schema), diagnostics: [] };
5374
+ return parseScenarios(path, raw, schema);
5374
5375
  }
5375
5376
  if (type === "entity") {
5376
5377
  return { ...parseEntityRegistry(path, raw), diagnostics: [] };
@@ -5382,7 +5383,7 @@ function parseFile(type, path, raw, schema = DEFAULT_SCHEMA) {
5382
5383
  return { ...parseTest(path, raw, schema), diagnostics: [] };
5383
5384
  }
5384
5385
  if (type === "design") {
5385
- return { ...parseDesign(path, raw), diagnostics: [] };
5386
+ return parseDesign(path, raw, schema);
5386
5387
  }
5387
5388
  if (type === "e2e_test") {
5388
5389
  return { ...parseE2eTest(path, raw), diagnostics: [] };
@@ -5584,13 +5585,14 @@ function parseScenarios(path, raw, schema = DEFAULT_SCHEMA) {
5584
5585
  });
5585
5586
  const nodes = [];
5586
5587
  const edges = [];
5588
+ const diagnostics = [];
5587
5589
  for (let i = 0; i < starts.length; i += 1) {
5588
5590
  const start = starts[i];
5589
5591
  const end = starts[i + 1]?.index ?? lines.length;
5590
5592
  const block = lines.slice(start.index, end);
5591
- const featureRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u529F\u80FD", "feature");
5592
- const decisionRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u51B3\u7B56", "decision");
5593
- const entityRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u5B9E\u4F53", "entity");
5593
+ const featureRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u529F\u80FD", "feature", schema);
5594
+ const decisionRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u51B3\u7B56", "decision", schema);
5595
+ const entityRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u5B9E\u4F53", "entity", schema);
5594
5596
  nodes.push({
5595
5597
  type: "scenario",
5596
5598
  code: start.code,
@@ -5608,8 +5610,19 @@ function parseScenarios(path, raw, schema = DEFAULT_SCHEMA) {
5608
5610
  edges.push(...featureRefs.filter(isValidRelationOccurrence).map((ref) => edge(toUid("scenario", start.code), toUid("feature", ref.target), "references", "markdown", path, ref.line)));
5609
5611
  edges.push(...decisionRefs.filter(isValidRelationOccurrence).map((ref) => edge(toUid("scenario", start.code), toUid("decision", ref.target), "references", "markdown", path, ref.line)));
5610
5612
  edges.push(...entityRefs.filter(isValidRelationOccurrence).map((ref) => edge(toUid("scenario", start.code), toUid("entity", ref.target), "references", "markdown", path, ref.line)));
5613
+ for (const ref of [...featureRefs, ...decisionRefs, ...entityRefs]) {
5614
+ if (ref.valid === false) {
5615
+ diagnostics.push(issue(
5616
+ "FORMAT_ERROR",
5617
+ `scenario:${start.code} has invalid ${ref.targetType} reference "${ref.target}" in field ${ref.field} (expected ${schema.idPatterns[ref.targetType] ?? DEFAULT_SCHEMA.idPatterns[ref.targetType]})`,
5618
+ ref.path,
5619
+ ref.line,
5620
+ { node: toUid("scenario", start.code) }
5621
+ ));
5622
+ }
5623
+ }
5611
5624
  }
5612
- return { nodes, edges };
5625
+ return { nodes, edges, diagnostics };
5613
5626
  }
5614
5627
  function parseEntityRegistry(path, raw) {
5615
5628
  const nodes = [];
@@ -5921,7 +5934,7 @@ function containsTraceabilityTag(value, schema = DEFAULT_SCHEMA) {
5921
5934
  const alternatives = [...tokens].sort((a, b) => b.length - a.length).map((token) => token.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")).join("|");
5922
5935
  return new RegExp(`(?:^|\\s)@(?:${alternatives})(?=\\s|$)`).test(value);
5923
5936
  }
5924
- function parseDesign(path, raw) {
5937
+ function parseDesign(path, raw, schema = DEFAULT_SCHEMA) {
5925
5938
  const parsed = matter(raw);
5926
5939
  const data = parsed.data;
5927
5940
  const code = normalizeDesignCode(path);
@@ -5931,15 +5944,45 @@ function parseDesign(path, raw) {
5931
5944
  ...designFrontmatterEdges(path, code, data.related_features, "feature", "references"),
5932
5945
  ...designFrontmatterEdges(path, code, data.related_scenarios, "scenario", "references")
5933
5946
  ];
5934
- raw.split(/\r?\n/).forEach((line, index) => {
5947
+ const diagnostics = [];
5948
+ const decisionPattern = relationIdPattern("decision", schema);
5949
+ const frontmatterDecisions = /* @__PURE__ */ new Set();
5950
+ for (const value of toArray(data.related_decisions)) {
5951
+ const candidate = String(value).trim();
5952
+ if (!candidate || EMPTY_RELATION_FIELD_VALUES.has(candidate.toLowerCase())) {
5953
+ continue;
5954
+ }
5955
+ if (!decisionPattern.test(candidate)) {
5956
+ diagnostics.push(issue(
5957
+ "FORMAT_ERROR",
5958
+ `design:${code} has invalid decision reference "${candidate}" in related_decisions (expected ${schema.idPatterns.decision ?? DEFAULT_SCHEMA.idPatterns.decision})`,
5959
+ path,
5960
+ 1,
5961
+ { node: toUid("design", code) }
5962
+ ));
5963
+ continue;
5964
+ }
5965
+ if (frontmatterDecisions.has(candidate)) {
5966
+ continue;
5967
+ }
5968
+ frontmatterDecisions.add(candidate);
5969
+ edges.push(edge(toUid("design", code), toUid("decision", candidate), "references", "frontmatter", path, 1));
5970
+ }
5971
+ const hasFrontmatter = /^---\r?\n/.test(raw);
5972
+ const contentStart = hasFrontmatter && parsed.content ? Math.max(raw.indexOf(parsed.content), 0) : 0;
5973
+ const lineOffset = contentStart > 0 ? raw.slice(0, contentStart).split(/\r?\n/).length - 1 : 0;
5974
+ parsed.content.split(/\r?\n/).forEach((line, index) => {
5935
5975
  for (const codeValue of extractCodes(line, "entity")) {
5936
- edges.push(edge(toUid("design", code), toUid("entity", codeValue), "references", "markdown", path, index + 1));
5976
+ edges.push(edge(toUid("design", code), toUid("entity", codeValue), "references", "markdown", path, lineOffset + index + 1));
5937
5977
  }
5938
5978
  for (const codeValue of extractCodes(line, "decision")) {
5939
- edges.push(edge(toUid("design", code), toUid("decision", codeValue), "references", "markdown", path, index + 1));
5979
+ if (frontmatterDecisions.has(codeValue)) {
5980
+ continue;
5981
+ }
5982
+ edges.push(edge(toUid("design", code), toUid("decision", codeValue), "references", "markdown", path, lineOffset + index + 1));
5940
5983
  }
5941
5984
  });
5942
- return { nodes, edges };
5985
+ return { nodes, edges, diagnostics };
5943
5986
  }
5944
5987
  function parseE2eTest(path, raw) {
5945
5988
  const parsed = matter(raw);
@@ -6784,20 +6827,25 @@ function decisionFrontmatterEdges(path, decisionCode, value, targetType, kind) {
6784
6827
  return [edge(toUid("decision", decisionCode), toUid(targetType, code), kind, "frontmatter", path, 1)];
6785
6828
  });
6786
6829
  }
6787
- function markdownRelationOccurrences(path, scenario, block, label, targetType, schema) {
6830
+ function matchExplicitRelationFieldLine(line, label) {
6831
+ const escaped = escapeRegExp(label);
6832
+ const match = new RegExp(`^\\s*(?:\\*\\*${escaped}\\*\\*|${escaped})[:\uFF1A]\\s*(.*?)\\s*$`).exec(line);
6833
+ return match?.[1] ?? null;
6834
+ }
6835
+ function markdownRelationOccurrences(path, scenario, block, label, targetType, schema = DEFAULT_SCHEMA) {
6788
6836
  const result = [];
6789
6837
  block.forEach((line, index) => {
6790
- if (!line.includes(label)) {
6838
+ const relationText = matchExplicitRelationFieldLine(line, label);
6839
+ if (!relationText) {
6791
6840
  return;
6792
6841
  }
6793
- const relationText = line.split(/[::]/).slice(1).join(":").split(/\s+[—-]\s+/)[0] ?? line;
6794
6842
  const lineNumber = scenario.index + index + 1;
6795
- const codes = extractCodes(relationText, targetType, schema);
6796
- for (const code of codes) {
6797
- result.push({ field: label, targetType, target: code, path, line: lineNumber, raw: relationText.trim() });
6843
+ const { valid, invalid } = parseExplicitRelationField(relationText, targetType, schema);
6844
+ for (const code of valid) {
6845
+ result.push({ field: label, targetType, target: code, path, line: lineNumber, raw: relationText, valid: true });
6798
6846
  }
6799
- for (const invalid of invalidRelationCandidates(relationText, targetType, codes)) {
6800
- result.push({ field: label, targetType, target: invalid, path, line: lineNumber, raw: relationText.trim() });
6847
+ for (const code of invalid) {
6848
+ result.push({ field: label, targetType, target: code, path, line: lineNumber, raw: relationText, valid: false });
6801
6849
  }
6802
6850
  });
6803
6851
  return result;
@@ -6820,7 +6868,8 @@ function relationOccurrences(node, field, targetType) {
6820
6868
  target,
6821
6869
  path: typeof record.path === "string" ? record.path : node.path,
6822
6870
  line: typeof record.line === "number" ? record.line : node.line,
6823
- raw: typeof record.raw === "string" ? record.raw : void 0
6871
+ raw: typeof record.raw === "string" ? record.raw : void 0,
6872
+ valid: typeof record.valid === "boolean" ? record.valid : void 0
6824
6873
  }];
6825
6874
  });
6826
6875
  }
@@ -6835,28 +6884,40 @@ function pushDuplicateIssues(issues, nodeUid, refs, targetType) {
6835
6884
  }
6836
6885
  }
6837
6886
  function isValidRelationOccurrence(ref) {
6887
+ if (ref.valid !== void 0) {
6888
+ return ref.valid;
6889
+ }
6838
6890
  return relationTargetPattern(ref.targetType).test(ref.target);
6839
6891
  }
6840
- function invalidRelationCandidates(text, targetType, validCodes) {
6841
- const valid = new Set(validCodes);
6842
- const candidates = text.split(/[\s,,、;;()[\]()]+/).map((token) => token.trim().replace(/^["'`]+|["'`.::]+$/g, "")).filter(Boolean);
6843
- const pattern = relationTargetPattern(targetType);
6844
- return [...new Set(candidates.filter((candidate) => looksLikeRelationId(candidate, targetType) && !valid.has(candidate) && !pattern.test(candidate)))];
6892
+ function relationIdPattern(targetType, schema) {
6893
+ return new RegExp(schema.idPatterns[targetType] ?? DEFAULT_SCHEMA.idPatterns[targetType] ?? "^.+$");
6845
6894
  }
6846
- function looksLikeRelationId(candidate, targetType) {
6847
- if (targetType === "feature") {
6848
- return /^[A-Z][A-Z0-9-]*$/.test(candidate) && (/\d/.test(candidate) || candidate.includes("-")) && !/^AC\d+$/.test(candidate);
6849
- }
6850
- if (targetType === "scenario") {
6851
- return /^S[-A-Z0-9]+[a-z]?$/.test(candidate);
6852
- }
6853
- if (targetType === "decision") {
6854
- return /^D[-A-Z0-9]+$/.test(candidate);
6895
+ function stripRelationItemAnnotation(item) {
6896
+ let result = item.trim();
6897
+ let previous = "";
6898
+ while (result !== previous) {
6899
+ previous = result;
6900
+ result = result.replace(/\s*[((][^()()]*[))]\s*$/, "").trim();
6901
+ result = result.replace(/\s+[—–-]\s+.*$/, "").trim();
6855
6902
  }
6856
- if (targetType === "entity") {
6857
- return /^E[-0-9]+$/.test(candidate);
6903
+ return result;
6904
+ }
6905
+ function parseExplicitRelationField(text, targetType, schema) {
6906
+ const pattern = relationIdPattern(targetType, schema);
6907
+ const valid = [];
6908
+ const invalid = [];
6909
+ for (const item of text.split(/[,,、;;]/)) {
6910
+ const candidate = stripRelationItemAnnotation(item);
6911
+ if (!candidate || EMPTY_RELATION_FIELD_VALUES.has(candidate.toLowerCase())) {
6912
+ continue;
6913
+ }
6914
+ if (pattern.test(candidate)) {
6915
+ valid.push(candidate);
6916
+ } else if (!invalid.includes(candidate)) {
6917
+ invalid.push(candidate);
6918
+ }
6858
6919
  }
6859
- return false;
6920
+ return { valid, invalid };
6860
6921
  }
6861
6922
  function relationTargetPattern(targetType) {
6862
6923
  const patterns = {
@@ -7979,21 +8040,19 @@ function globToRegExp(pattern) {
7979
8040
  }
7980
8041
  return new RegExp(`${source}$`);
7981
8042
  }
7982
- function normalizeUid(value) {
8043
+ function resolveQueryStartUid(graph, value) {
7983
8044
  if (value.includes(":")) {
7984
8045
  return value;
7985
8046
  }
7986
- if (/^S-\d+[a-z]?$/.test(value)) {
7987
- return toUid("scenario", value);
8047
+ const matches = graph.nodes.filter((node) => node.code === value);
8048
+ if (matches.length === 1) {
8049
+ return matches[0].uid;
7988
8050
  }
7989
- if (/^D-[A-Z]+-\d+$/.test(value)) {
7990
- return toUid("decision", value);
7991
- }
7992
- if (/^E-\d{3,}$/.test(value)) {
7993
- return toUid("entity", value);
7994
- }
7995
- if (/^[A-Z]{1,4}\d+$/.test(value)) {
7996
- return toUid("feature", value);
8051
+ if (matches.length > 1) {
8052
+ const candidates = matches.map((node) => node.uid).sort();
8053
+ throw new Error(
8054
+ `Ambiguous artifact id "${value}": matches ${candidates.join(", ")}. Use a fully qualified uid (type:id), for example ${candidates[0]}`
8055
+ );
7997
8056
  }
7998
8057
  return value;
7999
8058
  }
@@ -8497,7 +8556,7 @@ function formatContextMarkdown(manifest) {
8497
8556
  }
8498
8557
  return lines.join("\n");
8499
8558
  }
8500
- var TARGET_ARTIFACT_TYPES, NON_TARGET_ROLES, DEFAULT_SCHEMA, VALID_TC_STATUSES, VALID_CHAIN_TYPES, DEPRECATED_CHAIN_TYPE_ALIASES, CONTEXT_CATEGORIES, TIER_ORDER;
8559
+ var TARGET_ARTIFACT_TYPES, NON_TARGET_ROLES, DEFAULT_SCHEMA, EMPTY_RELATION_FIELD_VALUES, VALID_TC_STATUSES, VALID_CHAIN_TYPES, DEPRECATED_CHAIN_TYPE_ALIASES, CONTEXT_CATEGORIES, TIER_ORDER;
8501
8560
  var init_index = __esm({
8502
8561
  "src/index.ts"() {
8503
8562
  "use strict";
@@ -8551,7 +8610,7 @@ var init_index = __esm({
8551
8610
  relationFields: {
8552
8611
  feature: ["scenarios", "decisions", "depends_on", "design_docs"],
8553
8612
  scenario: ["\u5173\u8054\u529F\u80FD", "\u5173\u8054\u51B3\u7B56"],
8554
- design: ["related_features", "related_scenarios"],
8613
+ design: ["related_features", "related_scenarios", "related_decisions"],
8555
8614
  test: ["@scenario", "@feature", "@entity", "@decision"],
8556
8615
  e2e_test: ["test_batch", "scope", "ac_coverage", "related_scenarios", "related_decisions", "related_entities", "\u8986\u76D6\u573A\u666F", "\u8986\u76D6\u529F\u80FD"],
8557
8616
  e2e_registry: ["batches"]
@@ -8566,6 +8625,7 @@ var init_index = __esm({
8566
8625
  runners: []
8567
8626
  }
8568
8627
  };
8628
+ EMPTY_RELATION_FIELD_VALUES = /* @__PURE__ */ new Set(["\u65E0", "none", "n/a", "-"]);
8569
8629
  VALID_TC_STATUSES = /* @__PURE__ */ new Set(["created", "automated", "verified", "waived"]);
8570
8630
  VALID_CHAIN_TYPES = /* @__PURE__ */ new Set([
8571
8631
  "desktop_chain",
package/dist/index.cjs CHANGED
@@ -5050,6 +5050,7 @@ function validateScenarioPrdLinks(graph, schema = DEFAULT_SCHEMA) {
5050
5050
  }
5051
5051
  pushDuplicateIssues(issues, scenarioUid, validRefs, "feature");
5052
5052
  for (const ref of refs) {
5053
+ if (ref.valid === false) continue;
5053
5054
  if (!featurePattern.test(ref.target)) {
5054
5055
  issues.push(issue("FORMAT_ERROR", `scenario ${scenarioUid} has invalid feature reference ${ref.target}`, ref.path, ref.line, { node: scenarioUid }));
5055
5056
  }
@@ -5246,7 +5247,7 @@ function validateCodeCommentScenarioFeatureConsistency(graph) {
5246
5247
  }
5247
5248
  function queryGraph(graph, options) {
5248
5249
  const depth = options.depth ?? 1;
5249
- const start = normalizeUid(options.from ?? options.to ?? "");
5250
+ const start = resolveQueryStartUid(graph, options.from ?? options.to ?? "");
5250
5251
  const reverse = Boolean(options.to && !options.from);
5251
5252
  const selected = /* @__PURE__ */ new Set([start]);
5252
5253
  let frontier = /* @__PURE__ */ new Set([start]);
@@ -5348,7 +5349,7 @@ function parseFile(type, path, raw, schema = DEFAULT_SCHEMA) {
5348
5349
  return { ...parseFeature(path, raw), diagnostics: [] };
5349
5350
  }
5350
5351
  if (type === "scenario") {
5351
- return { ...parseScenarios(path, raw, schema), diagnostics: [] };
5352
+ return parseScenarios(path, raw, schema);
5352
5353
  }
5353
5354
  if (type === "entity") {
5354
5355
  return { ...parseEntityRegistry(path, raw), diagnostics: [] };
@@ -5360,7 +5361,7 @@ function parseFile(type, path, raw, schema = DEFAULT_SCHEMA) {
5360
5361
  return { ...parseTest(path, raw, schema), diagnostics: [] };
5361
5362
  }
5362
5363
  if (type === "design") {
5363
- return { ...parseDesign(path, raw), diagnostics: [] };
5364
+ return parseDesign(path, raw, schema);
5364
5365
  }
5365
5366
  if (type === "e2e_test") {
5366
5367
  return { ...parseE2eTest(path, raw), diagnostics: [] };
@@ -5562,13 +5563,14 @@ function parseScenarios(path, raw, schema = DEFAULT_SCHEMA) {
5562
5563
  });
5563
5564
  const nodes = [];
5564
5565
  const edges = [];
5566
+ const diagnostics = [];
5565
5567
  for (let i = 0; i < starts.length; i += 1) {
5566
5568
  const start = starts[i];
5567
5569
  const end = starts[i + 1]?.index ?? lines.length;
5568
5570
  const block = lines.slice(start.index, end);
5569
- const featureRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u529F\u80FD", "feature");
5570
- const decisionRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u51B3\u7B56", "decision");
5571
- const entityRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u5B9E\u4F53", "entity");
5571
+ const featureRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u529F\u80FD", "feature", schema);
5572
+ const decisionRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u51B3\u7B56", "decision", schema);
5573
+ const entityRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u5B9E\u4F53", "entity", schema);
5572
5574
  nodes.push({
5573
5575
  type: "scenario",
5574
5576
  code: start.code,
@@ -5586,8 +5588,19 @@ function parseScenarios(path, raw, schema = DEFAULT_SCHEMA) {
5586
5588
  edges.push(...featureRefs.filter(isValidRelationOccurrence).map((ref) => edge(toUid("scenario", start.code), toUid("feature", ref.target), "references", "markdown", path, ref.line)));
5587
5589
  edges.push(...decisionRefs.filter(isValidRelationOccurrence).map((ref) => edge(toUid("scenario", start.code), toUid("decision", ref.target), "references", "markdown", path, ref.line)));
5588
5590
  edges.push(...entityRefs.filter(isValidRelationOccurrence).map((ref) => edge(toUid("scenario", start.code), toUid("entity", ref.target), "references", "markdown", path, ref.line)));
5591
+ for (const ref of [...featureRefs, ...decisionRefs, ...entityRefs]) {
5592
+ if (ref.valid === false) {
5593
+ diagnostics.push(issue(
5594
+ "FORMAT_ERROR",
5595
+ `scenario:${start.code} has invalid ${ref.targetType} reference "${ref.target}" in field ${ref.field} (expected ${schema.idPatterns[ref.targetType] ?? DEFAULT_SCHEMA.idPatterns[ref.targetType]})`,
5596
+ ref.path,
5597
+ ref.line,
5598
+ { node: toUid("scenario", start.code) }
5599
+ ));
5600
+ }
5601
+ }
5589
5602
  }
5590
- return { nodes, edges };
5603
+ return { nodes, edges, diagnostics };
5591
5604
  }
5592
5605
  function parseEntityRegistry(path, raw) {
5593
5606
  const nodes = [];
@@ -5899,7 +5912,7 @@ function containsTraceabilityTag(value, schema = DEFAULT_SCHEMA) {
5899
5912
  const alternatives = [...tokens].sort((a, b) => b.length - a.length).map((token) => token.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")).join("|");
5900
5913
  return new RegExp(`(?:^|\\s)@(?:${alternatives})(?=\\s|$)`).test(value);
5901
5914
  }
5902
- function parseDesign(path, raw) {
5915
+ function parseDesign(path, raw, schema = DEFAULT_SCHEMA) {
5903
5916
  const parsed = (0, import_gray_matter.default)(raw);
5904
5917
  const data = parsed.data;
5905
5918
  const code = normalizeDesignCode(path);
@@ -5909,15 +5922,45 @@ function parseDesign(path, raw) {
5909
5922
  ...designFrontmatterEdges(path, code, data.related_features, "feature", "references"),
5910
5923
  ...designFrontmatterEdges(path, code, data.related_scenarios, "scenario", "references")
5911
5924
  ];
5912
- raw.split(/\r?\n/).forEach((line, index) => {
5925
+ const diagnostics = [];
5926
+ const decisionPattern = relationIdPattern("decision", schema);
5927
+ const frontmatterDecisions = /* @__PURE__ */ new Set();
5928
+ for (const value of toArray(data.related_decisions)) {
5929
+ const candidate = String(value).trim();
5930
+ if (!candidate || EMPTY_RELATION_FIELD_VALUES.has(candidate.toLowerCase())) {
5931
+ continue;
5932
+ }
5933
+ if (!decisionPattern.test(candidate)) {
5934
+ diagnostics.push(issue(
5935
+ "FORMAT_ERROR",
5936
+ `design:${code} has invalid decision reference "${candidate}" in related_decisions (expected ${schema.idPatterns.decision ?? DEFAULT_SCHEMA.idPatterns.decision})`,
5937
+ path,
5938
+ 1,
5939
+ { node: toUid("design", code) }
5940
+ ));
5941
+ continue;
5942
+ }
5943
+ if (frontmatterDecisions.has(candidate)) {
5944
+ continue;
5945
+ }
5946
+ frontmatterDecisions.add(candidate);
5947
+ edges.push(edge(toUid("design", code), toUid("decision", candidate), "references", "frontmatter", path, 1));
5948
+ }
5949
+ const hasFrontmatter = /^---\r?\n/.test(raw);
5950
+ const contentStart = hasFrontmatter && parsed.content ? Math.max(raw.indexOf(parsed.content), 0) : 0;
5951
+ const lineOffset = contentStart > 0 ? raw.slice(0, contentStart).split(/\r?\n/).length - 1 : 0;
5952
+ parsed.content.split(/\r?\n/).forEach((line, index) => {
5913
5953
  for (const codeValue of extractCodes(line, "entity")) {
5914
- edges.push(edge(toUid("design", code), toUid("entity", codeValue), "references", "markdown", path, index + 1));
5954
+ edges.push(edge(toUid("design", code), toUid("entity", codeValue), "references", "markdown", path, lineOffset + index + 1));
5915
5955
  }
5916
5956
  for (const codeValue of extractCodes(line, "decision")) {
5917
- edges.push(edge(toUid("design", code), toUid("decision", codeValue), "references", "markdown", path, index + 1));
5957
+ if (frontmatterDecisions.has(codeValue)) {
5958
+ continue;
5959
+ }
5960
+ edges.push(edge(toUid("design", code), toUid("decision", codeValue), "references", "markdown", path, lineOffset + index + 1));
5918
5961
  }
5919
5962
  });
5920
- return { nodes, edges };
5963
+ return { nodes, edges, diagnostics };
5921
5964
  }
5922
5965
  function parseE2eTest(path, raw) {
5923
5966
  const parsed = (0, import_gray_matter.default)(raw);
@@ -6762,20 +6805,25 @@ function decisionFrontmatterEdges(path, decisionCode, value, targetType, kind) {
6762
6805
  return [edge(toUid("decision", decisionCode), toUid(targetType, code), kind, "frontmatter", path, 1)];
6763
6806
  });
6764
6807
  }
6765
- function markdownRelationOccurrences(path, scenario, block, label, targetType, schema) {
6808
+ function matchExplicitRelationFieldLine(line, label) {
6809
+ const escaped = escapeRegExp(label);
6810
+ const match = new RegExp(`^\\s*(?:\\*\\*${escaped}\\*\\*|${escaped})[:\uFF1A]\\s*(.*?)\\s*$`).exec(line);
6811
+ return match?.[1] ?? null;
6812
+ }
6813
+ function markdownRelationOccurrences(path, scenario, block, label, targetType, schema = DEFAULT_SCHEMA) {
6766
6814
  const result = [];
6767
6815
  block.forEach((line, index) => {
6768
- if (!line.includes(label)) {
6816
+ const relationText = matchExplicitRelationFieldLine(line, label);
6817
+ if (!relationText) {
6769
6818
  return;
6770
6819
  }
6771
- const relationText = line.split(/[::]/).slice(1).join(":").split(/\s+[—-]\s+/)[0] ?? line;
6772
6820
  const lineNumber = scenario.index + index + 1;
6773
- const codes = extractCodes(relationText, targetType, schema);
6774
- for (const code of codes) {
6775
- result.push({ field: label, targetType, target: code, path, line: lineNumber, raw: relationText.trim() });
6821
+ const { valid, invalid } = parseExplicitRelationField(relationText, targetType, schema);
6822
+ for (const code of valid) {
6823
+ result.push({ field: label, targetType, target: code, path, line: lineNumber, raw: relationText, valid: true });
6776
6824
  }
6777
- for (const invalid of invalidRelationCandidates(relationText, targetType, codes)) {
6778
- result.push({ field: label, targetType, target: invalid, path, line: lineNumber, raw: relationText.trim() });
6825
+ for (const code of invalid) {
6826
+ result.push({ field: label, targetType, target: code, path, line: lineNumber, raw: relationText, valid: false });
6779
6827
  }
6780
6828
  });
6781
6829
  return result;
@@ -6798,7 +6846,8 @@ function relationOccurrences(node, field, targetType) {
6798
6846
  target,
6799
6847
  path: typeof record.path === "string" ? record.path : node.path,
6800
6848
  line: typeof record.line === "number" ? record.line : node.line,
6801
- raw: typeof record.raw === "string" ? record.raw : void 0
6849
+ raw: typeof record.raw === "string" ? record.raw : void 0,
6850
+ valid: typeof record.valid === "boolean" ? record.valid : void 0
6802
6851
  }];
6803
6852
  });
6804
6853
  }
@@ -6813,28 +6862,40 @@ function pushDuplicateIssues(issues, nodeUid, refs, targetType) {
6813
6862
  }
6814
6863
  }
6815
6864
  function isValidRelationOccurrence(ref) {
6865
+ if (ref.valid !== void 0) {
6866
+ return ref.valid;
6867
+ }
6816
6868
  return relationTargetPattern(ref.targetType).test(ref.target);
6817
6869
  }
6818
- function invalidRelationCandidates(text, targetType, validCodes) {
6819
- const valid = new Set(validCodes);
6820
- const candidates = text.split(/[\s,,、;;()[\]()]+/).map((token) => token.trim().replace(/^["'`]+|["'`.::]+$/g, "")).filter(Boolean);
6821
- const pattern = relationTargetPattern(targetType);
6822
- return [...new Set(candidates.filter((candidate) => looksLikeRelationId(candidate, targetType) && !valid.has(candidate) && !pattern.test(candidate)))];
6870
+ function relationIdPattern(targetType, schema) {
6871
+ return new RegExp(schema.idPatterns[targetType] ?? DEFAULT_SCHEMA.idPatterns[targetType] ?? "^.+$");
6823
6872
  }
6824
- function looksLikeRelationId(candidate, targetType) {
6825
- if (targetType === "feature") {
6826
- return /^[A-Z][A-Z0-9-]*$/.test(candidate) && (/\d/.test(candidate) || candidate.includes("-")) && !/^AC\d+$/.test(candidate);
6827
- }
6828
- if (targetType === "scenario") {
6829
- return /^S[-A-Z0-9]+[a-z]?$/.test(candidate);
6830
- }
6831
- if (targetType === "decision") {
6832
- return /^D[-A-Z0-9]+$/.test(candidate);
6873
+ function stripRelationItemAnnotation(item) {
6874
+ let result = item.trim();
6875
+ let previous = "";
6876
+ while (result !== previous) {
6877
+ previous = result;
6878
+ result = result.replace(/\s*[((][^()()]*[))]\s*$/, "").trim();
6879
+ result = result.replace(/\s+[—–-]\s+.*$/, "").trim();
6833
6880
  }
6834
- if (targetType === "entity") {
6835
- return /^E[-0-9]+$/.test(candidate);
6881
+ return result;
6882
+ }
6883
+ function parseExplicitRelationField(text, targetType, schema) {
6884
+ const pattern = relationIdPattern(targetType, schema);
6885
+ const valid = [];
6886
+ const invalid = [];
6887
+ for (const item of text.split(/[,,、;;]/)) {
6888
+ const candidate = stripRelationItemAnnotation(item);
6889
+ if (!candidate || EMPTY_RELATION_FIELD_VALUES.has(candidate.toLowerCase())) {
6890
+ continue;
6891
+ }
6892
+ if (pattern.test(candidate)) {
6893
+ valid.push(candidate);
6894
+ } else if (!invalid.includes(candidate)) {
6895
+ invalid.push(candidate);
6896
+ }
6836
6897
  }
6837
- return false;
6898
+ return { valid, invalid };
6838
6899
  }
6839
6900
  function relationTargetPattern(targetType) {
6840
6901
  const patterns = {
@@ -7957,21 +8018,19 @@ function globToRegExp(pattern) {
7957
8018
  }
7958
8019
  return new RegExp(`${source}$`);
7959
8020
  }
7960
- function normalizeUid(value) {
8021
+ function resolveQueryStartUid(graph, value) {
7961
8022
  if (value.includes(":")) {
7962
8023
  return value;
7963
8024
  }
7964
- if (/^S-\d+[a-z]?$/.test(value)) {
7965
- return toUid("scenario", value);
8025
+ const matches = graph.nodes.filter((node) => node.code === value);
8026
+ if (matches.length === 1) {
8027
+ return matches[0].uid;
7966
8028
  }
7967
- if (/^D-[A-Z]+-\d+$/.test(value)) {
7968
- return toUid("decision", value);
7969
- }
7970
- if (/^E-\d{3,}$/.test(value)) {
7971
- return toUid("entity", value);
7972
- }
7973
- if (/^[A-Z]{1,4}\d+$/.test(value)) {
7974
- return toUid("feature", value);
8029
+ if (matches.length > 1) {
8030
+ const candidates = matches.map((node) => node.uid).sort();
8031
+ throw new Error(
8032
+ `Ambiguous artifact id "${value}": matches ${candidates.join(", ")}. Use a fully qualified uid (type:id), for example ${candidates[0]}`
8033
+ );
7975
8034
  }
7976
8035
  return value;
7977
8036
  }
@@ -8475,7 +8534,7 @@ function formatContextMarkdown(manifest) {
8475
8534
  }
8476
8535
  return lines.join("\n");
8477
8536
  }
8478
- var import_better_sqlite3, import_gray_matter, import_js_yaml, import_node_fs4, import_promises7, import_node_path8, TARGET_ARTIFACT_TYPES, NON_TARGET_ROLES, DEFAULT_SCHEMA, VALID_TC_STATUSES, VALID_CHAIN_TYPES, DEPRECATED_CHAIN_TYPE_ALIASES, CONTEXT_CATEGORIES, TIER_ORDER;
8537
+ var import_better_sqlite3, import_gray_matter, import_js_yaml, import_node_fs4, import_promises7, import_node_path8, TARGET_ARTIFACT_TYPES, NON_TARGET_ROLES, DEFAULT_SCHEMA, EMPTY_RELATION_FIELD_VALUES, VALID_TC_STATUSES, VALID_CHAIN_TYPES, DEPRECATED_CHAIN_TYPE_ALIASES, CONTEXT_CATEGORIES, TIER_ORDER;
8479
8538
  var init_index = __esm({
8480
8539
  "src/index.ts"() {
8481
8540
  import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
@@ -8534,7 +8593,7 @@ var init_index = __esm({
8534
8593
  relationFields: {
8535
8594
  feature: ["scenarios", "decisions", "depends_on", "design_docs"],
8536
8595
  scenario: ["\u5173\u8054\u529F\u80FD", "\u5173\u8054\u51B3\u7B56"],
8537
- design: ["related_features", "related_scenarios"],
8596
+ design: ["related_features", "related_scenarios", "related_decisions"],
8538
8597
  test: ["@scenario", "@feature", "@entity", "@decision"],
8539
8598
  e2e_test: ["test_batch", "scope", "ac_coverage", "related_scenarios", "related_decisions", "related_entities", "\u8986\u76D6\u573A\u666F", "\u8986\u76D6\u529F\u80FD"],
8540
8599
  e2e_registry: ["batches"]
@@ -8549,6 +8608,7 @@ var init_index = __esm({
8549
8608
  runners: []
8550
8609
  }
8551
8610
  };
8611
+ EMPTY_RELATION_FIELD_VALUES = /* @__PURE__ */ new Set(["\u65E0", "none", "n/a", "-"]);
8552
8612
  VALID_TC_STATUSES = /* @__PURE__ */ new Set(["created", "automated", "verified", "waived"]);
8553
8613
  VALID_CHAIN_TYPES = /* @__PURE__ */ new Set([
8554
8614
  "desktop_chain",
package/dist/index.js CHANGED
@@ -4940,6 +4940,7 @@ function validateScenarioPrdLinks(graph, schema = DEFAULT_SCHEMA) {
4940
4940
  }
4941
4941
  pushDuplicateIssues(issues, scenarioUid, validRefs, "feature");
4942
4942
  for (const ref of refs) {
4943
+ if (ref.valid === false) continue;
4943
4944
  if (!featurePattern.test(ref.target)) {
4944
4945
  issues.push(issue("FORMAT_ERROR", `scenario ${scenarioUid} has invalid feature reference ${ref.target}`, ref.path, ref.line, { node: scenarioUid }));
4945
4946
  }
@@ -5136,7 +5137,7 @@ function validateCodeCommentScenarioFeatureConsistency(graph) {
5136
5137
  }
5137
5138
  function queryGraph(graph, options) {
5138
5139
  const depth = options.depth ?? 1;
5139
- const start = normalizeUid(options.from ?? options.to ?? "");
5140
+ const start = resolveQueryStartUid(graph, options.from ?? options.to ?? "");
5140
5141
  const reverse = Boolean(options.to && !options.from);
5141
5142
  const selected = /* @__PURE__ */ new Set([start]);
5142
5143
  let frontier = /* @__PURE__ */ new Set([start]);
@@ -5238,7 +5239,7 @@ function parseFile(type, path, raw, schema = DEFAULT_SCHEMA) {
5238
5239
  return { ...parseFeature(path, raw), diagnostics: [] };
5239
5240
  }
5240
5241
  if (type === "scenario") {
5241
- return { ...parseScenarios(path, raw, schema), diagnostics: [] };
5242
+ return parseScenarios(path, raw, schema);
5242
5243
  }
5243
5244
  if (type === "entity") {
5244
5245
  return { ...parseEntityRegistry(path, raw), diagnostics: [] };
@@ -5250,7 +5251,7 @@ function parseFile(type, path, raw, schema = DEFAULT_SCHEMA) {
5250
5251
  return { ...parseTest(path, raw, schema), diagnostics: [] };
5251
5252
  }
5252
5253
  if (type === "design") {
5253
- return { ...parseDesign(path, raw), diagnostics: [] };
5254
+ return parseDesign(path, raw, schema);
5254
5255
  }
5255
5256
  if (type === "e2e_test") {
5256
5257
  return { ...parseE2eTest(path, raw), diagnostics: [] };
@@ -5452,13 +5453,14 @@ function parseScenarios(path, raw, schema = DEFAULT_SCHEMA) {
5452
5453
  });
5453
5454
  const nodes = [];
5454
5455
  const edges = [];
5456
+ const diagnostics = [];
5455
5457
  for (let i = 0; i < starts.length; i += 1) {
5456
5458
  const start = starts[i];
5457
5459
  const end = starts[i + 1]?.index ?? lines.length;
5458
5460
  const block = lines.slice(start.index, end);
5459
- const featureRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u529F\u80FD", "feature");
5460
- const decisionRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u51B3\u7B56", "decision");
5461
- const entityRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u5B9E\u4F53", "entity");
5461
+ const featureRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u529F\u80FD", "feature", schema);
5462
+ const decisionRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u51B3\u7B56", "decision", schema);
5463
+ const entityRefs = markdownRelationOccurrences(path, start, block, "\u5173\u8054\u5B9E\u4F53", "entity", schema);
5462
5464
  nodes.push({
5463
5465
  type: "scenario",
5464
5466
  code: start.code,
@@ -5476,8 +5478,19 @@ function parseScenarios(path, raw, schema = DEFAULT_SCHEMA) {
5476
5478
  edges.push(...featureRefs.filter(isValidRelationOccurrence).map((ref) => edge(toUid("scenario", start.code), toUid("feature", ref.target), "references", "markdown", path, ref.line)));
5477
5479
  edges.push(...decisionRefs.filter(isValidRelationOccurrence).map((ref) => edge(toUid("scenario", start.code), toUid("decision", ref.target), "references", "markdown", path, ref.line)));
5478
5480
  edges.push(...entityRefs.filter(isValidRelationOccurrence).map((ref) => edge(toUid("scenario", start.code), toUid("entity", ref.target), "references", "markdown", path, ref.line)));
5481
+ for (const ref of [...featureRefs, ...decisionRefs, ...entityRefs]) {
5482
+ if (ref.valid === false) {
5483
+ diagnostics.push(issue(
5484
+ "FORMAT_ERROR",
5485
+ `scenario:${start.code} has invalid ${ref.targetType} reference "${ref.target}" in field ${ref.field} (expected ${schema.idPatterns[ref.targetType] ?? DEFAULT_SCHEMA.idPatterns[ref.targetType]})`,
5486
+ ref.path,
5487
+ ref.line,
5488
+ { node: toUid("scenario", start.code) }
5489
+ ));
5490
+ }
5491
+ }
5479
5492
  }
5480
- return { nodes, edges };
5493
+ return { nodes, edges, diagnostics };
5481
5494
  }
5482
5495
  function parseEntityRegistry(path, raw) {
5483
5496
  const nodes = [];
@@ -5789,7 +5802,7 @@ function containsTraceabilityTag(value, schema = DEFAULT_SCHEMA) {
5789
5802
  const alternatives = [...tokens].sort((a, b) => b.length - a.length).map((token) => token.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")).join("|");
5790
5803
  return new RegExp(`(?:^|\\s)@(?:${alternatives})(?=\\s|$)`).test(value);
5791
5804
  }
5792
- function parseDesign(path, raw) {
5805
+ function parseDesign(path, raw, schema = DEFAULT_SCHEMA) {
5793
5806
  const parsed = matter(raw);
5794
5807
  const data = parsed.data;
5795
5808
  const code = normalizeDesignCode(path);
@@ -5799,15 +5812,45 @@ function parseDesign(path, raw) {
5799
5812
  ...designFrontmatterEdges(path, code, data.related_features, "feature", "references"),
5800
5813
  ...designFrontmatterEdges(path, code, data.related_scenarios, "scenario", "references")
5801
5814
  ];
5802
- raw.split(/\r?\n/).forEach((line, index) => {
5815
+ const diagnostics = [];
5816
+ const decisionPattern = relationIdPattern("decision", schema);
5817
+ const frontmatterDecisions = /* @__PURE__ */ new Set();
5818
+ for (const value of toArray(data.related_decisions)) {
5819
+ const candidate = String(value).trim();
5820
+ if (!candidate || EMPTY_RELATION_FIELD_VALUES.has(candidate.toLowerCase())) {
5821
+ continue;
5822
+ }
5823
+ if (!decisionPattern.test(candidate)) {
5824
+ diagnostics.push(issue(
5825
+ "FORMAT_ERROR",
5826
+ `design:${code} has invalid decision reference "${candidate}" in related_decisions (expected ${schema.idPatterns.decision ?? DEFAULT_SCHEMA.idPatterns.decision})`,
5827
+ path,
5828
+ 1,
5829
+ { node: toUid("design", code) }
5830
+ ));
5831
+ continue;
5832
+ }
5833
+ if (frontmatterDecisions.has(candidate)) {
5834
+ continue;
5835
+ }
5836
+ frontmatterDecisions.add(candidate);
5837
+ edges.push(edge(toUid("design", code), toUid("decision", candidate), "references", "frontmatter", path, 1));
5838
+ }
5839
+ const hasFrontmatter = /^---\r?\n/.test(raw);
5840
+ const contentStart = hasFrontmatter && parsed.content ? Math.max(raw.indexOf(parsed.content), 0) : 0;
5841
+ const lineOffset = contentStart > 0 ? raw.slice(0, contentStart).split(/\r?\n/).length - 1 : 0;
5842
+ parsed.content.split(/\r?\n/).forEach((line, index) => {
5803
5843
  for (const codeValue of extractCodes(line, "entity")) {
5804
- edges.push(edge(toUid("design", code), toUid("entity", codeValue), "references", "markdown", path, index + 1));
5844
+ edges.push(edge(toUid("design", code), toUid("entity", codeValue), "references", "markdown", path, lineOffset + index + 1));
5805
5845
  }
5806
5846
  for (const codeValue of extractCodes(line, "decision")) {
5807
- edges.push(edge(toUid("design", code), toUid("decision", codeValue), "references", "markdown", path, index + 1));
5847
+ if (frontmatterDecisions.has(codeValue)) {
5848
+ continue;
5849
+ }
5850
+ edges.push(edge(toUid("design", code), toUid("decision", codeValue), "references", "markdown", path, lineOffset + index + 1));
5808
5851
  }
5809
5852
  });
5810
- return { nodes, edges };
5853
+ return { nodes, edges, diagnostics };
5811
5854
  }
5812
5855
  function parseE2eTest(path, raw) {
5813
5856
  const parsed = matter(raw);
@@ -6652,20 +6695,25 @@ function decisionFrontmatterEdges(path, decisionCode, value, targetType, kind) {
6652
6695
  return [edge(toUid("decision", decisionCode), toUid(targetType, code), kind, "frontmatter", path, 1)];
6653
6696
  });
6654
6697
  }
6655
- function markdownRelationOccurrences(path, scenario, block, label, targetType, schema) {
6698
+ function matchExplicitRelationFieldLine(line, label) {
6699
+ const escaped = escapeRegExp(label);
6700
+ const match = new RegExp(`^\\s*(?:\\*\\*${escaped}\\*\\*|${escaped})[:\uFF1A]\\s*(.*?)\\s*$`).exec(line);
6701
+ return match?.[1] ?? null;
6702
+ }
6703
+ function markdownRelationOccurrences(path, scenario, block, label, targetType, schema = DEFAULT_SCHEMA) {
6656
6704
  const result = [];
6657
6705
  block.forEach((line, index) => {
6658
- if (!line.includes(label)) {
6706
+ const relationText = matchExplicitRelationFieldLine(line, label);
6707
+ if (!relationText) {
6659
6708
  return;
6660
6709
  }
6661
- const relationText = line.split(/[::]/).slice(1).join(":").split(/\s+[—-]\s+/)[0] ?? line;
6662
6710
  const lineNumber = scenario.index + index + 1;
6663
- const codes = extractCodes(relationText, targetType, schema);
6664
- for (const code of codes) {
6665
- result.push({ field: label, targetType, target: code, path, line: lineNumber, raw: relationText.trim() });
6711
+ const { valid, invalid } = parseExplicitRelationField(relationText, targetType, schema);
6712
+ for (const code of valid) {
6713
+ result.push({ field: label, targetType, target: code, path, line: lineNumber, raw: relationText, valid: true });
6666
6714
  }
6667
- for (const invalid of invalidRelationCandidates(relationText, targetType, codes)) {
6668
- result.push({ field: label, targetType, target: invalid, path, line: lineNumber, raw: relationText.trim() });
6715
+ for (const code of invalid) {
6716
+ result.push({ field: label, targetType, target: code, path, line: lineNumber, raw: relationText, valid: false });
6669
6717
  }
6670
6718
  });
6671
6719
  return result;
@@ -6688,7 +6736,8 @@ function relationOccurrences(node, field, targetType) {
6688
6736
  target,
6689
6737
  path: typeof record.path === "string" ? record.path : node.path,
6690
6738
  line: typeof record.line === "number" ? record.line : node.line,
6691
- raw: typeof record.raw === "string" ? record.raw : void 0
6739
+ raw: typeof record.raw === "string" ? record.raw : void 0,
6740
+ valid: typeof record.valid === "boolean" ? record.valid : void 0
6692
6741
  }];
6693
6742
  });
6694
6743
  }
@@ -6703,28 +6752,40 @@ function pushDuplicateIssues(issues, nodeUid, refs, targetType) {
6703
6752
  }
6704
6753
  }
6705
6754
  function isValidRelationOccurrence(ref) {
6755
+ if (ref.valid !== void 0) {
6756
+ return ref.valid;
6757
+ }
6706
6758
  return relationTargetPattern(ref.targetType).test(ref.target);
6707
6759
  }
6708
- function invalidRelationCandidates(text, targetType, validCodes) {
6709
- const valid = new Set(validCodes);
6710
- const candidates = text.split(/[\s,,、;;()[\]()]+/).map((token) => token.trim().replace(/^["'`]+|["'`.::]+$/g, "")).filter(Boolean);
6711
- const pattern = relationTargetPattern(targetType);
6712
- return [...new Set(candidates.filter((candidate) => looksLikeRelationId(candidate, targetType) && !valid.has(candidate) && !pattern.test(candidate)))];
6760
+ function relationIdPattern(targetType, schema) {
6761
+ return new RegExp(schema.idPatterns[targetType] ?? DEFAULT_SCHEMA.idPatterns[targetType] ?? "^.+$");
6713
6762
  }
6714
- function looksLikeRelationId(candidate, targetType) {
6715
- if (targetType === "feature") {
6716
- return /^[A-Z][A-Z0-9-]*$/.test(candidate) && (/\d/.test(candidate) || candidate.includes("-")) && !/^AC\d+$/.test(candidate);
6717
- }
6718
- if (targetType === "scenario") {
6719
- return /^S[-A-Z0-9]+[a-z]?$/.test(candidate);
6720
- }
6721
- if (targetType === "decision") {
6722
- return /^D[-A-Z0-9]+$/.test(candidate);
6763
+ function stripRelationItemAnnotation(item) {
6764
+ let result = item.trim();
6765
+ let previous = "";
6766
+ while (result !== previous) {
6767
+ previous = result;
6768
+ result = result.replace(/\s*[((][^()()]*[))]\s*$/, "").trim();
6769
+ result = result.replace(/\s+[—–-]\s+.*$/, "").trim();
6723
6770
  }
6724
- if (targetType === "entity") {
6725
- return /^E[-0-9]+$/.test(candidate);
6771
+ return result;
6772
+ }
6773
+ function parseExplicitRelationField(text, targetType, schema) {
6774
+ const pattern = relationIdPattern(targetType, schema);
6775
+ const valid = [];
6776
+ const invalid = [];
6777
+ for (const item of text.split(/[,,、;;]/)) {
6778
+ const candidate = stripRelationItemAnnotation(item);
6779
+ if (!candidate || EMPTY_RELATION_FIELD_VALUES.has(candidate.toLowerCase())) {
6780
+ continue;
6781
+ }
6782
+ if (pattern.test(candidate)) {
6783
+ valid.push(candidate);
6784
+ } else if (!invalid.includes(candidate)) {
6785
+ invalid.push(candidate);
6786
+ }
6726
6787
  }
6727
- return false;
6788
+ return { valid, invalid };
6728
6789
  }
6729
6790
  function relationTargetPattern(targetType) {
6730
6791
  const patterns = {
@@ -7847,21 +7908,19 @@ function globToRegExp(pattern) {
7847
7908
  }
7848
7909
  return new RegExp(`${source}$`);
7849
7910
  }
7850
- function normalizeUid(value) {
7911
+ function resolveQueryStartUid(graph, value) {
7851
7912
  if (value.includes(":")) {
7852
7913
  return value;
7853
7914
  }
7854
- if (/^S-\d+[a-z]?$/.test(value)) {
7855
- return toUid("scenario", value);
7915
+ const matches = graph.nodes.filter((node) => node.code === value);
7916
+ if (matches.length === 1) {
7917
+ return matches[0].uid;
7856
7918
  }
7857
- if (/^D-[A-Z]+-\d+$/.test(value)) {
7858
- return toUid("decision", value);
7859
- }
7860
- if (/^E-\d{3,}$/.test(value)) {
7861
- return toUid("entity", value);
7862
- }
7863
- if (/^[A-Z]{1,4}\d+$/.test(value)) {
7864
- return toUid("feature", value);
7919
+ if (matches.length > 1) {
7920
+ const candidates = matches.map((node) => node.uid).sort();
7921
+ throw new Error(
7922
+ `Ambiguous artifact id "${value}": matches ${candidates.join(", ")}. Use a fully qualified uid (type:id), for example ${candidates[0]}`
7923
+ );
7865
7924
  }
7866
7925
  return value;
7867
7926
  }
@@ -8365,7 +8424,7 @@ function formatContextMarkdown(manifest) {
8365
8424
  }
8366
8425
  return lines.join("\n");
8367
8426
  }
8368
- var TARGET_ARTIFACT_TYPES, NON_TARGET_ROLES, DEFAULT_SCHEMA, VALID_TC_STATUSES, VALID_CHAIN_TYPES, DEPRECATED_CHAIN_TYPE_ALIASES, CONTEXT_CATEGORIES, TIER_ORDER;
8427
+ var TARGET_ARTIFACT_TYPES, NON_TARGET_ROLES, DEFAULT_SCHEMA, EMPTY_RELATION_FIELD_VALUES, VALID_TC_STATUSES, VALID_CHAIN_TYPES, DEPRECATED_CHAIN_TYPE_ALIASES, CONTEXT_CATEGORIES, TIER_ORDER;
8369
8428
  var init_index = __esm({
8370
8429
  "src/index.ts"() {
8371
8430
  init_packet_constants();
@@ -8418,7 +8477,7 @@ var init_index = __esm({
8418
8477
  relationFields: {
8419
8478
  feature: ["scenarios", "decisions", "depends_on", "design_docs"],
8420
8479
  scenario: ["\u5173\u8054\u529F\u80FD", "\u5173\u8054\u51B3\u7B56"],
8421
- design: ["related_features", "related_scenarios"],
8480
+ design: ["related_features", "related_scenarios", "related_decisions"],
8422
8481
  test: ["@scenario", "@feature", "@entity", "@decision"],
8423
8482
  e2e_test: ["test_batch", "scope", "ac_coverage", "related_scenarios", "related_decisions", "related_entities", "\u8986\u76D6\u573A\u666F", "\u8986\u76D6\u529F\u80FD"],
8424
8483
  e2e_registry: ["batches"]
@@ -8433,6 +8492,7 @@ var init_index = __esm({
8433
8492
  runners: []
8434
8493
  }
8435
8494
  };
8495
+ EMPTY_RELATION_FIELD_VALUES = /* @__PURE__ */ new Set(["\u65E0", "none", "n/a", "-"]);
8436
8496
  VALID_TC_STATUSES = /* @__PURE__ */ new Set(["created", "automated", "verified", "waived"]);
8437
8497
  VALID_CHAIN_TYPES = /* @__PURE__ */ new Set([
8438
8498
  "desktop_chain",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artifact-graph",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Git-native Markdown artifact graph scanner and validator",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",