qfai 0.3.1 → 0.3.3

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.
@@ -86,12 +86,29 @@ async function exists(target) {
86
86
  }
87
87
 
88
88
  // src/cli/lib/assets.ts
89
+ import { existsSync } from "fs";
89
90
  import path2 from "path";
90
91
  import { fileURLToPath } from "url";
91
92
  function getInitAssetsDir() {
92
93
  const base = import.meta.url;
93
94
  const basePath = base.startsWith("file:") ? fileURLToPath(base) : base;
94
- return path2.resolve(path2.dirname(basePath), "../../assets/init");
95
+ const baseDir = path2.dirname(basePath);
96
+ const candidates = [
97
+ path2.resolve(baseDir, "../../../assets/init"),
98
+ path2.resolve(baseDir, "../../assets/init")
99
+ ];
100
+ for (const candidate of candidates) {
101
+ if (existsSync(candidate)) {
102
+ return candidate;
103
+ }
104
+ }
105
+ throw new Error(
106
+ [
107
+ "init \u7528\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002Template assets not found.",
108
+ "\u78BA\u8A8D\u3057\u305F\u30D1\u30B9 / Checked paths:",
109
+ ...candidates.map((candidate) => `- ${candidate}`)
110
+ ].join("\n")
111
+ );
95
112
  }
96
113
 
97
114
  // src/cli/lib/logger.ts
@@ -478,7 +495,7 @@ import { readFile as readFile10 } from "fs/promises";
478
495
  import path13 from "path";
479
496
 
480
497
  // src/core/discovery.ts
481
- import path6 from "path";
498
+ import { access as access3 } from "fs/promises";
482
499
 
483
500
  // src/core/fs.ts
484
501
  import { access as access2, readdir as readdir2 } from "fs/promises";
@@ -535,25 +552,50 @@ async function exists2(target) {
535
552
  }
536
553
  }
537
554
 
538
- // src/core/discovery.ts
539
- var SPEC_PACK_DIR_PATTERN = /^spec-\d{3}$/;
540
- async function collectSpecPackDirs(specsRoot) {
541
- const files = await collectFiles(specsRoot, { extensions: [".md"] });
542
- const packs = /* @__PURE__ */ new Set();
543
- for (const file of files) {
544
- if (isSpecPackFile(file, "spec.md")) {
545
- packs.add(path6.dirname(file));
555
+ // src/core/specLayout.ts
556
+ import { readdir as readdir3 } from "fs/promises";
557
+ import path6 from "path";
558
+ var SPEC_DIR_RE = /^spec-\d{4}$/;
559
+ async function collectSpecEntries(specsRoot) {
560
+ const dirs = await listSpecDirs(specsRoot);
561
+ const entries = dirs.map((dir) => ({
562
+ dir,
563
+ specPath: path6.join(dir, "spec.md"),
564
+ deltaPath: path6.join(dir, "delta.md"),
565
+ scenarioPath: path6.join(dir, "scenario.md")
566
+ }));
567
+ return entries.sort((a, b) => a.dir.localeCompare(b.dir));
568
+ }
569
+ async function listSpecDirs(specsRoot) {
570
+ try {
571
+ const items = await readdir3(specsRoot, { withFileTypes: true });
572
+ return items.filter((item) => item.isDirectory()).map((item) => item.name).filter((name) => SPEC_DIR_RE.test(name.toLowerCase())).map((name) => path6.join(specsRoot, name));
573
+ } catch (error2) {
574
+ if (isMissingFileError(error2)) {
575
+ return [];
546
576
  }
577
+ throw error2;
578
+ }
579
+ }
580
+ function isMissingFileError(error2) {
581
+ if (!error2 || typeof error2 !== "object") {
582
+ return false;
547
583
  }
548
- return Array.from(packs).sort();
584
+ return error2.code === "ENOENT";
585
+ }
586
+
587
+ // src/core/discovery.ts
588
+ async function collectSpecPackDirs(specsRoot) {
589
+ const entries = await collectSpecEntries(specsRoot);
590
+ return entries.map((entry) => entry.dir);
549
591
  }
550
592
  async function collectSpecFiles(specsRoot) {
551
- const files = await collectFiles(specsRoot, { extensions: [".md"] });
552
- return files.filter((file) => isSpecPackFile(file, "spec.md"));
593
+ const entries = await collectSpecEntries(specsRoot);
594
+ return filterExisting(entries.map((entry) => entry.specPath));
553
595
  }
554
596
  async function collectScenarioFiles(specsRoot) {
555
- const files = await collectFiles(specsRoot, { extensions: [".md"] });
556
- return files.filter((file) => isSpecPackFile(file, "scenario.md"));
597
+ const entries = await collectSpecEntries(specsRoot);
598
+ return filterExisting(entries.map((entry) => entry.scenarioPath));
557
599
  }
558
600
  async function collectUiContractFiles(uiRoot) {
559
601
  return collectFiles(uiRoot, { extensions: [".yaml", ".yml"] });
@@ -572,12 +614,22 @@ async function collectContractFiles(uiRoot, apiRoot, dataRoot) {
572
614
  ]);
573
615
  return { ui, api, db };
574
616
  }
575
- function isSpecPackFile(filePath, baseName) {
576
- if (path6.basename(filePath).toLowerCase() !== baseName) {
617
+ async function filterExisting(files) {
618
+ const existing = [];
619
+ for (const file of files) {
620
+ if (await exists3(file)) {
621
+ existing.push(file);
622
+ }
623
+ }
624
+ return existing;
625
+ }
626
+ async function exists3(target) {
627
+ try {
628
+ await access3(target);
629
+ return true;
630
+ } catch {
577
631
  return false;
578
632
  }
579
- const dirName = path6.basename(path6.dirname(filePath)).toLowerCase();
580
- return SPEC_PACK_DIR_PATTERN.test(dirName);
581
633
  }
582
634
 
583
635
  // src/core/ids.ts
@@ -641,8 +693,8 @@ import { readFile as readFile2 } from "fs/promises";
641
693
  import path7 from "path";
642
694
  import { fileURLToPath as fileURLToPath2 } from "url";
643
695
  async function resolveToolVersion() {
644
- if ("0.3.1".length > 0) {
645
- return "0.3.1";
696
+ if ("0.3.3".length > 0) {
697
+ return "0.3.3";
646
698
  }
647
699
  try {
648
700
  const packagePath = resolvePackageJsonPath();
@@ -975,7 +1027,7 @@ async function validateDeltas(root, config) {
975
1027
  try {
976
1028
  text = await readFile4(deltaPath, "utf-8");
977
1029
  } catch (error2) {
978
- if (isMissingFileError(error2)) {
1030
+ if (isMissingFileError2(error2)) {
979
1031
  issues.push(
980
1032
  issue2(
981
1033
  "QFAI-DELTA-001",
@@ -1020,7 +1072,7 @@ async function validateDeltas(root, config) {
1020
1072
  }
1021
1073
  return issues;
1022
1074
  }
1023
- function isMissingFileError(error2) {
1075
+ function isMissingFileError2(error2) {
1024
1076
  if (!error2 || typeof error2 !== "object") {
1025
1077
  return false;
1026
1078
  }
@@ -1109,66 +1161,6 @@ function record(index, id, file) {
1109
1161
  index.idToFiles.set(id, current);
1110
1162
  }
1111
1163
 
1112
- // src/core/parse/gherkin.ts
1113
- var FEATURE_RE = /^\s*Feature:\s+/;
1114
- var SCENARIO_RE = /^\s*Scenario(?: Outline)?:\s*(.+)\s*$/;
1115
- var TAG_LINE_RE = /^\s*@/;
1116
- function parseTags(line) {
1117
- return line.trim().split(/\s+/).filter((tag) => tag.startsWith("@")).map((tag) => tag.replace(/^@/, ""));
1118
- }
1119
- function parseGherkinFeature(text, file) {
1120
- const lines = text.split(/\r?\n/);
1121
- const scenarios = [];
1122
- let featurePresent = false;
1123
- let featureTags = [];
1124
- let pendingTags = [];
1125
- let current = null;
1126
- const flush = () => {
1127
- if (!current) return;
1128
- scenarios.push({
1129
- ...current,
1130
- body: current.body.trim()
1131
- });
1132
- current = null;
1133
- };
1134
- for (let i = 0; i < lines.length; i++) {
1135
- const line = lines[i] ?? "";
1136
- const trimmed = line.trim();
1137
- if (TAG_LINE_RE.test(trimmed)) {
1138
- pendingTags.push(...parseTags(trimmed));
1139
- continue;
1140
- }
1141
- if (FEATURE_RE.test(trimmed)) {
1142
- featurePresent = true;
1143
- featureTags = [...pendingTags];
1144
- pendingTags = [];
1145
- continue;
1146
- }
1147
- const match = trimmed.match(SCENARIO_RE);
1148
- if (match) {
1149
- const scenarioName = match[1]?.trim();
1150
- if (!scenarioName) {
1151
- continue;
1152
- }
1153
- flush();
1154
- current = {
1155
- name: scenarioName,
1156
- line: i + 1,
1157
- tags: [...featureTags, ...pendingTags],
1158
- body: ""
1159
- };
1160
- pendingTags = [];
1161
- continue;
1162
- }
1163
- if (current) {
1164
- current.body += `${line}
1165
- `;
1166
- }
1167
- }
1168
- flush();
1169
- return { file, featurePresent, scenarios };
1170
- }
1171
-
1172
1164
  // src/core/parse/markdown.ts
1173
1165
  var HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
1174
1166
  function parseHeadings(md) {
@@ -1287,8 +1279,166 @@ function parseSpec(md, file) {
1287
1279
  return parsed;
1288
1280
  }
1289
1281
 
1290
- // src/core/validators/ids.ts
1282
+ // src/core/gherkin/parse.ts
1283
+ import {
1284
+ AstBuilder,
1285
+ GherkinClassicTokenMatcher,
1286
+ Parser
1287
+ } from "@cucumber/gherkin";
1288
+ import { randomUUID } from "crypto";
1289
+ function parseGherkin(source, uri) {
1290
+ const errors = [];
1291
+ const uuidFn = () => randomUUID();
1292
+ const builder = new AstBuilder(uuidFn);
1293
+ const matcher = new GherkinClassicTokenMatcher();
1294
+ const parser = new Parser(builder, matcher);
1295
+ try {
1296
+ const gherkinDocument = parser.parse(source);
1297
+ gherkinDocument.uri = uri;
1298
+ return { gherkinDocument, errors };
1299
+ } catch (error2) {
1300
+ errors.push(formatError3(error2));
1301
+ return { gherkinDocument: null, errors };
1302
+ }
1303
+ }
1304
+ function formatError3(error2) {
1305
+ if (error2 instanceof Error) {
1306
+ return error2.message;
1307
+ }
1308
+ return String(error2);
1309
+ }
1310
+
1311
+ // src/core/scenarioModel.ts
1312
+ var SPEC_TAG_RE = /^SPEC-\d{4}$/;
1291
1313
  var SC_TAG_RE = /^SC-\d{4}$/;
1314
+ var BR_TAG_RE = /^BR-\d{4}$/;
1315
+ var UI_TAG_RE = /^UI-\d{4}$/;
1316
+ var API_TAG_RE = /^API-\d{4}$/;
1317
+ var DATA_TAG_RE = /^DATA-\d{4}$/;
1318
+ function parseScenarioDocument(text, uri) {
1319
+ const { gherkinDocument, errors } = parseGherkin(text, uri);
1320
+ if (!gherkinDocument) {
1321
+ return { document: null, errors };
1322
+ }
1323
+ const feature = gherkinDocument.feature;
1324
+ if (!feature) {
1325
+ return {
1326
+ document: { uri, featureTags: [], scenarios: [] },
1327
+ errors
1328
+ };
1329
+ }
1330
+ const featureTags = collectTagNames(feature.tags);
1331
+ const scenarios = collectScenarioNodes(feature, featureTags);
1332
+ return {
1333
+ document: {
1334
+ uri,
1335
+ featureName: feature.name,
1336
+ featureTags,
1337
+ scenarios
1338
+ },
1339
+ errors
1340
+ };
1341
+ }
1342
+ function buildScenarioAtoms(document) {
1343
+ return document.scenarios.map((scenario) => {
1344
+ const specIds = scenario.tags.filter((tag) => SPEC_TAG_RE.test(tag));
1345
+ const scIds = scenario.tags.filter((tag) => SC_TAG_RE.test(tag));
1346
+ const brIds = unique2(scenario.tags.filter((tag) => BR_TAG_RE.test(tag)));
1347
+ const contractIds = /* @__PURE__ */ new Set();
1348
+ scenario.tags.forEach((tag) => {
1349
+ if (UI_TAG_RE.test(tag) || API_TAG_RE.test(tag) || DATA_TAG_RE.test(tag)) {
1350
+ contractIds.add(tag);
1351
+ }
1352
+ });
1353
+ for (const step of scenario.steps) {
1354
+ for (const text of collectStepTexts(step)) {
1355
+ extractIds(text, "UI").forEach((id) => contractIds.add(id));
1356
+ extractIds(text, "API").forEach((id) => contractIds.add(id));
1357
+ extractIds(text, "DATA").forEach((id) => contractIds.add(id));
1358
+ }
1359
+ }
1360
+ const atom = {
1361
+ uri: document.uri,
1362
+ featureName: document.featureName ?? "",
1363
+ scenarioName: scenario.name,
1364
+ kind: scenario.kind,
1365
+ brIds,
1366
+ contractIds: Array.from(contractIds).sort()
1367
+ };
1368
+ if (scenario.line !== void 0) {
1369
+ atom.line = scenario.line;
1370
+ }
1371
+ if (specIds.length === 1) {
1372
+ const specId = specIds[0];
1373
+ if (specId) {
1374
+ atom.specId = specId;
1375
+ }
1376
+ }
1377
+ if (scIds.length === 1) {
1378
+ const scId = scIds[0];
1379
+ if (scId) {
1380
+ atom.scId = scId;
1381
+ }
1382
+ }
1383
+ return atom;
1384
+ });
1385
+ }
1386
+ function collectScenarioNodes(feature, featureTags) {
1387
+ const scenarios = [];
1388
+ for (const child of feature.children) {
1389
+ if (child.scenario) {
1390
+ scenarios.push(buildScenarioNode(child.scenario, featureTags, []));
1391
+ }
1392
+ if (child.rule) {
1393
+ const ruleTags = collectTagNames(child.rule.tags);
1394
+ for (const ruleChild of child.rule.children) {
1395
+ if (ruleChild.scenario) {
1396
+ scenarios.push(
1397
+ buildScenarioNode(ruleChild.scenario, featureTags, ruleTags)
1398
+ );
1399
+ }
1400
+ }
1401
+ }
1402
+ }
1403
+ return scenarios;
1404
+ }
1405
+ function buildScenarioNode(scenario, featureTags, ruleTags) {
1406
+ const tags = [...featureTags, ...ruleTags, ...collectTagNames(scenario.tags)];
1407
+ const kind = scenario.examples.length > 0 ? "ScenarioOutline" : "Scenario";
1408
+ return {
1409
+ name: scenario.name,
1410
+ kind,
1411
+ line: scenario.location?.line,
1412
+ tags,
1413
+ steps: scenario.steps
1414
+ };
1415
+ }
1416
+ function collectTagNames(tags) {
1417
+ return tags.map((tag) => tag.name.replace(/^@/, ""));
1418
+ }
1419
+ function collectStepTexts(step) {
1420
+ const texts = [];
1421
+ if (step.text) {
1422
+ texts.push(step.text);
1423
+ }
1424
+ if (step.docString?.content) {
1425
+ texts.push(step.docString.content);
1426
+ }
1427
+ if (step.dataTable?.rows) {
1428
+ for (const row of step.dataTable.rows) {
1429
+ for (const cell of row.cells) {
1430
+ texts.push(cell.value);
1431
+ }
1432
+ }
1433
+ }
1434
+ return texts;
1435
+ }
1436
+ function unique2(values) {
1437
+ return Array.from(new Set(values));
1438
+ }
1439
+
1440
+ // src/core/validators/ids.ts
1441
+ var SC_TAG_RE2 = /^SC-\d{4}$/;
1292
1442
  async function validateDefinedIds(root, config) {
1293
1443
  const issues = [];
1294
1444
  const specsRoot = resolvePath(root, config, "specsDir");
@@ -1333,10 +1483,13 @@ async function collectSpecDefinitionIds(files, out) {
1333
1483
  async function collectScenarioDefinitionIds(files, out) {
1334
1484
  for (const file of files) {
1335
1485
  const text = await readFile6(file, "utf-8");
1336
- const parsed = parseGherkinFeature(text, file);
1337
- for (const scenario of parsed.scenarios) {
1486
+ const { document, errors } = parseScenarioDocument(text, file);
1487
+ if (!document || errors.length > 0) {
1488
+ continue;
1489
+ }
1490
+ for (const scenario of document.scenarios) {
1338
1491
  for (const tag of scenario.tags) {
1339
- if (SC_TAG_RE.test(tag)) {
1492
+ if (SC_TAG_RE2.test(tag)) {
1340
1493
  recordId(out, tag, file);
1341
1494
  }
1342
1495
  }
@@ -1377,17 +1530,19 @@ import { readFile as readFile7 } from "fs/promises";
1377
1530
  var GIVEN_PATTERN = /\bGiven\b/;
1378
1531
  var WHEN_PATTERN = /\bWhen\b/;
1379
1532
  var THEN_PATTERN = /\bThen\b/;
1380
- var SC_TAG_RE2 = /^SC-\d{4}$/;
1381
- var SPEC_TAG_RE = /^SPEC-\d{4}$/;
1382
- var BR_TAG_RE = /^BR-\d{4}$/;
1533
+ var SC_TAG_RE3 = /^SC-\d{4}$/;
1534
+ var SPEC_TAG_RE2 = /^SPEC-\d{4}$/;
1535
+ var BR_TAG_RE2 = /^BR-\d{4}$/;
1383
1536
  async function validateScenarios(root, config) {
1384
1537
  const specsRoot = resolvePath(root, config, "specsDir");
1385
- const files = await collectScenarioFiles(specsRoot);
1386
- if (files.length === 0) {
1538
+ const entries = await collectSpecEntries(specsRoot);
1539
+ if (entries.length === 0) {
1540
+ const expected = "spec-0001/scenario.md";
1541
+ const legacy = "spec-001/scenario.md";
1387
1542
  return [
1388
1543
  issue4(
1389
1544
  "QFAI-SC-000",
1390
- "Scenario \u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1545
+ `Scenario \u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002\u914D\u7F6E\u5834\u6240: ${config.paths.specsDir} / \u671F\u5F85\u30D1\u30BF\u30FC\u30F3: ${expected} (${legacy} \u306F\u975E\u5BFE\u5FDC)`,
1391
1546
  "info",
1392
1547
  specsRoot,
1393
1548
  "scenario.files"
@@ -1395,15 +1550,31 @@ async function validateScenarios(root, config) {
1395
1550
  ];
1396
1551
  }
1397
1552
  const issues = [];
1398
- for (const file of files) {
1399
- const text = await readFile7(file, "utf-8");
1400
- issues.push(...validateScenarioContent(text, file));
1553
+ for (const entry of entries) {
1554
+ let text;
1555
+ try {
1556
+ text = await readFile7(entry.scenarioPath, "utf-8");
1557
+ } catch (error2) {
1558
+ if (isMissingFileError3(error2)) {
1559
+ issues.push(
1560
+ issue4(
1561
+ "QFAI-SC-001",
1562
+ "scenario.md \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1563
+ "error",
1564
+ entry.scenarioPath,
1565
+ "scenario.exists"
1566
+ )
1567
+ );
1568
+ continue;
1569
+ }
1570
+ throw error2;
1571
+ }
1572
+ issues.push(...validateScenarioContent(text, entry.scenarioPath));
1401
1573
  }
1402
1574
  return issues;
1403
1575
  }
1404
1576
  function validateScenarioContent(text, file) {
1405
1577
  const issues = [];
1406
- const parsed = parseGherkinFeature(text, file);
1407
1578
  const invalidIds = extractInvalidIds(text, [
1408
1579
  "SPEC",
1409
1580
  "BR",
@@ -1425,9 +1596,47 @@ function validateScenarioContent(text, file) {
1425
1596
  )
1426
1597
  );
1427
1598
  }
1599
+ const { document, errors } = parseScenarioDocument(text, file);
1600
+ if (!document || errors.length > 0) {
1601
+ issues.push(
1602
+ issue4(
1603
+ "QFAI-SC-010",
1604
+ `Gherkin \u306E\u89E3\u6790\u306B\u5931\u6557\u3057\u307E\u3057\u305F: ${errors.join(", ") || "unknown"}`,
1605
+ "error",
1606
+ file,
1607
+ "scenario.parse"
1608
+ )
1609
+ );
1610
+ return issues;
1611
+ }
1612
+ const featureSpecTags = document.featureTags.filter(
1613
+ (tag) => SPEC_TAG_RE2.test(tag)
1614
+ );
1615
+ if (featureSpecTags.length === 0) {
1616
+ issues.push(
1617
+ issue4(
1618
+ "QFAI-SC-009",
1619
+ "Feature \u30BF\u30B0\u306B SPEC \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1620
+ "error",
1621
+ file,
1622
+ "scenario.featureSpec"
1623
+ )
1624
+ );
1625
+ } else if (featureSpecTags.length > 1) {
1626
+ issues.push(
1627
+ issue4(
1628
+ "QFAI-SC-009",
1629
+ `Feature \u306E SPEC \u30BF\u30B0\u304C\u8907\u6570\u3042\u308A\u307E\u3059: ${featureSpecTags.join(", ")}`,
1630
+ "error",
1631
+ file,
1632
+ "scenario.featureSpec",
1633
+ featureSpecTags
1634
+ )
1635
+ );
1636
+ }
1428
1637
  const missingStructure = [];
1429
- if (!parsed.featurePresent) missingStructure.push("Feature");
1430
- if (parsed.scenarios.length === 0) missingStructure.push("Scenario");
1638
+ if (!document.featureName) missingStructure.push("Feature");
1639
+ if (document.scenarios.length === 0) missingStructure.push("Scenario");
1431
1640
  if (missingStructure.length > 0) {
1432
1641
  issues.push(
1433
1642
  issue4(
@@ -1441,7 +1650,7 @@ function validateScenarioContent(text, file) {
1441
1650
  )
1442
1651
  );
1443
1652
  }
1444
- for (const scenario of parsed.scenarios) {
1653
+ for (const scenario of document.scenarios) {
1445
1654
  if (scenario.tags.length === 0) {
1446
1655
  issues.push(
1447
1656
  issue4(
@@ -1455,16 +1664,16 @@ function validateScenarioContent(text, file) {
1455
1664
  continue;
1456
1665
  }
1457
1666
  const missingTags = [];
1458
- const scTags = scenario.tags.filter((tag) => SC_TAG_RE2.test(tag));
1667
+ const scTags = scenario.tags.filter((tag) => SC_TAG_RE3.test(tag));
1459
1668
  if (scTags.length === 0) {
1460
1669
  missingTags.push("SC(0\u4EF6)");
1461
1670
  } else if (scTags.length > 1) {
1462
1671
  missingTags.push(`SC(${scTags.length}\u4EF6/1\u4EF6\u5FC5\u9808)`);
1463
1672
  }
1464
- if (!scenario.tags.some((tag) => SPEC_TAG_RE.test(tag))) {
1673
+ if (!scenario.tags.some((tag) => SPEC_TAG_RE2.test(tag))) {
1465
1674
  missingTags.push("SPEC");
1466
1675
  }
1467
- if (!scenario.tags.some((tag) => BR_TAG_RE.test(tag))) {
1676
+ if (!scenario.tags.some((tag) => BR_TAG_RE2.test(tag))) {
1468
1677
  missingTags.push("BR");
1469
1678
  }
1470
1679
  if (missingTags.length > 0) {
@@ -1479,15 +1688,16 @@ function validateScenarioContent(text, file) {
1479
1688
  );
1480
1689
  }
1481
1690
  }
1482
- for (const scenario of parsed.scenarios) {
1691
+ for (const scenario of document.scenarios) {
1483
1692
  const missingSteps = [];
1484
- if (!GIVEN_PATTERN.test(scenario.body)) {
1693
+ const keywords = scenario.steps.map((step) => step.keyword.trim());
1694
+ if (!keywords.some((keyword) => GIVEN_PATTERN.test(keyword))) {
1485
1695
  missingSteps.push("Given");
1486
1696
  }
1487
- if (!WHEN_PATTERN.test(scenario.body)) {
1697
+ if (!keywords.some((keyword) => WHEN_PATTERN.test(keyword))) {
1488
1698
  missingSteps.push("When");
1489
1699
  }
1490
- if (!THEN_PATTERN.test(scenario.body)) {
1700
+ if (!keywords.some((keyword) => THEN_PATTERN.test(keyword))) {
1491
1701
  missingSteps.push("Then");
1492
1702
  }
1493
1703
  if (missingSteps.length > 0) {
@@ -1521,18 +1731,25 @@ function issue4(code, message, severity, file, rule, refs) {
1521
1731
  }
1522
1732
  return issue7;
1523
1733
  }
1734
+ function isMissingFileError3(error2) {
1735
+ if (!error2 || typeof error2 !== "object") {
1736
+ return false;
1737
+ }
1738
+ return error2.code === "ENOENT";
1739
+ }
1524
1740
 
1525
1741
  // src/core/validators/spec.ts
1526
1742
  import { readFile as readFile8 } from "fs/promises";
1527
1743
  async function validateSpecs(root, config) {
1528
1744
  const specsRoot = resolvePath(root, config, "specsDir");
1529
- const files = await collectSpecFiles(specsRoot);
1530
- if (files.length === 0) {
1531
- const expected = "spec-001/spec.md";
1745
+ const entries = await collectSpecEntries(specsRoot);
1746
+ if (entries.length === 0) {
1747
+ const expected = "spec-0001/spec.md";
1748
+ const legacy = "spec-001/spec.md";
1532
1749
  return [
1533
1750
  issue5(
1534
1751
  "QFAI-SPEC-000",
1535
- `Spec \u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002\u914D\u7F6E\u5834\u6240: ${config.paths.specsDir} / \u671F\u5F85\u30D1\u30BF\u30FC\u30F3: ${expected}`,
1752
+ `Spec \u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002\u914D\u7F6E\u5834\u6240: ${config.paths.specsDir} / \u671F\u5F85\u30D1\u30BF\u30FC\u30F3: ${expected} (${legacy} \u306F\u975E\u5BFE\u5FDC)`,
1536
1753
  "info",
1537
1754
  specsRoot,
1538
1755
  "spec.files"
@@ -1540,12 +1757,29 @@ async function validateSpecs(root, config) {
1540
1757
  ];
1541
1758
  }
1542
1759
  const issues = [];
1543
- for (const file of files) {
1544
- const text = await readFile8(file, "utf-8");
1760
+ for (const entry of entries) {
1761
+ let text;
1762
+ try {
1763
+ text = await readFile8(entry.specPath, "utf-8");
1764
+ } catch (error2) {
1765
+ if (isMissingFileError4(error2)) {
1766
+ issues.push(
1767
+ issue5(
1768
+ "QFAI-SPEC-005",
1769
+ "spec.md \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1770
+ "error",
1771
+ entry.specPath,
1772
+ "spec.exists"
1773
+ )
1774
+ );
1775
+ continue;
1776
+ }
1777
+ throw error2;
1778
+ }
1545
1779
  issues.push(
1546
1780
  ...validateSpecContent(
1547
1781
  text,
1548
- file,
1782
+ entry.specPath,
1549
1783
  config.validation.require.specSections
1550
1784
  )
1551
1785
  );
@@ -1667,15 +1901,18 @@ function issue5(code, message, severity, file, rule, refs) {
1667
1901
  }
1668
1902
  return issue7;
1669
1903
  }
1904
+ function isMissingFileError4(error2) {
1905
+ if (!error2 || typeof error2 !== "object") {
1906
+ return false;
1907
+ }
1908
+ return error2.code === "ENOENT";
1909
+ }
1670
1910
 
1671
1911
  // src/core/validators/traceability.ts
1672
1912
  import { readFile as readFile9 } from "fs/promises";
1673
- var SC_TAG_RE3 = /^SC-\d{4}$/;
1674
- var SPEC_TAG_RE2 = /^SPEC-\d{4}$/;
1675
- var BR_TAG_RE2 = /^BR-\d{4}$/;
1676
- var UI_TAG_RE = /^UI-\d{4}$/;
1677
- var API_TAG_RE = /^API-\d{4}$/;
1678
- var DATA_TAG_RE = /^DATA-\d{4}$/;
1913
+ var SC_TAG_RE4 = /^SC-\d{4}$/;
1914
+ var SPEC_TAG_RE3 = /^SPEC-\d{4}$/;
1915
+ var BR_TAG_RE3 = /^BR-\d{4}$/;
1679
1916
  async function validateTraceability(root, config) {
1680
1917
  const issues = [];
1681
1918
  const specsRoot = resolvePath(root, config, "specsDir");
@@ -1733,104 +1970,97 @@ async function validateTraceability(root, config) {
1733
1970
  for (const file of scenarioFiles) {
1734
1971
  const text = await readFile9(file, "utf-8");
1735
1972
  extractAllIds(text).forEach((id) => upstreamIds.add(id));
1736
- const parsed = parseGherkinFeature(text, file);
1737
- const specIdsInScenario = /* @__PURE__ */ new Set();
1738
- const brIds = /* @__PURE__ */ new Set();
1739
- const scIds = /* @__PURE__ */ new Set();
1740
- const scenarioIds = /* @__PURE__ */ new Set();
1741
- for (const scenario of parsed.scenarios) {
1742
- for (const tag of scenario.tags) {
1743
- if (SPEC_TAG_RE2.test(tag)) {
1744
- specIdsInScenario.add(tag);
1745
- }
1746
- if (BR_TAG_RE2.test(tag)) {
1747
- brIds.add(tag);
1748
- }
1749
- if (SC_TAG_RE3.test(tag)) {
1750
- scIds.add(tag);
1751
- }
1752
- if (UI_TAG_RE.test(tag) || API_TAG_RE.test(tag) || DATA_TAG_RE.test(tag)) {
1753
- scenarioIds.add(tag);
1754
- }
1755
- }
1756
- }
1757
- const specIdsList = Array.from(specIdsInScenario);
1758
- const brIdsList = Array.from(brIds);
1759
- const scIdsList = Array.from(scIds);
1760
- const scenarioIdsList = Array.from(scenarioIds);
1761
- brIdsList.forEach((id) => brIdsInScenarios.add(id));
1762
- scIdsList.forEach((id) => scIdsInScenarios.add(id));
1763
- scenarioIdsList.forEach((id) => scenarioContractIds.add(id));
1764
- if (scenarioIdsList.length > 0) {
1765
- scIdsList.forEach((id) => scWithContracts.add(id));
1766
- }
1767
- const unknownSpecIds = specIdsList.filter((id) => !specIds.has(id));
1768
- if (unknownSpecIds.length > 0) {
1769
- issues.push(
1770
- issue6(
1771
- "QFAI-TRACE-005",
1772
- `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044 SPEC \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownSpecIds.join(", ")}`,
1773
- "error",
1774
- file,
1775
- "traceability.scenarioSpecExists",
1776
- unknownSpecIds
1777
- )
1778
- );
1779
- }
1780
- const unknownBrIds = brIdsList.filter((id) => !brIdsInSpecs.has(id));
1781
- if (unknownBrIds.length > 0) {
1782
- issues.push(
1783
- issue6(
1784
- "QFAI-TRACE-006",
1785
- `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044 BR \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownBrIds.join(", ")}`,
1786
- "error",
1787
- file,
1788
- "traceability.scenarioBrExists",
1789
- unknownBrIds
1790
- )
1791
- );
1792
- }
1793
- const unknownContractIds = scenarioIdsList.filter(
1794
- (id) => !contractIds.has(id)
1795
- );
1796
- if (unknownContractIds.length > 0) {
1797
- issues.push(
1798
- issue6(
1799
- "QFAI-TRACE-008",
1800
- `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044\u5951\u7D04 ID \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownContractIds.join(
1801
- ", "
1802
- )}`,
1803
- config.validation.traceability.unknownContractIdSeverity,
1804
- file,
1805
- "traceability.scenarioContractExists",
1806
- unknownContractIds
1807
- )
1808
- );
1973
+ const { document, errors } = parseScenarioDocument(text, file);
1974
+ if (!document || errors.length > 0) {
1975
+ continue;
1809
1976
  }
1810
- if (specIdsList.length > 0) {
1811
- const allowedBrIds = /* @__PURE__ */ new Set();
1812
- for (const specId of specIdsList) {
1813
- const brIdsForSpec = specToBrIds.get(specId);
1814
- if (!brIdsForSpec) {
1815
- continue;
1816
- }
1817
- brIdsForSpec.forEach((id) => allowedBrIds.add(id));
1977
+ const atoms = buildScenarioAtoms(document);
1978
+ for (const [index, scenario] of document.scenarios.entries()) {
1979
+ const atom = atoms[index];
1980
+ if (!atom) {
1981
+ continue;
1982
+ }
1983
+ const specTags = scenario.tags.filter((tag) => SPEC_TAG_RE3.test(tag));
1984
+ const brTags = scenario.tags.filter((tag) => BR_TAG_RE3.test(tag));
1985
+ const scTags = scenario.tags.filter((tag) => SC_TAG_RE4.test(tag));
1986
+ brTags.forEach((id) => brIdsInScenarios.add(id));
1987
+ scTags.forEach((id) => scIdsInScenarios.add(id));
1988
+ atom.contractIds.forEach((id) => scenarioContractIds.add(id));
1989
+ if (atom.contractIds.length > 0) {
1990
+ scTags.forEach((id) => scWithContracts.add(id));
1818
1991
  }
1819
- const invalidBrIds = brIdsList.filter((id) => !allowedBrIds.has(id));
1820
- if (invalidBrIds.length > 0) {
1992
+ const unknownSpecIds = specTags.filter((id) => !specIds.has(id));
1993
+ if (unknownSpecIds.length > 0) {
1821
1994
  issues.push(
1822
1995
  issue6(
1823
- "QFAI-TRACE-007",
1824
- `Scenario \u306E BR \u304C\u53C2\u7167 SPEC \u306B\u5C5E\u3057\u3066\u3044\u307E\u305B\u3093: ${invalidBrIds.join(
1996
+ "QFAI-TRACE-005",
1997
+ `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044 SPEC \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownSpecIds.join(
1825
1998
  ", "
1826
- )} (SPEC: ${specIdsList.join(", ")})`,
1999
+ )} (${scenario.name})`,
1827
2000
  "error",
1828
2001
  file,
1829
- "traceability.scenarioBrUnderSpec",
1830
- invalidBrIds
2002
+ "traceability.scenarioSpecExists",
2003
+ unknownSpecIds
1831
2004
  )
1832
2005
  );
1833
2006
  }
2007
+ const unknownBrIds = brTags.filter((id) => !brIdsInSpecs.has(id));
2008
+ if (unknownBrIds.length > 0) {
2009
+ issues.push(
2010
+ issue6(
2011
+ "QFAI-TRACE-006",
2012
+ `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044 BR \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownBrIds.join(
2013
+ ", "
2014
+ )} (${scenario.name})`,
2015
+ "error",
2016
+ file,
2017
+ "traceability.scenarioBrExists",
2018
+ unknownBrIds
2019
+ )
2020
+ );
2021
+ }
2022
+ const unknownContractIds = atom.contractIds.filter(
2023
+ (id) => !contractIds.has(id)
2024
+ );
2025
+ if (unknownContractIds.length > 0) {
2026
+ issues.push(
2027
+ issue6(
2028
+ "QFAI-TRACE-008",
2029
+ `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044\u5951\u7D04 ID \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownContractIds.join(
2030
+ ", "
2031
+ )} (${scenario.name})`,
2032
+ config.validation.traceability.unknownContractIdSeverity,
2033
+ file,
2034
+ "traceability.scenarioContractExists",
2035
+ unknownContractIds
2036
+ )
2037
+ );
2038
+ }
2039
+ if (specTags.length > 0 && brTags.length > 0) {
2040
+ const allowedBrIds = /* @__PURE__ */ new Set();
2041
+ for (const specId of specTags) {
2042
+ const brIdsForSpec = specToBrIds.get(specId);
2043
+ if (!brIdsForSpec) {
2044
+ continue;
2045
+ }
2046
+ brIdsForSpec.forEach((id) => allowedBrIds.add(id));
2047
+ }
2048
+ const invalidBrIds = brTags.filter((id) => !allowedBrIds.has(id));
2049
+ if (invalidBrIds.length > 0) {
2050
+ issues.push(
2051
+ issue6(
2052
+ "QFAI-TRACE-007",
2053
+ `Scenario \u306E BR \u304C\u53C2\u7167 SPEC \u306B\u5C5E\u3057\u3066\u3044\u307E\u305B\u3093: ${invalidBrIds.join(
2054
+ ", "
2055
+ )} (SPEC: ${specTags.join(", ")}) (${scenario.name})`,
2056
+ "error",
2057
+ file,
2058
+ "traceability.scenarioBrUnderSpec",
2059
+ invalidBrIds
2060
+ )
2061
+ );
2062
+ }
2063
+ }
1834
2064
  }
1835
2065
  }
1836
2066
  if (upstreamIds.size === 0) {
@@ -2246,7 +2476,7 @@ async function runReport(options) {
2246
2476
  try {
2247
2477
  validation = await readValidationResult(inputPath);
2248
2478
  } catch (err) {
2249
- if (isMissingFileError2(err)) {
2479
+ if (isMissingFileError5(err)) {
2250
2480
  error(
2251
2481
  [
2252
2482
  `qfai report: \u5165\u529B\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${inputPath}`,
@@ -2310,7 +2540,7 @@ function isValidationResult(value) {
2310
2540
  }
2311
2541
  return typeof counts.info === "number" && typeof counts.warning === "number" && typeof counts.error === "number";
2312
2542
  }
2313
- function isMissingFileError2(error2) {
2543
+ function isMissingFileError5(error2) {
2314
2544
  if (!error2 || typeof error2 !== "object") {
2315
2545
  return false;
2316
2546
  }