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.
package/dist/index.mjs CHANGED
@@ -389,7 +389,7 @@ import { readFile as readFile10 } from "fs/promises";
389
389
  import path10 from "path";
390
390
 
391
391
  // src/core/discovery.ts
392
- import path3 from "path";
392
+ import { access as access2 } from "fs/promises";
393
393
 
394
394
  // src/core/fs.ts
395
395
  import { access, readdir } from "fs/promises";
@@ -446,25 +446,50 @@ async function exists(target) {
446
446
  }
447
447
  }
448
448
 
449
- // src/core/discovery.ts
450
- var SPEC_PACK_DIR_PATTERN = /^spec-\d{3}$/;
451
- async function collectSpecPackDirs(specsRoot) {
452
- const files = await collectFiles(specsRoot, { extensions: [".md"] });
453
- const packs = /* @__PURE__ */ new Set();
454
- for (const file of files) {
455
- if (isSpecPackFile(file, "spec.md")) {
456
- packs.add(path3.dirname(file));
449
+ // src/core/specLayout.ts
450
+ import { readdir as readdir2 } from "fs/promises";
451
+ import path3 from "path";
452
+ var SPEC_DIR_RE = /^spec-\d{4}$/;
453
+ async function collectSpecEntries(specsRoot) {
454
+ const dirs = await listSpecDirs(specsRoot);
455
+ const entries = dirs.map((dir) => ({
456
+ dir,
457
+ specPath: path3.join(dir, "spec.md"),
458
+ deltaPath: path3.join(dir, "delta.md"),
459
+ scenarioPath: path3.join(dir, "scenario.md")
460
+ }));
461
+ return entries.sort((a, b) => a.dir.localeCompare(b.dir));
462
+ }
463
+ async function listSpecDirs(specsRoot) {
464
+ try {
465
+ const items = await readdir2(specsRoot, { withFileTypes: true });
466
+ return items.filter((item) => item.isDirectory()).map((item) => item.name).filter((name) => SPEC_DIR_RE.test(name.toLowerCase())).map((name) => path3.join(specsRoot, name));
467
+ } catch (error) {
468
+ if (isMissingFileError(error)) {
469
+ return [];
457
470
  }
471
+ throw error;
458
472
  }
459
- return Array.from(packs).sort();
473
+ }
474
+ function isMissingFileError(error) {
475
+ if (!error || typeof error !== "object") {
476
+ return false;
477
+ }
478
+ return error.code === "ENOENT";
479
+ }
480
+
481
+ // src/core/discovery.ts
482
+ async function collectSpecPackDirs(specsRoot) {
483
+ const entries = await collectSpecEntries(specsRoot);
484
+ return entries.map((entry) => entry.dir);
460
485
  }
461
486
  async function collectSpecFiles(specsRoot) {
462
- const files = await collectFiles(specsRoot, { extensions: [".md"] });
463
- return files.filter((file) => isSpecPackFile(file, "spec.md"));
487
+ const entries = await collectSpecEntries(specsRoot);
488
+ return filterExisting(entries.map((entry) => entry.specPath));
464
489
  }
465
490
  async function collectScenarioFiles(specsRoot) {
466
- const files = await collectFiles(specsRoot, { extensions: [".md"] });
467
- return files.filter((file) => isSpecPackFile(file, "scenario.md"));
491
+ const entries = await collectSpecEntries(specsRoot);
492
+ return filterExisting(entries.map((entry) => entry.scenarioPath));
468
493
  }
469
494
  async function collectUiContractFiles(uiRoot) {
470
495
  return collectFiles(uiRoot, { extensions: [".yaml", ".yml"] });
@@ -483,12 +508,22 @@ async function collectContractFiles(uiRoot, apiRoot, dataRoot) {
483
508
  ]);
484
509
  return { ui, api, db };
485
510
  }
486
- function isSpecPackFile(filePath, baseName) {
487
- if (path3.basename(filePath).toLowerCase() !== baseName) {
511
+ async function filterExisting(files) {
512
+ const existing = [];
513
+ for (const file of files) {
514
+ if (await exists2(file)) {
515
+ existing.push(file);
516
+ }
517
+ }
518
+ return existing;
519
+ }
520
+ async function exists2(target) {
521
+ try {
522
+ await access2(target);
523
+ return true;
524
+ } catch {
488
525
  return false;
489
526
  }
490
- const dirName = path3.basename(path3.dirname(filePath)).toLowerCase();
491
- return SPEC_PACK_DIR_PATTERN.test(dirName);
492
527
  }
493
528
 
494
529
  // src/core/types.ts
@@ -499,8 +534,8 @@ import { readFile as readFile2 } from "fs/promises";
499
534
  import path4 from "path";
500
535
  import { fileURLToPath } from "url";
501
536
  async function resolveToolVersion() {
502
- if ("0.3.1".length > 0) {
503
- return "0.3.1";
537
+ if ("0.3.2".length > 0) {
538
+ return "0.3.2";
504
539
  }
505
540
  try {
506
541
  const packagePath = resolvePackageJsonPath();
@@ -833,7 +868,7 @@ async function validateDeltas(root, config) {
833
868
  try {
834
869
  text = await readFile4(deltaPath, "utf-8");
835
870
  } catch (error) {
836
- if (isMissingFileError(error)) {
871
+ if (isMissingFileError2(error)) {
837
872
  issues.push(
838
873
  issue2(
839
874
  "QFAI-DELTA-001",
@@ -878,7 +913,7 @@ async function validateDeltas(root, config) {
878
913
  }
879
914
  return issues;
880
915
  }
881
- function isMissingFileError(error) {
916
+ function isMissingFileError2(error) {
882
917
  if (!error || typeof error !== "object") {
883
918
  return false;
884
919
  }
@@ -967,66 +1002,6 @@ function record(index, id, file) {
967
1002
  index.idToFiles.set(id, current);
968
1003
  }
969
1004
 
970
- // src/core/parse/gherkin.ts
971
- var FEATURE_RE = /^\s*Feature:\s+/;
972
- var SCENARIO_RE = /^\s*Scenario(?: Outline)?:\s*(.+)\s*$/;
973
- var TAG_LINE_RE = /^\s*@/;
974
- function parseTags(line) {
975
- return line.trim().split(/\s+/).filter((tag) => tag.startsWith("@")).map((tag) => tag.replace(/^@/, ""));
976
- }
977
- function parseGherkinFeature(text, file) {
978
- const lines = text.split(/\r?\n/);
979
- const scenarios = [];
980
- let featurePresent = false;
981
- let featureTags = [];
982
- let pendingTags = [];
983
- let current = null;
984
- const flush = () => {
985
- if (!current) return;
986
- scenarios.push({
987
- ...current,
988
- body: current.body.trim()
989
- });
990
- current = null;
991
- };
992
- for (let i = 0; i < lines.length; i++) {
993
- const line = lines[i] ?? "";
994
- const trimmed = line.trim();
995
- if (TAG_LINE_RE.test(trimmed)) {
996
- pendingTags.push(...parseTags(trimmed));
997
- continue;
998
- }
999
- if (FEATURE_RE.test(trimmed)) {
1000
- featurePresent = true;
1001
- featureTags = [...pendingTags];
1002
- pendingTags = [];
1003
- continue;
1004
- }
1005
- const match = trimmed.match(SCENARIO_RE);
1006
- if (match) {
1007
- const scenarioName = match[1]?.trim();
1008
- if (!scenarioName) {
1009
- continue;
1010
- }
1011
- flush();
1012
- current = {
1013
- name: scenarioName,
1014
- line: i + 1,
1015
- tags: [...featureTags, ...pendingTags],
1016
- body: ""
1017
- };
1018
- pendingTags = [];
1019
- continue;
1020
- }
1021
- if (current) {
1022
- current.body += `${line}
1023
- `;
1024
- }
1025
- }
1026
- flush();
1027
- return { file, featurePresent, scenarios };
1028
- }
1029
-
1030
1005
  // src/core/parse/markdown.ts
1031
1006
  var HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
1032
1007
  function parseHeadings(md) {
@@ -1145,8 +1120,166 @@ function parseSpec(md, file) {
1145
1120
  return parsed;
1146
1121
  }
1147
1122
 
1148
- // src/core/validators/ids.ts
1123
+ // src/core/gherkin/parse.ts
1124
+ import {
1125
+ AstBuilder,
1126
+ GherkinClassicTokenMatcher,
1127
+ Parser
1128
+ } from "@cucumber/gherkin";
1129
+ import { randomUUID } from "crypto";
1130
+ function parseGherkin(source, uri) {
1131
+ const errors = [];
1132
+ const uuidFn = () => randomUUID();
1133
+ const builder = new AstBuilder(uuidFn);
1134
+ const matcher = new GherkinClassicTokenMatcher();
1135
+ const parser = new Parser(builder, matcher);
1136
+ try {
1137
+ const gherkinDocument = parser.parse(source);
1138
+ gherkinDocument.uri = uri;
1139
+ return { gherkinDocument, errors };
1140
+ } catch (error) {
1141
+ errors.push(formatError3(error));
1142
+ return { gherkinDocument: null, errors };
1143
+ }
1144
+ }
1145
+ function formatError3(error) {
1146
+ if (error instanceof Error) {
1147
+ return error.message;
1148
+ }
1149
+ return String(error);
1150
+ }
1151
+
1152
+ // src/core/scenarioModel.ts
1153
+ var SPEC_TAG_RE = /^SPEC-\d{4}$/;
1149
1154
  var SC_TAG_RE = /^SC-\d{4}$/;
1155
+ var BR_TAG_RE = /^BR-\d{4}$/;
1156
+ var UI_TAG_RE = /^UI-\d{4}$/;
1157
+ var API_TAG_RE = /^API-\d{4}$/;
1158
+ var DATA_TAG_RE = /^DATA-\d{4}$/;
1159
+ function parseScenarioDocument(text, uri) {
1160
+ const { gherkinDocument, errors } = parseGherkin(text, uri);
1161
+ if (!gherkinDocument) {
1162
+ return { document: null, errors };
1163
+ }
1164
+ const feature = gherkinDocument.feature;
1165
+ if (!feature) {
1166
+ return {
1167
+ document: { uri, featureTags: [], scenarios: [] },
1168
+ errors
1169
+ };
1170
+ }
1171
+ const featureTags = collectTagNames(feature.tags);
1172
+ const scenarios = collectScenarioNodes(feature, featureTags);
1173
+ return {
1174
+ document: {
1175
+ uri,
1176
+ featureName: feature.name,
1177
+ featureTags,
1178
+ scenarios
1179
+ },
1180
+ errors
1181
+ };
1182
+ }
1183
+ function buildScenarioAtoms(document) {
1184
+ return document.scenarios.map((scenario) => {
1185
+ const specIds = scenario.tags.filter((tag) => SPEC_TAG_RE.test(tag));
1186
+ const scIds = scenario.tags.filter((tag) => SC_TAG_RE.test(tag));
1187
+ const brIds = unique2(scenario.tags.filter((tag) => BR_TAG_RE.test(tag)));
1188
+ const contractIds = /* @__PURE__ */ new Set();
1189
+ scenario.tags.forEach((tag) => {
1190
+ if (UI_TAG_RE.test(tag) || API_TAG_RE.test(tag) || DATA_TAG_RE.test(tag)) {
1191
+ contractIds.add(tag);
1192
+ }
1193
+ });
1194
+ for (const step of scenario.steps) {
1195
+ for (const text of collectStepTexts(step)) {
1196
+ extractIds(text, "UI").forEach((id) => contractIds.add(id));
1197
+ extractIds(text, "API").forEach((id) => contractIds.add(id));
1198
+ extractIds(text, "DATA").forEach((id) => contractIds.add(id));
1199
+ }
1200
+ }
1201
+ const atom = {
1202
+ uri: document.uri,
1203
+ featureName: document.featureName ?? "",
1204
+ scenarioName: scenario.name,
1205
+ kind: scenario.kind,
1206
+ brIds,
1207
+ contractIds: Array.from(contractIds).sort()
1208
+ };
1209
+ if (scenario.line !== void 0) {
1210
+ atom.line = scenario.line;
1211
+ }
1212
+ if (specIds.length === 1) {
1213
+ const specId = specIds[0];
1214
+ if (specId) {
1215
+ atom.specId = specId;
1216
+ }
1217
+ }
1218
+ if (scIds.length === 1) {
1219
+ const scId = scIds[0];
1220
+ if (scId) {
1221
+ atom.scId = scId;
1222
+ }
1223
+ }
1224
+ return atom;
1225
+ });
1226
+ }
1227
+ function collectScenarioNodes(feature, featureTags) {
1228
+ const scenarios = [];
1229
+ for (const child of feature.children) {
1230
+ if (child.scenario) {
1231
+ scenarios.push(buildScenarioNode(child.scenario, featureTags, []));
1232
+ }
1233
+ if (child.rule) {
1234
+ const ruleTags = collectTagNames(child.rule.tags);
1235
+ for (const ruleChild of child.rule.children) {
1236
+ if (ruleChild.scenario) {
1237
+ scenarios.push(
1238
+ buildScenarioNode(ruleChild.scenario, featureTags, ruleTags)
1239
+ );
1240
+ }
1241
+ }
1242
+ }
1243
+ }
1244
+ return scenarios;
1245
+ }
1246
+ function buildScenarioNode(scenario, featureTags, ruleTags) {
1247
+ const tags = [...featureTags, ...ruleTags, ...collectTagNames(scenario.tags)];
1248
+ const kind = scenario.examples.length > 0 ? "ScenarioOutline" : "Scenario";
1249
+ return {
1250
+ name: scenario.name,
1251
+ kind,
1252
+ line: scenario.location?.line,
1253
+ tags,
1254
+ steps: scenario.steps
1255
+ };
1256
+ }
1257
+ function collectTagNames(tags) {
1258
+ return tags.map((tag) => tag.name.replace(/^@/, ""));
1259
+ }
1260
+ function collectStepTexts(step) {
1261
+ const texts = [];
1262
+ if (step.text) {
1263
+ texts.push(step.text);
1264
+ }
1265
+ if (step.docString?.content) {
1266
+ texts.push(step.docString.content);
1267
+ }
1268
+ if (step.dataTable?.rows) {
1269
+ for (const row of step.dataTable.rows) {
1270
+ for (const cell of row.cells) {
1271
+ texts.push(cell.value);
1272
+ }
1273
+ }
1274
+ }
1275
+ return texts;
1276
+ }
1277
+ function unique2(values) {
1278
+ return Array.from(new Set(values));
1279
+ }
1280
+
1281
+ // src/core/validators/ids.ts
1282
+ var SC_TAG_RE2 = /^SC-\d{4}$/;
1150
1283
  async function validateDefinedIds(root, config) {
1151
1284
  const issues = [];
1152
1285
  const specsRoot = resolvePath(root, config, "specsDir");
@@ -1191,10 +1324,13 @@ async function collectSpecDefinitionIds(files, out) {
1191
1324
  async function collectScenarioDefinitionIds(files, out) {
1192
1325
  for (const file of files) {
1193
1326
  const text = await readFile6(file, "utf-8");
1194
- const parsed = parseGherkinFeature(text, file);
1195
- for (const scenario of parsed.scenarios) {
1327
+ const { document, errors } = parseScenarioDocument(text, file);
1328
+ if (!document || errors.length > 0) {
1329
+ continue;
1330
+ }
1331
+ for (const scenario of document.scenarios) {
1196
1332
  for (const tag of scenario.tags) {
1197
- if (SC_TAG_RE.test(tag)) {
1333
+ if (SC_TAG_RE2.test(tag)) {
1198
1334
  recordId(out, tag, file);
1199
1335
  }
1200
1336
  }
@@ -1235,17 +1371,19 @@ import { readFile as readFile7 } from "fs/promises";
1235
1371
  var GIVEN_PATTERN = /\bGiven\b/;
1236
1372
  var WHEN_PATTERN = /\bWhen\b/;
1237
1373
  var THEN_PATTERN = /\bThen\b/;
1238
- var SC_TAG_RE2 = /^SC-\d{4}$/;
1239
- var SPEC_TAG_RE = /^SPEC-\d{4}$/;
1240
- var BR_TAG_RE = /^BR-\d{4}$/;
1374
+ var SC_TAG_RE3 = /^SC-\d{4}$/;
1375
+ var SPEC_TAG_RE2 = /^SPEC-\d{4}$/;
1376
+ var BR_TAG_RE2 = /^BR-\d{4}$/;
1241
1377
  async function validateScenarios(root, config) {
1242
1378
  const specsRoot = resolvePath(root, config, "specsDir");
1243
- const files = await collectScenarioFiles(specsRoot);
1244
- if (files.length === 0) {
1379
+ const entries = await collectSpecEntries(specsRoot);
1380
+ if (entries.length === 0) {
1381
+ const expected = "spec-0001/scenario.md";
1382
+ const legacy = "spec-001/scenario.md";
1245
1383
  return [
1246
1384
  issue4(
1247
1385
  "QFAI-SC-000",
1248
- "Scenario \u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1386
+ `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)`,
1249
1387
  "info",
1250
1388
  specsRoot,
1251
1389
  "scenario.files"
@@ -1253,15 +1391,31 @@ async function validateScenarios(root, config) {
1253
1391
  ];
1254
1392
  }
1255
1393
  const issues = [];
1256
- for (const file of files) {
1257
- const text = await readFile7(file, "utf-8");
1258
- issues.push(...validateScenarioContent(text, file));
1394
+ for (const entry of entries) {
1395
+ let text;
1396
+ try {
1397
+ text = await readFile7(entry.scenarioPath, "utf-8");
1398
+ } catch (error) {
1399
+ if (isMissingFileError3(error)) {
1400
+ issues.push(
1401
+ issue4(
1402
+ "QFAI-SC-001",
1403
+ "scenario.md \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1404
+ "error",
1405
+ entry.scenarioPath,
1406
+ "scenario.exists"
1407
+ )
1408
+ );
1409
+ continue;
1410
+ }
1411
+ throw error;
1412
+ }
1413
+ issues.push(...validateScenarioContent(text, entry.scenarioPath));
1259
1414
  }
1260
1415
  return issues;
1261
1416
  }
1262
1417
  function validateScenarioContent(text, file) {
1263
1418
  const issues = [];
1264
- const parsed = parseGherkinFeature(text, file);
1265
1419
  const invalidIds = extractInvalidIds(text, [
1266
1420
  "SPEC",
1267
1421
  "BR",
@@ -1283,9 +1437,47 @@ function validateScenarioContent(text, file) {
1283
1437
  )
1284
1438
  );
1285
1439
  }
1440
+ const { document, errors } = parseScenarioDocument(text, file);
1441
+ if (!document || errors.length > 0) {
1442
+ issues.push(
1443
+ issue4(
1444
+ "QFAI-SC-010",
1445
+ `Gherkin \u306E\u89E3\u6790\u306B\u5931\u6557\u3057\u307E\u3057\u305F: ${errors.join(", ") || "unknown"}`,
1446
+ "error",
1447
+ file,
1448
+ "scenario.parse"
1449
+ )
1450
+ );
1451
+ return issues;
1452
+ }
1453
+ const featureSpecTags = document.featureTags.filter(
1454
+ (tag) => SPEC_TAG_RE2.test(tag)
1455
+ );
1456
+ if (featureSpecTags.length === 0) {
1457
+ issues.push(
1458
+ issue4(
1459
+ "QFAI-SC-009",
1460
+ "Feature \u30BF\u30B0\u306B SPEC \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1461
+ "error",
1462
+ file,
1463
+ "scenario.featureSpec"
1464
+ )
1465
+ );
1466
+ } else if (featureSpecTags.length > 1) {
1467
+ issues.push(
1468
+ issue4(
1469
+ "QFAI-SC-009",
1470
+ `Feature \u306E SPEC \u30BF\u30B0\u304C\u8907\u6570\u3042\u308A\u307E\u3059: ${featureSpecTags.join(", ")}`,
1471
+ "error",
1472
+ file,
1473
+ "scenario.featureSpec",
1474
+ featureSpecTags
1475
+ )
1476
+ );
1477
+ }
1286
1478
  const missingStructure = [];
1287
- if (!parsed.featurePresent) missingStructure.push("Feature");
1288
- if (parsed.scenarios.length === 0) missingStructure.push("Scenario");
1479
+ if (!document.featureName) missingStructure.push("Feature");
1480
+ if (document.scenarios.length === 0) missingStructure.push("Scenario");
1289
1481
  if (missingStructure.length > 0) {
1290
1482
  issues.push(
1291
1483
  issue4(
@@ -1299,7 +1491,7 @@ function validateScenarioContent(text, file) {
1299
1491
  )
1300
1492
  );
1301
1493
  }
1302
- for (const scenario of parsed.scenarios) {
1494
+ for (const scenario of document.scenarios) {
1303
1495
  if (scenario.tags.length === 0) {
1304
1496
  issues.push(
1305
1497
  issue4(
@@ -1313,16 +1505,16 @@ function validateScenarioContent(text, file) {
1313
1505
  continue;
1314
1506
  }
1315
1507
  const missingTags = [];
1316
- const scTags = scenario.tags.filter((tag) => SC_TAG_RE2.test(tag));
1508
+ const scTags = scenario.tags.filter((tag) => SC_TAG_RE3.test(tag));
1317
1509
  if (scTags.length === 0) {
1318
1510
  missingTags.push("SC(0\u4EF6)");
1319
1511
  } else if (scTags.length > 1) {
1320
1512
  missingTags.push(`SC(${scTags.length}\u4EF6/1\u4EF6\u5FC5\u9808)`);
1321
1513
  }
1322
- if (!scenario.tags.some((tag) => SPEC_TAG_RE.test(tag))) {
1514
+ if (!scenario.tags.some((tag) => SPEC_TAG_RE2.test(tag))) {
1323
1515
  missingTags.push("SPEC");
1324
1516
  }
1325
- if (!scenario.tags.some((tag) => BR_TAG_RE.test(tag))) {
1517
+ if (!scenario.tags.some((tag) => BR_TAG_RE2.test(tag))) {
1326
1518
  missingTags.push("BR");
1327
1519
  }
1328
1520
  if (missingTags.length > 0) {
@@ -1337,15 +1529,16 @@ function validateScenarioContent(text, file) {
1337
1529
  );
1338
1530
  }
1339
1531
  }
1340
- for (const scenario of parsed.scenarios) {
1532
+ for (const scenario of document.scenarios) {
1341
1533
  const missingSteps = [];
1342
- if (!GIVEN_PATTERN.test(scenario.body)) {
1534
+ const keywords = scenario.steps.map((step) => step.keyword.trim());
1535
+ if (!keywords.some((keyword) => GIVEN_PATTERN.test(keyword))) {
1343
1536
  missingSteps.push("Given");
1344
1537
  }
1345
- if (!WHEN_PATTERN.test(scenario.body)) {
1538
+ if (!keywords.some((keyword) => WHEN_PATTERN.test(keyword))) {
1346
1539
  missingSteps.push("When");
1347
1540
  }
1348
- if (!THEN_PATTERN.test(scenario.body)) {
1541
+ if (!keywords.some((keyword) => THEN_PATTERN.test(keyword))) {
1349
1542
  missingSteps.push("Then");
1350
1543
  }
1351
1544
  if (missingSteps.length > 0) {
@@ -1379,18 +1572,25 @@ function issue4(code, message, severity, file, rule, refs) {
1379
1572
  }
1380
1573
  return issue7;
1381
1574
  }
1575
+ function isMissingFileError3(error) {
1576
+ if (!error || typeof error !== "object") {
1577
+ return false;
1578
+ }
1579
+ return error.code === "ENOENT";
1580
+ }
1382
1581
 
1383
1582
  // src/core/validators/spec.ts
1384
1583
  import { readFile as readFile8 } from "fs/promises";
1385
1584
  async function validateSpecs(root, config) {
1386
1585
  const specsRoot = resolvePath(root, config, "specsDir");
1387
- const files = await collectSpecFiles(specsRoot);
1388
- if (files.length === 0) {
1389
- const expected = "spec-001/spec.md";
1586
+ const entries = await collectSpecEntries(specsRoot);
1587
+ if (entries.length === 0) {
1588
+ const expected = "spec-0001/spec.md";
1589
+ const legacy = "spec-001/spec.md";
1390
1590
  return [
1391
1591
  issue5(
1392
1592
  "QFAI-SPEC-000",
1393
- `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}`,
1593
+ `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)`,
1394
1594
  "info",
1395
1595
  specsRoot,
1396
1596
  "spec.files"
@@ -1398,12 +1598,29 @@ async function validateSpecs(root, config) {
1398
1598
  ];
1399
1599
  }
1400
1600
  const issues = [];
1401
- for (const file of files) {
1402
- const text = await readFile8(file, "utf-8");
1601
+ for (const entry of entries) {
1602
+ let text;
1603
+ try {
1604
+ text = await readFile8(entry.specPath, "utf-8");
1605
+ } catch (error) {
1606
+ if (isMissingFileError4(error)) {
1607
+ issues.push(
1608
+ issue5(
1609
+ "QFAI-SPEC-005",
1610
+ "spec.md \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002",
1611
+ "error",
1612
+ entry.specPath,
1613
+ "spec.exists"
1614
+ )
1615
+ );
1616
+ continue;
1617
+ }
1618
+ throw error;
1619
+ }
1403
1620
  issues.push(
1404
1621
  ...validateSpecContent(
1405
1622
  text,
1406
- file,
1623
+ entry.specPath,
1407
1624
  config.validation.require.specSections
1408
1625
  )
1409
1626
  );
@@ -1525,15 +1742,18 @@ function issue5(code, message, severity, file, rule, refs) {
1525
1742
  }
1526
1743
  return issue7;
1527
1744
  }
1745
+ function isMissingFileError4(error) {
1746
+ if (!error || typeof error !== "object") {
1747
+ return false;
1748
+ }
1749
+ return error.code === "ENOENT";
1750
+ }
1528
1751
 
1529
1752
  // src/core/validators/traceability.ts
1530
1753
  import { readFile as readFile9 } from "fs/promises";
1531
- var SC_TAG_RE3 = /^SC-\d{4}$/;
1532
- var SPEC_TAG_RE2 = /^SPEC-\d{4}$/;
1533
- var BR_TAG_RE2 = /^BR-\d{4}$/;
1534
- var UI_TAG_RE = /^UI-\d{4}$/;
1535
- var API_TAG_RE = /^API-\d{4}$/;
1536
- var DATA_TAG_RE = /^DATA-\d{4}$/;
1754
+ var SC_TAG_RE4 = /^SC-\d{4}$/;
1755
+ var SPEC_TAG_RE3 = /^SPEC-\d{4}$/;
1756
+ var BR_TAG_RE3 = /^BR-\d{4}$/;
1537
1757
  async function validateTraceability(root, config) {
1538
1758
  const issues = [];
1539
1759
  const specsRoot = resolvePath(root, config, "specsDir");
@@ -1591,104 +1811,97 @@ async function validateTraceability(root, config) {
1591
1811
  for (const file of scenarioFiles) {
1592
1812
  const text = await readFile9(file, "utf-8");
1593
1813
  extractAllIds(text).forEach((id) => upstreamIds.add(id));
1594
- const parsed = parseGherkinFeature(text, file);
1595
- const specIdsInScenario = /* @__PURE__ */ new Set();
1596
- const brIds = /* @__PURE__ */ new Set();
1597
- const scIds = /* @__PURE__ */ new Set();
1598
- const scenarioIds = /* @__PURE__ */ new Set();
1599
- for (const scenario of parsed.scenarios) {
1600
- for (const tag of scenario.tags) {
1601
- if (SPEC_TAG_RE2.test(tag)) {
1602
- specIdsInScenario.add(tag);
1603
- }
1604
- if (BR_TAG_RE2.test(tag)) {
1605
- brIds.add(tag);
1606
- }
1607
- if (SC_TAG_RE3.test(tag)) {
1608
- scIds.add(tag);
1609
- }
1610
- if (UI_TAG_RE.test(tag) || API_TAG_RE.test(tag) || DATA_TAG_RE.test(tag)) {
1611
- scenarioIds.add(tag);
1612
- }
1613
- }
1614
- }
1615
- const specIdsList = Array.from(specIdsInScenario);
1616
- const brIdsList = Array.from(brIds);
1617
- const scIdsList = Array.from(scIds);
1618
- const scenarioIdsList = Array.from(scenarioIds);
1619
- brIdsList.forEach((id) => brIdsInScenarios.add(id));
1620
- scIdsList.forEach((id) => scIdsInScenarios.add(id));
1621
- scenarioIdsList.forEach((id) => scenarioContractIds.add(id));
1622
- if (scenarioIdsList.length > 0) {
1623
- scIdsList.forEach((id) => scWithContracts.add(id));
1624
- }
1625
- const unknownSpecIds = specIdsList.filter((id) => !specIds.has(id));
1626
- if (unknownSpecIds.length > 0) {
1627
- issues.push(
1628
- issue6(
1629
- "QFAI-TRACE-005",
1630
- `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044 SPEC \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownSpecIds.join(", ")}`,
1631
- "error",
1632
- file,
1633
- "traceability.scenarioSpecExists",
1634
- unknownSpecIds
1635
- )
1636
- );
1637
- }
1638
- const unknownBrIds = brIdsList.filter((id) => !brIdsInSpecs.has(id));
1639
- if (unknownBrIds.length > 0) {
1640
- issues.push(
1641
- issue6(
1642
- "QFAI-TRACE-006",
1643
- `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044 BR \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownBrIds.join(", ")}`,
1644
- "error",
1645
- file,
1646
- "traceability.scenarioBrExists",
1647
- unknownBrIds
1648
- )
1649
- );
1650
- }
1651
- const unknownContractIds = scenarioIdsList.filter(
1652
- (id) => !contractIds.has(id)
1653
- );
1654
- if (unknownContractIds.length > 0) {
1655
- issues.push(
1656
- issue6(
1657
- "QFAI-TRACE-008",
1658
- `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044\u5951\u7D04 ID \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownContractIds.join(
1659
- ", "
1660
- )}`,
1661
- config.validation.traceability.unknownContractIdSeverity,
1662
- file,
1663
- "traceability.scenarioContractExists",
1664
- unknownContractIds
1665
- )
1666
- );
1814
+ const { document, errors } = parseScenarioDocument(text, file);
1815
+ if (!document || errors.length > 0) {
1816
+ continue;
1667
1817
  }
1668
- if (specIdsList.length > 0) {
1669
- const allowedBrIds = /* @__PURE__ */ new Set();
1670
- for (const specId of specIdsList) {
1671
- const brIdsForSpec = specToBrIds.get(specId);
1672
- if (!brIdsForSpec) {
1673
- continue;
1674
- }
1675
- brIdsForSpec.forEach((id) => allowedBrIds.add(id));
1818
+ const atoms = buildScenarioAtoms(document);
1819
+ for (const [index, scenario] of document.scenarios.entries()) {
1820
+ const atom = atoms[index];
1821
+ if (!atom) {
1822
+ continue;
1823
+ }
1824
+ const specTags = scenario.tags.filter((tag) => SPEC_TAG_RE3.test(tag));
1825
+ const brTags = scenario.tags.filter((tag) => BR_TAG_RE3.test(tag));
1826
+ const scTags = scenario.tags.filter((tag) => SC_TAG_RE4.test(tag));
1827
+ brTags.forEach((id) => brIdsInScenarios.add(id));
1828
+ scTags.forEach((id) => scIdsInScenarios.add(id));
1829
+ atom.contractIds.forEach((id) => scenarioContractIds.add(id));
1830
+ if (atom.contractIds.length > 0) {
1831
+ scTags.forEach((id) => scWithContracts.add(id));
1832
+ }
1833
+ const unknownSpecIds = specTags.filter((id) => !specIds.has(id));
1834
+ if (unknownSpecIds.length > 0) {
1835
+ issues.push(
1836
+ issue6(
1837
+ "QFAI-TRACE-005",
1838
+ `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044 SPEC \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownSpecIds.join(
1839
+ ", "
1840
+ )} (${scenario.name})`,
1841
+ "error",
1842
+ file,
1843
+ "traceability.scenarioSpecExists",
1844
+ unknownSpecIds
1845
+ )
1846
+ );
1676
1847
  }
1677
- const invalidBrIds = brIdsList.filter((id) => !allowedBrIds.has(id));
1678
- if (invalidBrIds.length > 0) {
1848
+ const unknownBrIds = brTags.filter((id) => !brIdsInSpecs.has(id));
1849
+ if (unknownBrIds.length > 0) {
1679
1850
  issues.push(
1680
1851
  issue6(
1681
- "QFAI-TRACE-007",
1682
- `Scenario \u306E BR \u304C\u53C2\u7167 SPEC \u306B\u5C5E\u3057\u3066\u3044\u307E\u305B\u3093: ${invalidBrIds.join(
1852
+ "QFAI-TRACE-006",
1853
+ `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044 BR \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownBrIds.join(
1683
1854
  ", "
1684
- )} (SPEC: ${specIdsList.join(", ")})`,
1855
+ )} (${scenario.name})`,
1685
1856
  "error",
1686
1857
  file,
1687
- "traceability.scenarioBrUnderSpec",
1688
- invalidBrIds
1858
+ "traceability.scenarioBrExists",
1859
+ unknownBrIds
1860
+ )
1861
+ );
1862
+ }
1863
+ const unknownContractIds = atom.contractIds.filter(
1864
+ (id) => !contractIds.has(id)
1865
+ );
1866
+ if (unknownContractIds.length > 0) {
1867
+ issues.push(
1868
+ issue6(
1869
+ "QFAI-TRACE-008",
1870
+ `Scenario \u304C\u5B58\u5728\u3057\u306A\u3044\u5951\u7D04 ID \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownContractIds.join(
1871
+ ", "
1872
+ )} (${scenario.name})`,
1873
+ config.validation.traceability.unknownContractIdSeverity,
1874
+ file,
1875
+ "traceability.scenarioContractExists",
1876
+ unknownContractIds
1689
1877
  )
1690
1878
  );
1691
1879
  }
1880
+ if (specTags.length > 0 && brTags.length > 0) {
1881
+ const allowedBrIds = /* @__PURE__ */ new Set();
1882
+ for (const specId of specTags) {
1883
+ const brIdsForSpec = specToBrIds.get(specId);
1884
+ if (!brIdsForSpec) {
1885
+ continue;
1886
+ }
1887
+ brIdsForSpec.forEach((id) => allowedBrIds.add(id));
1888
+ }
1889
+ const invalidBrIds = brTags.filter((id) => !allowedBrIds.has(id));
1890
+ if (invalidBrIds.length > 0) {
1891
+ issues.push(
1892
+ issue6(
1893
+ "QFAI-TRACE-007",
1894
+ `Scenario \u306E BR \u304C\u53C2\u7167 SPEC \u306B\u5C5E\u3057\u3066\u3044\u307E\u305B\u3093: ${invalidBrIds.join(
1895
+ ", "
1896
+ )} (SPEC: ${specTags.join(", ")}) (${scenario.name})`,
1897
+ "error",
1898
+ file,
1899
+ "traceability.scenarioBrUnderSpec",
1900
+ invalidBrIds
1901
+ )
1902
+ );
1903
+ }
1904
+ }
1692
1905
  }
1693
1906
  }
1694
1907
  if (upstreamIds.size === 0) {