qfai 0.3.1 → 0.3.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.
@@ -478,7 +478,7 @@ import { readFile as readFile10 } from "fs/promises";
478
478
  import path13 from "path";
479
479
 
480
480
  // src/core/discovery.ts
481
- import path6 from "path";
481
+ import { access as access3 } from "fs/promises";
482
482
 
483
483
  // src/core/fs.ts
484
484
  import { access as access2, readdir as readdir2 } from "fs/promises";
@@ -535,25 +535,50 @@ async function exists2(target) {
535
535
  }
536
536
  }
537
537
 
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));
538
+ // src/core/specLayout.ts
539
+ import { readdir as readdir3 } from "fs/promises";
540
+ import path6 from "path";
541
+ var SPEC_DIR_RE = /^spec-\d{4}$/;
542
+ async function collectSpecEntries(specsRoot) {
543
+ const dirs = await listSpecDirs(specsRoot);
544
+ const entries = dirs.map((dir) => ({
545
+ dir,
546
+ specPath: path6.join(dir, "spec.md"),
547
+ deltaPath: path6.join(dir, "delta.md"),
548
+ scenarioPath: path6.join(dir, "scenario.md")
549
+ }));
550
+ return entries.sort((a, b) => a.dir.localeCompare(b.dir));
551
+ }
552
+ async function listSpecDirs(specsRoot) {
553
+ try {
554
+ const items = await readdir3(specsRoot, { withFileTypes: true });
555
+ return items.filter((item) => item.isDirectory()).map((item) => item.name).filter((name) => SPEC_DIR_RE.test(name.toLowerCase())).map((name) => path6.join(specsRoot, name));
556
+ } catch (error2) {
557
+ if (isMissingFileError(error2)) {
558
+ return [];
546
559
  }
560
+ throw error2;
547
561
  }
548
- return Array.from(packs).sort();
562
+ }
563
+ function isMissingFileError(error2) {
564
+ if (!error2 || typeof error2 !== "object") {
565
+ return false;
566
+ }
567
+ return error2.code === "ENOENT";
568
+ }
569
+
570
+ // src/core/discovery.ts
571
+ async function collectSpecPackDirs(specsRoot) {
572
+ const entries = await collectSpecEntries(specsRoot);
573
+ return entries.map((entry) => entry.dir);
549
574
  }
550
575
  async function collectSpecFiles(specsRoot) {
551
- const files = await collectFiles(specsRoot, { extensions: [".md"] });
552
- return files.filter((file) => isSpecPackFile(file, "spec.md"));
576
+ const entries = await collectSpecEntries(specsRoot);
577
+ return filterExisting(entries.map((entry) => entry.specPath));
553
578
  }
554
579
  async function collectScenarioFiles(specsRoot) {
555
- const files = await collectFiles(specsRoot, { extensions: [".md"] });
556
- return files.filter((file) => isSpecPackFile(file, "scenario.md"));
580
+ const entries = await collectSpecEntries(specsRoot);
581
+ return filterExisting(entries.map((entry) => entry.scenarioPath));
557
582
  }
558
583
  async function collectUiContractFiles(uiRoot) {
559
584
  return collectFiles(uiRoot, { extensions: [".yaml", ".yml"] });
@@ -572,12 +597,22 @@ async function collectContractFiles(uiRoot, apiRoot, dataRoot) {
572
597
  ]);
573
598
  return { ui, api, db };
574
599
  }
575
- function isSpecPackFile(filePath, baseName) {
576
- if (path6.basename(filePath).toLowerCase() !== baseName) {
600
+ async function filterExisting(files) {
601
+ const existing = [];
602
+ for (const file of files) {
603
+ if (await exists3(file)) {
604
+ existing.push(file);
605
+ }
606
+ }
607
+ return existing;
608
+ }
609
+ async function exists3(target) {
610
+ try {
611
+ await access3(target);
612
+ return true;
613
+ } catch {
577
614
  return false;
578
615
  }
579
- const dirName = path6.basename(path6.dirname(filePath)).toLowerCase();
580
- return SPEC_PACK_DIR_PATTERN.test(dirName);
581
616
  }
582
617
 
583
618
  // src/core/ids.ts
@@ -641,8 +676,8 @@ import { readFile as readFile2 } from "fs/promises";
641
676
  import path7 from "path";
642
677
  import { fileURLToPath as fileURLToPath2 } from "url";
643
678
  async function resolveToolVersion() {
644
- if ("0.3.1".length > 0) {
645
- return "0.3.1";
679
+ if ("0.3.2".length > 0) {
680
+ return "0.3.2";
646
681
  }
647
682
  try {
648
683
  const packagePath = resolvePackageJsonPath();
@@ -975,7 +1010,7 @@ async function validateDeltas(root, config) {
975
1010
  try {
976
1011
  text = await readFile4(deltaPath, "utf-8");
977
1012
  } catch (error2) {
978
- if (isMissingFileError(error2)) {
1013
+ if (isMissingFileError2(error2)) {
979
1014
  issues.push(
980
1015
  issue2(
981
1016
  "QFAI-DELTA-001",
@@ -1020,7 +1055,7 @@ async function validateDeltas(root, config) {
1020
1055
  }
1021
1056
  return issues;
1022
1057
  }
1023
- function isMissingFileError(error2) {
1058
+ function isMissingFileError2(error2) {
1024
1059
  if (!error2 || typeof error2 !== "object") {
1025
1060
  return false;
1026
1061
  }
@@ -1109,66 +1144,6 @@ function record(index, id, file) {
1109
1144
  index.idToFiles.set(id, current);
1110
1145
  }
1111
1146
 
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
1147
  // src/core/parse/markdown.ts
1173
1148
  var HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
1174
1149
  function parseHeadings(md) {
@@ -1287,8 +1262,166 @@ function parseSpec(md, file) {
1287
1262
  return parsed;
1288
1263
  }
1289
1264
 
1290
- // src/core/validators/ids.ts
1265
+ // src/core/gherkin/parse.ts
1266
+ import {
1267
+ AstBuilder,
1268
+ GherkinClassicTokenMatcher,
1269
+ Parser
1270
+ } from "@cucumber/gherkin";
1271
+ import { randomUUID } from "crypto";
1272
+ function parseGherkin(source, uri) {
1273
+ const errors = [];
1274
+ const uuidFn = () => randomUUID();
1275
+ const builder = new AstBuilder(uuidFn);
1276
+ const matcher = new GherkinClassicTokenMatcher();
1277
+ const parser = new Parser(builder, matcher);
1278
+ try {
1279
+ const gherkinDocument = parser.parse(source);
1280
+ gherkinDocument.uri = uri;
1281
+ return { gherkinDocument, errors };
1282
+ } catch (error2) {
1283
+ errors.push(formatError3(error2));
1284
+ return { gherkinDocument: null, errors };
1285
+ }
1286
+ }
1287
+ function formatError3(error2) {
1288
+ if (error2 instanceof Error) {
1289
+ return error2.message;
1290
+ }
1291
+ return String(error2);
1292
+ }
1293
+
1294
+ // src/core/scenarioModel.ts
1295
+ var SPEC_TAG_RE = /^SPEC-\d{4}$/;
1291
1296
  var SC_TAG_RE = /^SC-\d{4}$/;
1297
+ var BR_TAG_RE = /^BR-\d{4}$/;
1298
+ var UI_TAG_RE = /^UI-\d{4}$/;
1299
+ var API_TAG_RE = /^API-\d{4}$/;
1300
+ var DATA_TAG_RE = /^DATA-\d{4}$/;
1301
+ function parseScenarioDocument(text, uri) {
1302
+ const { gherkinDocument, errors } = parseGherkin(text, uri);
1303
+ if (!gherkinDocument) {
1304
+ return { document: null, errors };
1305
+ }
1306
+ const feature = gherkinDocument.feature;
1307
+ if (!feature) {
1308
+ return {
1309
+ document: { uri, featureTags: [], scenarios: [] },
1310
+ errors
1311
+ };
1312
+ }
1313
+ const featureTags = collectTagNames(feature.tags);
1314
+ const scenarios = collectScenarioNodes(feature, featureTags);
1315
+ return {
1316
+ document: {
1317
+ uri,
1318
+ featureName: feature.name,
1319
+ featureTags,
1320
+ scenarios
1321
+ },
1322
+ errors
1323
+ };
1324
+ }
1325
+ function buildScenarioAtoms(document) {
1326
+ return document.scenarios.map((scenario) => {
1327
+ const specIds = scenario.tags.filter((tag) => SPEC_TAG_RE.test(tag));
1328
+ const scIds = scenario.tags.filter((tag) => SC_TAG_RE.test(tag));
1329
+ const brIds = unique2(scenario.tags.filter((tag) => BR_TAG_RE.test(tag)));
1330
+ const contractIds = /* @__PURE__ */ new Set();
1331
+ scenario.tags.forEach((tag) => {
1332
+ if (UI_TAG_RE.test(tag) || API_TAG_RE.test(tag) || DATA_TAG_RE.test(tag)) {
1333
+ contractIds.add(tag);
1334
+ }
1335
+ });
1336
+ for (const step of scenario.steps) {
1337
+ for (const text of collectStepTexts(step)) {
1338
+ extractIds(text, "UI").forEach((id) => contractIds.add(id));
1339
+ extractIds(text, "API").forEach((id) => contractIds.add(id));
1340
+ extractIds(text, "DATA").forEach((id) => contractIds.add(id));
1341
+ }
1342
+ }
1343
+ const atom = {
1344
+ uri: document.uri,
1345
+ featureName: document.featureName ?? "",
1346
+ scenarioName: scenario.name,
1347
+ kind: scenario.kind,
1348
+ brIds,
1349
+ contractIds: Array.from(contractIds).sort()
1350
+ };
1351
+ if (scenario.line !== void 0) {
1352
+ atom.line = scenario.line;
1353
+ }
1354
+ if (specIds.length === 1) {
1355
+ const specId = specIds[0];
1356
+ if (specId) {
1357
+ atom.specId = specId;
1358
+ }
1359
+ }
1360
+ if (scIds.length === 1) {
1361
+ const scId = scIds[0];
1362
+ if (scId) {
1363
+ atom.scId = scId;
1364
+ }
1365
+ }
1366
+ return atom;
1367
+ });
1368
+ }
1369
+ function collectScenarioNodes(feature, featureTags) {
1370
+ const scenarios = [];
1371
+ for (const child of feature.children) {
1372
+ if (child.scenario) {
1373
+ scenarios.push(buildScenarioNode(child.scenario, featureTags, []));
1374
+ }
1375
+ if (child.rule) {
1376
+ const ruleTags = collectTagNames(child.rule.tags);
1377
+ for (const ruleChild of child.rule.children) {
1378
+ if (ruleChild.scenario) {
1379
+ scenarios.push(
1380
+ buildScenarioNode(ruleChild.scenario, featureTags, ruleTags)
1381
+ );
1382
+ }
1383
+ }
1384
+ }
1385
+ }
1386
+ return scenarios;
1387
+ }
1388
+ function buildScenarioNode(scenario, featureTags, ruleTags) {
1389
+ const tags = [...featureTags, ...ruleTags, ...collectTagNames(scenario.tags)];
1390
+ const kind = scenario.examples.length > 0 ? "ScenarioOutline" : "Scenario";
1391
+ return {
1392
+ name: scenario.name,
1393
+ kind,
1394
+ line: scenario.location?.line,
1395
+ tags,
1396
+ steps: scenario.steps
1397
+ };
1398
+ }
1399
+ function collectTagNames(tags) {
1400
+ return tags.map((tag) => tag.name.replace(/^@/, ""));
1401
+ }
1402
+ function collectStepTexts(step) {
1403
+ const texts = [];
1404
+ if (step.text) {
1405
+ texts.push(step.text);
1406
+ }
1407
+ if (step.docString?.content) {
1408
+ texts.push(step.docString.content);
1409
+ }
1410
+ if (step.dataTable?.rows) {
1411
+ for (const row of step.dataTable.rows) {
1412
+ for (const cell of row.cells) {
1413
+ texts.push(cell.value);
1414
+ }
1415
+ }
1416
+ }
1417
+ return texts;
1418
+ }
1419
+ function unique2(values) {
1420
+ return Array.from(new Set(values));
1421
+ }
1422
+
1423
+ // src/core/validators/ids.ts
1424
+ var SC_TAG_RE2 = /^SC-\d{4}$/;
1292
1425
  async function validateDefinedIds(root, config) {
1293
1426
  const issues = [];
1294
1427
  const specsRoot = resolvePath(root, config, "specsDir");
@@ -1333,10 +1466,13 @@ async function collectSpecDefinitionIds(files, out) {
1333
1466
  async function collectScenarioDefinitionIds(files, out) {
1334
1467
  for (const file of files) {
1335
1468
  const text = await readFile6(file, "utf-8");
1336
- const parsed = parseGherkinFeature(text, file);
1337
- for (const scenario of parsed.scenarios) {
1469
+ const { document, errors } = parseScenarioDocument(text, file);
1470
+ if (!document || errors.length > 0) {
1471
+ continue;
1472
+ }
1473
+ for (const scenario of document.scenarios) {
1338
1474
  for (const tag of scenario.tags) {
1339
- if (SC_TAG_RE.test(tag)) {
1475
+ if (SC_TAG_RE2.test(tag)) {
1340
1476
  recordId(out, tag, file);
1341
1477
  }
1342
1478
  }
@@ -1377,17 +1513,19 @@ import { readFile as readFile7 } from "fs/promises";
1377
1513
  var GIVEN_PATTERN = /\bGiven\b/;
1378
1514
  var WHEN_PATTERN = /\bWhen\b/;
1379
1515
  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}$/;
1516
+ var SC_TAG_RE3 = /^SC-\d{4}$/;
1517
+ var SPEC_TAG_RE2 = /^SPEC-\d{4}$/;
1518
+ var BR_TAG_RE2 = /^BR-\d{4}$/;
1383
1519
  async function validateScenarios(root, config) {
1384
1520
  const specsRoot = resolvePath(root, config, "specsDir");
1385
- const files = await collectScenarioFiles(specsRoot);
1386
- if (files.length === 0) {
1521
+ const entries = await collectSpecEntries(specsRoot);
1522
+ if (entries.length === 0) {
1523
+ const expected = "spec-0001/scenario.md";
1524
+ const legacy = "spec-001/scenario.md";
1387
1525
  return [
1388
1526
  issue4(
1389
1527
  "QFAI-SC-000",
1390
- "Scenario \u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1528
+ `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
1529
  "info",
1392
1530
  specsRoot,
1393
1531
  "scenario.files"
@@ -1395,15 +1533,31 @@ async function validateScenarios(root, config) {
1395
1533
  ];
1396
1534
  }
1397
1535
  const issues = [];
1398
- for (const file of files) {
1399
- const text = await readFile7(file, "utf-8");
1400
- issues.push(...validateScenarioContent(text, file));
1536
+ for (const entry of entries) {
1537
+ let text;
1538
+ try {
1539
+ text = await readFile7(entry.scenarioPath, "utf-8");
1540
+ } catch (error2) {
1541
+ if (isMissingFileError3(error2)) {
1542
+ issues.push(
1543
+ issue4(
1544
+ "QFAI-SC-001",
1545
+ "scenario.md \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1546
+ "error",
1547
+ entry.scenarioPath,
1548
+ "scenario.exists"
1549
+ )
1550
+ );
1551
+ continue;
1552
+ }
1553
+ throw error2;
1554
+ }
1555
+ issues.push(...validateScenarioContent(text, entry.scenarioPath));
1401
1556
  }
1402
1557
  return issues;
1403
1558
  }
1404
1559
  function validateScenarioContent(text, file) {
1405
1560
  const issues = [];
1406
- const parsed = parseGherkinFeature(text, file);
1407
1561
  const invalidIds = extractInvalidIds(text, [
1408
1562
  "SPEC",
1409
1563
  "BR",
@@ -1425,9 +1579,47 @@ function validateScenarioContent(text, file) {
1425
1579
  )
1426
1580
  );
1427
1581
  }
1582
+ const { document, errors } = parseScenarioDocument(text, file);
1583
+ if (!document || errors.length > 0) {
1584
+ issues.push(
1585
+ issue4(
1586
+ "QFAI-SC-010",
1587
+ `Gherkin \u306E\u89E3\u6790\u306B\u5931\u6557\u3057\u307E\u3057\u305F: ${errors.join(", ") || "unknown"}`,
1588
+ "error",
1589
+ file,
1590
+ "scenario.parse"
1591
+ )
1592
+ );
1593
+ return issues;
1594
+ }
1595
+ const featureSpecTags = document.featureTags.filter(
1596
+ (tag) => SPEC_TAG_RE2.test(tag)
1597
+ );
1598
+ if (featureSpecTags.length === 0) {
1599
+ issues.push(
1600
+ issue4(
1601
+ "QFAI-SC-009",
1602
+ "Feature \u30BF\u30B0\u306B SPEC \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1603
+ "error",
1604
+ file,
1605
+ "scenario.featureSpec"
1606
+ )
1607
+ );
1608
+ } else if (featureSpecTags.length > 1) {
1609
+ issues.push(
1610
+ issue4(
1611
+ "QFAI-SC-009",
1612
+ `Feature \u306E SPEC \u30BF\u30B0\u304C\u8907\u6570\u3042\u308A\u307E\u3059: ${featureSpecTags.join(", ")}`,
1613
+ "error",
1614
+ file,
1615
+ "scenario.featureSpec",
1616
+ featureSpecTags
1617
+ )
1618
+ );
1619
+ }
1428
1620
  const missingStructure = [];
1429
- if (!parsed.featurePresent) missingStructure.push("Feature");
1430
- if (parsed.scenarios.length === 0) missingStructure.push("Scenario");
1621
+ if (!document.featureName) missingStructure.push("Feature");
1622
+ if (document.scenarios.length === 0) missingStructure.push("Scenario");
1431
1623
  if (missingStructure.length > 0) {
1432
1624
  issues.push(
1433
1625
  issue4(
@@ -1441,7 +1633,7 @@ function validateScenarioContent(text, file) {
1441
1633
  )
1442
1634
  );
1443
1635
  }
1444
- for (const scenario of parsed.scenarios) {
1636
+ for (const scenario of document.scenarios) {
1445
1637
  if (scenario.tags.length === 0) {
1446
1638
  issues.push(
1447
1639
  issue4(
@@ -1455,16 +1647,16 @@ function validateScenarioContent(text, file) {
1455
1647
  continue;
1456
1648
  }
1457
1649
  const missingTags = [];
1458
- const scTags = scenario.tags.filter((tag) => SC_TAG_RE2.test(tag));
1650
+ const scTags = scenario.tags.filter((tag) => SC_TAG_RE3.test(tag));
1459
1651
  if (scTags.length === 0) {
1460
1652
  missingTags.push("SC(0\u4EF6)");
1461
1653
  } else if (scTags.length > 1) {
1462
1654
  missingTags.push(`SC(${scTags.length}\u4EF6/1\u4EF6\u5FC5\u9808)`);
1463
1655
  }
1464
- if (!scenario.tags.some((tag) => SPEC_TAG_RE.test(tag))) {
1656
+ if (!scenario.tags.some((tag) => SPEC_TAG_RE2.test(tag))) {
1465
1657
  missingTags.push("SPEC");
1466
1658
  }
1467
- if (!scenario.tags.some((tag) => BR_TAG_RE.test(tag))) {
1659
+ if (!scenario.tags.some((tag) => BR_TAG_RE2.test(tag))) {
1468
1660
  missingTags.push("BR");
1469
1661
  }
1470
1662
  if (missingTags.length > 0) {
@@ -1479,15 +1671,16 @@ function validateScenarioContent(text, file) {
1479
1671
  );
1480
1672
  }
1481
1673
  }
1482
- for (const scenario of parsed.scenarios) {
1674
+ for (const scenario of document.scenarios) {
1483
1675
  const missingSteps = [];
1484
- if (!GIVEN_PATTERN.test(scenario.body)) {
1676
+ const keywords = scenario.steps.map((step) => step.keyword.trim());
1677
+ if (!keywords.some((keyword) => GIVEN_PATTERN.test(keyword))) {
1485
1678
  missingSteps.push("Given");
1486
1679
  }
1487
- if (!WHEN_PATTERN.test(scenario.body)) {
1680
+ if (!keywords.some((keyword) => WHEN_PATTERN.test(keyword))) {
1488
1681
  missingSteps.push("When");
1489
1682
  }
1490
- if (!THEN_PATTERN.test(scenario.body)) {
1683
+ if (!keywords.some((keyword) => THEN_PATTERN.test(keyword))) {
1491
1684
  missingSteps.push("Then");
1492
1685
  }
1493
1686
  if (missingSteps.length > 0) {
@@ -1521,18 +1714,25 @@ function issue4(code, message, severity, file, rule, refs) {
1521
1714
  }
1522
1715
  return issue7;
1523
1716
  }
1717
+ function isMissingFileError3(error2) {
1718
+ if (!error2 || typeof error2 !== "object") {
1719
+ return false;
1720
+ }
1721
+ return error2.code === "ENOENT";
1722
+ }
1524
1723
 
1525
1724
  // src/core/validators/spec.ts
1526
1725
  import { readFile as readFile8 } from "fs/promises";
1527
1726
  async function validateSpecs(root, config) {
1528
1727
  const specsRoot = resolvePath(root, config, "specsDir");
1529
- const files = await collectSpecFiles(specsRoot);
1530
- if (files.length === 0) {
1531
- const expected = "spec-001/spec.md";
1728
+ const entries = await collectSpecEntries(specsRoot);
1729
+ if (entries.length === 0) {
1730
+ const expected = "spec-0001/spec.md";
1731
+ const legacy = "spec-001/spec.md";
1532
1732
  return [
1533
1733
  issue5(
1534
1734
  "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}`,
1735
+ `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
1736
  "info",
1537
1737
  specsRoot,
1538
1738
  "spec.files"
@@ -1540,12 +1740,29 @@ async function validateSpecs(root, config) {
1540
1740
  ];
1541
1741
  }
1542
1742
  const issues = [];
1543
- for (const file of files) {
1544
- const text = await readFile8(file, "utf-8");
1743
+ for (const entry of entries) {
1744
+ let text;
1745
+ try {
1746
+ text = await readFile8(entry.specPath, "utf-8");
1747
+ } catch (error2) {
1748
+ if (isMissingFileError4(error2)) {
1749
+ issues.push(
1750
+ issue5(
1751
+ "QFAI-SPEC-005",
1752
+ "spec.md \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1753
+ "error",
1754
+ entry.specPath,
1755
+ "spec.exists"
1756
+ )
1757
+ );
1758
+ continue;
1759
+ }
1760
+ throw error2;
1761
+ }
1545
1762
  issues.push(
1546
1763
  ...validateSpecContent(
1547
1764
  text,
1548
- file,
1765
+ entry.specPath,
1549
1766
  config.validation.require.specSections
1550
1767
  )
1551
1768
  );
@@ -1667,15 +1884,18 @@ function issue5(code, message, severity, file, rule, refs) {
1667
1884
  }
1668
1885
  return issue7;
1669
1886
  }
1887
+ function isMissingFileError4(error2) {
1888
+ if (!error2 || typeof error2 !== "object") {
1889
+ return false;
1890
+ }
1891
+ return error2.code === "ENOENT";
1892
+ }
1670
1893
 
1671
1894
  // src/core/validators/traceability.ts
1672
1895
  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}$/;
1896
+ var SC_TAG_RE4 = /^SC-\d{4}$/;
1897
+ var SPEC_TAG_RE3 = /^SPEC-\d{4}$/;
1898
+ var BR_TAG_RE3 = /^BR-\d{4}$/;
1679
1899
  async function validateTraceability(root, config) {
1680
1900
  const issues = [];
1681
1901
  const specsRoot = resolvePath(root, config, "specsDir");
@@ -1733,104 +1953,97 @@ async function validateTraceability(root, config) {
1733
1953
  for (const file of scenarioFiles) {
1734
1954
  const text = await readFile9(file, "utf-8");
1735
1955
  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
- );
1956
+ const { document, errors } = parseScenarioDocument(text, file);
1957
+ if (!document || errors.length > 0) {
1958
+ continue;
1809
1959
  }
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));
1960
+ const atoms = buildScenarioAtoms(document);
1961
+ for (const [index, scenario] of document.scenarios.entries()) {
1962
+ const atom = atoms[index];
1963
+ if (!atom) {
1964
+ continue;
1965
+ }
1966
+ const specTags = scenario.tags.filter((tag) => SPEC_TAG_RE3.test(tag));
1967
+ const brTags = scenario.tags.filter((tag) => BR_TAG_RE3.test(tag));
1968
+ const scTags = scenario.tags.filter((tag) => SC_TAG_RE4.test(tag));
1969
+ brTags.forEach((id) => brIdsInScenarios.add(id));
1970
+ scTags.forEach((id) => scIdsInScenarios.add(id));
1971
+ atom.contractIds.forEach((id) => scenarioContractIds.add(id));
1972
+ if (atom.contractIds.length > 0) {
1973
+ scTags.forEach((id) => scWithContracts.add(id));
1974
+ }
1975
+ const unknownSpecIds = specTags.filter((id) => !specIds.has(id));
1976
+ if (unknownSpecIds.length > 0) {
1977
+ issues.push(
1978
+ issue6(
1979
+ "QFAI-TRACE-005",
1980
+ `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044 SPEC \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownSpecIds.join(
1981
+ ", "
1982
+ )} (${scenario.name})`,
1983
+ "error",
1984
+ file,
1985
+ "traceability.scenarioSpecExists",
1986
+ unknownSpecIds
1987
+ )
1988
+ );
1818
1989
  }
1819
- const invalidBrIds = brIdsList.filter((id) => !allowedBrIds.has(id));
1820
- if (invalidBrIds.length > 0) {
1990
+ const unknownBrIds = brTags.filter((id) => !brIdsInSpecs.has(id));
1991
+ if (unknownBrIds.length > 0) {
1821
1992
  issues.push(
1822
1993
  issue6(
1823
- "QFAI-TRACE-007",
1824
- `Scenario \u306E BR \u304C\u53C2\u7167 SPEC \u306B\u5C5E\u3057\u3066\u3044\u307E\u305B\u3093: ${invalidBrIds.join(
1994
+ "QFAI-TRACE-006",
1995
+ `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044 BR \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownBrIds.join(
1825
1996
  ", "
1826
- )} (SPEC: ${specIdsList.join(", ")})`,
1997
+ )} (${scenario.name})`,
1827
1998
  "error",
1828
1999
  file,
1829
- "traceability.scenarioBrUnderSpec",
1830
- invalidBrIds
2000
+ "traceability.scenarioBrExists",
2001
+ unknownBrIds
2002
+ )
2003
+ );
2004
+ }
2005
+ const unknownContractIds = atom.contractIds.filter(
2006
+ (id) => !contractIds.has(id)
2007
+ );
2008
+ if (unknownContractIds.length > 0) {
2009
+ issues.push(
2010
+ issue6(
2011
+ "QFAI-TRACE-008",
2012
+ `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044\u5951\u7D04 ID \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownContractIds.join(
2013
+ ", "
2014
+ )} (${scenario.name})`,
2015
+ config.validation.traceability.unknownContractIdSeverity,
2016
+ file,
2017
+ "traceability.scenarioContractExists",
2018
+ unknownContractIds
1831
2019
  )
1832
2020
  );
1833
2021
  }
2022
+ if (specTags.length > 0 && brTags.length > 0) {
2023
+ const allowedBrIds = /* @__PURE__ */ new Set();
2024
+ for (const specId of specTags) {
2025
+ const brIdsForSpec = specToBrIds.get(specId);
2026
+ if (!brIdsForSpec) {
2027
+ continue;
2028
+ }
2029
+ brIdsForSpec.forEach((id) => allowedBrIds.add(id));
2030
+ }
2031
+ const invalidBrIds = brTags.filter((id) => !allowedBrIds.has(id));
2032
+ if (invalidBrIds.length > 0) {
2033
+ issues.push(
2034
+ issue6(
2035
+ "QFAI-TRACE-007",
2036
+ `Scenario \u306E BR \u304C\u53C2\u7167 SPEC \u306B\u5C5E\u3057\u3066\u3044\u307E\u305B\u3093: ${invalidBrIds.join(
2037
+ ", "
2038
+ )} (SPEC: ${specTags.join(", ")}) (${scenario.name})`,
2039
+ "error",
2040
+ file,
2041
+ "traceability.scenarioBrUnderSpec",
2042
+ invalidBrIds
2043
+ )
2044
+ );
2045
+ }
2046
+ }
1834
2047
  }
1835
2048
  }
1836
2049
  if (upstreamIds.size === 0) {
@@ -2246,7 +2459,7 @@ async function runReport(options) {
2246
2459
  try {
2247
2460
  validation = await readValidationResult(inputPath);
2248
2461
  } catch (err) {
2249
- if (isMissingFileError2(err)) {
2462
+ if (isMissingFileError5(err)) {
2250
2463
  error(
2251
2464
  [
2252
2465
  `qfai report: \u5165\u529B\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${inputPath}`,
@@ -2310,7 +2523,7 @@ function isValidationResult(value) {
2310
2523
  }
2311
2524
  return typeof counts.info === "number" && typeof counts.warning === "number" && typeof counts.error === "number";
2312
2525
  }
2313
- function isMissingFileError2(error2) {
2526
+ function isMissingFileError5(error2) {
2314
2527
  if (!error2 || typeof error2 !== "object") {
2315
2528
  return false;
2316
2529
  }