qfai 1.2.2 → 1.2.4

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.
@@ -26,6 +26,12 @@ var defaultConfig = {
26
26
  require: {
27
27
  specSections: []
28
28
  },
29
+ testStrategy: {
30
+ requireLayerTags: false,
31
+ requireSizeTags: false,
32
+ maxE2eScenarioRatio: null,
33
+ maxE2eScenarioCount: null
34
+ },
29
35
  traceability: {
30
36
  brMustHaveSc: true,
31
37
  scMustHaveTest: true,
@@ -192,6 +198,20 @@ function normalizeValidation(raw, configPath, issues) {
192
198
  );
193
199
  traceabilityRaw = void 0;
194
200
  }
201
+ let testStrategyRaw;
202
+ if (raw.testStrategy === void 0) {
203
+ testStrategyRaw = void 0;
204
+ } else if (isRecord(raw.testStrategy)) {
205
+ testStrategyRaw = raw.testStrategy;
206
+ } else {
207
+ issues.push(
208
+ configIssue(
209
+ configPath,
210
+ "validation.testStrategy \u306F\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002"
211
+ )
212
+ );
213
+ testStrategyRaw = void 0;
214
+ }
195
215
  return {
196
216
  failOn: readFailOn(
197
217
  raw.failOn,
@@ -209,6 +229,36 @@ function normalizeValidation(raw, configPath, issues) {
209
229
  issues
210
230
  )
211
231
  },
232
+ testStrategy: {
233
+ requireLayerTags: readBoolean(
234
+ testStrategyRaw?.requireLayerTags,
235
+ base.testStrategy.requireLayerTags,
236
+ "validation.testStrategy.requireLayerTags",
237
+ configPath,
238
+ issues
239
+ ),
240
+ requireSizeTags: readBoolean(
241
+ testStrategyRaw?.requireSizeTags,
242
+ base.testStrategy.requireSizeTags,
243
+ "validation.testStrategy.requireSizeTags",
244
+ configPath,
245
+ issues
246
+ ),
247
+ maxE2eScenarioRatio: readOptionalRatio(
248
+ testStrategyRaw?.maxE2eScenarioRatio,
249
+ base.testStrategy.maxE2eScenarioRatio,
250
+ "validation.testStrategy.maxE2eScenarioRatio",
251
+ configPath,
252
+ issues
253
+ ),
254
+ maxE2eScenarioCount: readOptionalNonNegativeInt(
255
+ testStrategyRaw?.maxE2eScenarioCount,
256
+ base.testStrategy.maxE2eScenarioCount,
257
+ "validation.testStrategy.maxE2eScenarioCount",
258
+ configPath,
259
+ issues
260
+ )
261
+ },
212
262
  traceability: {
213
263
  brMustHaveSc: readBoolean(
214
264
  traceabilityRaw?.brMustHaveSc,
@@ -294,6 +344,36 @@ function readString(value, fallback, label, configPath, issues) {
294
344
  }
295
345
  return fallback;
296
346
  }
347
+ function readOptionalRatio(value, fallback, label, configPath, issues) {
348
+ if (value === void 0) {
349
+ return fallback;
350
+ }
351
+ if (value === null) {
352
+ return null;
353
+ }
354
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1) {
355
+ return value;
356
+ }
357
+ issues.push(
358
+ configIssue(configPath, `${label} \u306F 0\u301C1 \u306E\u6570\u5024\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002`)
359
+ );
360
+ return fallback;
361
+ }
362
+ function readOptionalNonNegativeInt(value, fallback, label, configPath, issues) {
363
+ if (value === void 0) {
364
+ return fallback;
365
+ }
366
+ if (value === null) {
367
+ return null;
368
+ }
369
+ if (typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0) {
370
+ return value;
371
+ }
372
+ issues.push(
373
+ configIssue(configPath, `${label} \u306F 0 \u4EE5\u4E0A\u306E\u6574\u6570\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002`)
374
+ );
375
+ return fallback;
376
+ }
297
377
  function readStringArray(value, fallback, label, configPath, issues) {
298
378
  if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
299
379
  return value;
@@ -810,6 +890,7 @@ async function collectScIdSourcesFromScenarioFiles(scenarioFiles) {
810
890
  }
811
891
  async function collectScTestReferences(root, globs, excludeGlobs) {
812
892
  const refs = /* @__PURE__ */ new Map();
893
+ const parseErrors = [];
813
894
  const normalizedGlobs = normalizeGlobs(globs);
814
895
  const normalizedExcludeGlobs = normalizeGlobs(excludeGlobs);
815
896
  const mergedExcludeGlobs = Array.from(
@@ -824,7 +905,8 @@ async function collectScTestReferences(root, globs, excludeGlobs) {
824
905
  matchedFileCount: 0,
825
906
  truncated: false,
826
907
  limit: DEFAULT_GLOB_FILE_LIMIT
827
- }
908
+ },
909
+ parseErrors
828
910
  };
829
911
  }
830
912
  let scanResult;
@@ -844,6 +926,7 @@ async function collectScTestReferences(root, globs, excludeGlobs) {
844
926
  truncated: false,
845
927
  limit: DEFAULT_GLOB_FILE_LIMIT
846
928
  },
929
+ parseErrors,
847
930
  error: formatError3(error2)
848
931
  };
849
932
  }
@@ -852,6 +935,33 @@ async function collectScTestReferences(root, globs, excludeGlobs) {
852
935
  );
853
936
  for (const file of normalizedFiles) {
854
937
  const text = await readFile2(file, "utf-8");
938
+ if (file.toLowerCase().endsWith(".feature")) {
939
+ const { document, errors } = parseScenarioDocument(text, file);
940
+ if (!document || errors.length > 0) {
941
+ parseErrors.push({
942
+ file,
943
+ errors: errors.length > 0 ? errors : ["Invalid Gherkin document."]
944
+ });
945
+ continue;
946
+ }
947
+ const scIds2 = /* @__PURE__ */ new Set();
948
+ for (const scenario of document.scenarios) {
949
+ for (const tag of scenario.tags) {
950
+ if (SC_TAG_RE2.test(tag)) {
951
+ scIds2.add(tag);
952
+ }
953
+ }
954
+ }
955
+ if (scIds2.size === 0) {
956
+ continue;
957
+ }
958
+ for (const scId of scIds2) {
959
+ const current = refs.get(scId) ?? /* @__PURE__ */ new Set();
960
+ current.add(file);
961
+ refs.set(scId, current);
962
+ }
963
+ continue;
964
+ }
855
965
  const scIds = extractAnnotatedScIds(text);
856
966
  if (scIds.length === 0) {
857
967
  continue;
@@ -870,7 +980,8 @@ async function collectScTestReferences(root, globs, excludeGlobs) {
870
980
  matchedFileCount: scanResult.matchedFileCount,
871
981
  truncated: scanResult.truncated,
872
982
  limit: scanResult.limit
873
- }
983
+ },
984
+ parseErrors
874
985
  };
875
986
  }
876
987
  function buildScCoverage(scIds, refs) {
@@ -1054,8 +1165,8 @@ import { readFile as readFile4 } from "fs/promises";
1054
1165
  import path9 from "path";
1055
1166
  import { fileURLToPath as fileURLToPath2 } from "url";
1056
1167
  async function resolveToolVersion() {
1057
- if ("1.2.2".length > 0) {
1058
- return "1.2.2";
1168
+ if ("1.2.4".length > 0) {
1169
+ return "1.2.4";
1059
1170
  }
1060
1171
  try {
1061
1172
  const packagePath = resolvePackageJsonPath();
@@ -3658,7 +3769,7 @@ async function validateScenarios(root, config) {
3658
3769
  ...collectStrategyCandidates(text, entry.scenarioPath)
3659
3770
  );
3660
3771
  }
3661
- issues.push(...buildStrategyIssues(strategyCandidates));
3772
+ issues.push(...buildStrategyIssues(strategyCandidates, config));
3662
3773
  return issues;
3663
3774
  }
3664
3775
  function validateScenarioContent(text, file) {
@@ -3861,22 +3972,39 @@ function collectStrategyCandidates(text, file) {
3861
3972
  label: buildScenarioLabel(file, scenario.tags, scenario.name),
3862
3973
  hasAnyTag: strategy.hasAnyTag,
3863
3974
  adopted: strategy.isAdopted,
3864
- missing: !strategy.isAdopted
3975
+ missing: !strategy.isAdopted,
3976
+ layerBucket: classifyLayer(scenario.tags),
3977
+ sizeBucket: classifySize(scenario.tags)
3865
3978
  };
3866
3979
  });
3867
3980
  }
3868
- function buildStrategyIssues(candidates) {
3981
+ function buildStrategyIssues(candidates, config) {
3869
3982
  const totalScenarios = candidates.length;
3870
3983
  if (totalScenarios === 0) {
3871
3984
  return [];
3872
3985
  }
3873
3986
  const adoptedCount = candidates.filter((item) => item.adopted).length;
3874
3987
  const missingItems = candidates.filter((item) => item.missing);
3988
+ const missingLayerItems = candidates.filter(
3989
+ (item) => item.layerBucket === "none"
3990
+ );
3991
+ const missingSizeItems = candidates.filter(
3992
+ (item) => item.sizeBucket === "none"
3993
+ );
3875
3994
  const hasAnyStrategyTag = candidates.some((item) => item.hasAnyTag);
3876
3995
  const missingSamples = missingItems.map((item) => item.label).slice(0, STRATEGY_SAMPLE_LIMIT);
3996
+ const missingLayerSamples = missingLayerItems.map((item) => item.label).slice(0, STRATEGY_SAMPLE_LIMIT);
3997
+ const missingSizeSamples = missingSizeItems.map((item) => item.label).slice(0, STRATEGY_SAMPLE_LIMIT);
3877
3998
  const adoptionRate = totalScenarios === 0 ? "0%" : `${Math.round(adoptedCount / totalScenarios * 100)}%`;
3999
+ const e2eCount = candidates.filter(
4000
+ (item) => item.layerBucket === "e2e"
4001
+ ).length;
4002
+ const e2eRatio = totalScenarios === 0 ? 0 : e2eCount / totalScenarios;
4003
+ const maxE2eRatio = config.validation.testStrategy.maxE2eScenarioRatio;
4004
+ const maxE2eCount = config.validation.testStrategy.maxE2eScenarioCount;
4005
+ const issues = [];
3878
4006
  if (!hasAnyStrategyTag) {
3879
- return [
4007
+ issues.push(
3880
4008
  issue(
3881
4009
  "QFAI-TS-100",
3882
4010
  "\u5168\u3066\u306E Scenario \u306B layer/size \u30BF\u30B0\u304C\u3042\u308A\u307E\u305B\u3093\u3002\u5C0E\u5165\u306F\u4EFB\u610F\u3067\u3059\uFF08opt-in\uFF09\u3002",
@@ -3884,10 +4012,10 @@ function buildStrategyIssues(candidates) {
3884
4012
  void 0,
3885
4013
  "scenario.testStrategyAllMissing"
3886
4014
  )
3887
- ];
4015
+ );
3888
4016
  }
3889
- if (adoptedCount < totalScenarios) {
3890
- return [
4017
+ if (hasAnyStrategyTag && adoptedCount < totalScenarios) {
4018
+ issues.push(
3891
4019
  issue(
3892
4020
  "QFAI-TS-101",
3893
4021
  `layer/size \u30BF\u30B0\u306E\u90E8\u5206\u5C0E\u5165\u3067\u3059: missing=${missingItems.length}, total=${totalScenarios}, adopted=${adoptedCount}, adoptionRate=${adoptionRate}, samples=${missingSamples.join(
@@ -3898,9 +4026,59 @@ function buildStrategyIssues(candidates) {
3898
4026
  "scenario.testStrategyPartialAdoption",
3899
4027
  missingSamples
3900
4028
  )
3901
- ];
4029
+ );
4030
+ }
4031
+ if (config.validation.testStrategy.requireLayerTags && missingLayerItems.length > 0) {
4032
+ issues.push(
4033
+ issue(
4034
+ "QFAI-TS-102",
4035
+ `layer \u30BF\u30B0\u304C\u672A\u8A2D\u5B9A\u3067\u3059: missing=${missingLayerItems.length}, total=${totalScenarios}, samples=${missingLayerSamples.join(
4036
+ ", "
4037
+ )}`,
4038
+ "warning",
4039
+ void 0,
4040
+ "scenario.testStrategyRequireLayerTags",
4041
+ missingLayerSamples
4042
+ )
4043
+ );
4044
+ }
4045
+ if (config.validation.testStrategy.requireSizeTags && missingSizeItems.length > 0) {
4046
+ issues.push(
4047
+ issue(
4048
+ "QFAI-TS-103",
4049
+ `size \u30BF\u30B0\u304C\u672A\u8A2D\u5B9A\u3067\u3059: missing=${missingSizeItems.length}, total=${totalScenarios}, samples=${missingSizeSamples.join(
4050
+ ", "
4051
+ )}`,
4052
+ "warning",
4053
+ void 0,
4054
+ "scenario.testStrategyRequireSizeTags",
4055
+ missingSizeSamples
4056
+ )
4057
+ );
4058
+ }
4059
+ if (maxE2eRatio !== null && e2eRatio > maxE2eRatio) {
4060
+ issues.push(
4061
+ issue(
4062
+ "QFAI-TS-110",
4063
+ `layer-e2e \u306E\u6BD4\u7387\u304C\u4E0A\u9650\u3092\u8D85\u904E\u3057\u3066\u3044\u307E\u3059: e2e=${e2eCount}, total=${totalScenarios}, ratio=${Math.round(e2eRatio * 1e3) / 10}% (max=${Math.round(maxE2eRatio * 1e3) / 10}%)`,
4064
+ "warning",
4065
+ void 0,
4066
+ "scenario.testStrategyMaxE2eRatio"
4067
+ )
4068
+ );
3902
4069
  }
3903
- return [];
4070
+ if (maxE2eCount !== null && e2eCount > maxE2eCount) {
4071
+ issues.push(
4072
+ issue(
4073
+ "QFAI-TS-111",
4074
+ `layer-e2e \u306E\u4EF6\u6570\u304C\u4E0A\u9650\u3092\u8D85\u904E\u3057\u3066\u3044\u307E\u3059: e2e=${e2eCount} (max=${maxE2eCount})`,
4075
+ "warning",
4076
+ void 0,
4077
+ "scenario.testStrategyMaxE2eCount"
4078
+ )
4079
+ );
4080
+ }
4081
+ return issues;
3904
4082
  }
3905
4083
  async function fileExists(target) {
3906
4084
  try {
@@ -4088,6 +4266,7 @@ function validateSpecContent(text, file, requiredSections) {
4088
4266
  import { readFile as readFile13 } from "fs/promises";
4089
4267
  var SPEC_TAG_RE3 = /^SPEC-\d{4}$/;
4090
4268
  var BR_TAG_RE2 = /^BR-\d{4}-\d{4}$/;
4269
+ var SAMPLE_LIMIT = 20;
4091
4270
  async function validateTraceability(root, config) {
4092
4271
  const issues = [];
4093
4272
  const specsRoot = resolvePath(root, config, "specsDir");
@@ -4104,6 +4283,8 @@ async function validateTraceability(root, config) {
4104
4283
  const scIdsInScenarios = /* @__PURE__ */ new Set();
4105
4284
  const specContractIds = /* @__PURE__ */ new Set();
4106
4285
  const specToBrIds = /* @__PURE__ */ new Map();
4286
+ const scIdToLayer = /* @__PURE__ */ new Map();
4287
+ const layerToScIds = /* @__PURE__ */ new Map();
4107
4288
  const contractIndex = await buildContractIndex(root, config);
4108
4289
  const contractIds = contractIndex.ids;
4109
4290
  for (const file of specFiles) {
@@ -4236,6 +4417,7 @@ async function validateTraceability(root, config) {
4236
4417
  const specTags = scenario.tags.filter((tag) => SPEC_TAG_RE3.test(tag));
4237
4418
  const brTags = scenario.tags.filter((tag) => BR_TAG_RE2.test(tag));
4238
4419
  const scTags = scenario.tags.filter((tag) => SC_TAG_RE2.test(tag));
4420
+ const layerBucket = classifyLayer(scenario.tags);
4239
4421
  if (specTags.length > 1) {
4240
4422
  issues.push(
4241
4423
  issue(
@@ -4273,6 +4455,10 @@ async function validateTraceability(root, config) {
4273
4455
  brTags.forEach((id) => brIdsInScenarios.add(id));
4274
4456
  scTags.forEach((id) => {
4275
4457
  scIdsInScenarios.add(id);
4458
+ scIdToLayer.set(id, layerBucket);
4459
+ const layerIds = layerToScIds.get(layerBucket) ?? /* @__PURE__ */ new Set();
4460
+ layerIds.add(id);
4461
+ layerToScIds.set(layerBucket, layerIds);
4276
4462
  const current = scIdToScenarioInfo.get(id) ?? {
4277
4463
  count: 0,
4278
4464
  names: /* @__PURE__ */ new Set()
@@ -4381,6 +4567,17 @@ async function validateTraceability(root, config) {
4381
4567
  }
4382
4568
  const specInfo = scenarioToSpec.get(file);
4383
4569
  if (specInfo && specInfo.contractRefs.lines.length > 0) {
4570
+ if (!specInfo.contractRefs.hasNone && specInfo.contractRefs.ids.length > 0 && scenarioContractRefs.hasNone) {
4571
+ issues.push(
4572
+ issue(
4573
+ "QFAI-TRACE-036",
4574
+ `Spec \u304C\u5951\u7D04 ID \u3092\u5217\u6319\u3057\u3066\u3044\u307E\u3059\u304C Scenario \u304C none \u3092\u6307\u5B9A\u3057\u3066\u3044\u307E\u3059 (SPEC: ${specInfo.specId ?? "unknown"})`,
4575
+ "warning",
4576
+ file,
4577
+ "traceability.scenarioContractRefNone"
4578
+ )
4579
+ );
4580
+ }
4384
4581
  if (specInfo.contractRefs.hasNone && scenarioContractRefs.ids.length > 0) {
4385
4582
  issues.push(
4386
4583
  issue(
@@ -4469,9 +4666,24 @@ async function validateTraceability(root, config) {
4469
4666
  );
4470
4667
  const scTestRefs = scRefsResult.refs;
4471
4668
  const testFileScan = scRefsResult.scan;
4669
+ const parseErrors = scRefsResult.parseErrors;
4472
4670
  const hasScenarios = scIdsInScenarios.size > 0;
4473
4671
  const hasGlobConfig = testFileScan.globs.length > 0;
4474
4672
  const hasMatchedTests = testFileScan.matchedFileCount > 0;
4673
+ if (parseErrors.length > 0) {
4674
+ for (const entry of parseErrors) {
4675
+ const detail = entry.errors.join(" / ");
4676
+ issues.push(
4677
+ issue(
4678
+ "QFAI-TRACE-040",
4679
+ `\u30C6\u30B9\u30C8\u8A3C\u8DE1\u306E .feature \u89E3\u6790\u306B\u5931\u6557\u3057\u307E\u3057\u305F: ${detail}`,
4680
+ "error",
4681
+ entry.file,
4682
+ "traceability.testFileParse"
4683
+ )
4684
+ );
4685
+ }
4686
+ }
4475
4687
  if (hasScenarios && (!hasGlobConfig || !hasMatchedTests || scRefsResult.error)) {
4476
4688
  const detail = scRefsResult.error ? `\uFF08\u8A73\u7D30: ${scRefsResult.error}\uFF09` : "";
4477
4689
  issues.push(
@@ -4485,21 +4697,55 @@ async function validateTraceability(root, config) {
4485
4697
  );
4486
4698
  } else {
4487
4699
  if (config.validation.traceability.scMustHaveTest && scIdsInScenarios.size) {
4488
- const scWithoutTests = Array.from(scIdsInScenarios).filter((id) => {
4489
- const refs = scTestRefs.get(id);
4490
- return !refs || refs.size === 0;
4491
- });
4492
- if (scWithoutTests.length > 0) {
4700
+ const enforcedLayers = /* @__PURE__ */ new Set();
4701
+ for (const [scId, refs] of scTestRefs.entries()) {
4702
+ if (!refs || refs.size === 0) {
4703
+ continue;
4704
+ }
4705
+ const layer = scIdToLayer.get(scId);
4706
+ if (layer) {
4707
+ enforcedLayers.add(layer);
4708
+ }
4709
+ }
4710
+ const deferredLayers = [];
4711
+ for (const [layer, scIds] of layerToScIds.entries()) {
4712
+ const missing = Array.from(scIds).filter((id) => {
4713
+ const refs = scTestRefs.get(id);
4714
+ return !refs || refs.size === 0;
4715
+ });
4716
+ if (missing.length === 0) {
4717
+ continue;
4718
+ }
4719
+ if (enforcedLayers.has(layer)) {
4720
+ const samples = missing.slice(0, SAMPLE_LIMIT);
4721
+ const truncated = samples.length < missing.length;
4722
+ const sampleText = samples.join(", ");
4723
+ const suffix = truncated ? " ..." : "";
4724
+ issues.push(
4725
+ issue(
4726
+ "QFAI-TRACE-010",
4727
+ `SC \u304C\u30C6\u30B9\u30C8\u3067\u53C2\u7167\u3055\u308C\u3066\u3044\u307E\u305B\u3093 (layer=${layer}, missing=${missing.length}, samples=${sampleText}${suffix})\u3002testFileGlobs \u306B\u4E00\u81F4\u3059\u308B\u30C6\u30B9\u30C8\u30D5\u30A1\u30A4\u30EB\u3078 QFAI:SC-XXXX-XXXX \u307E\u305F\u306F .feature \u306E @SC-XXXX-XXXX\uFF08\u5BFE\u8C61\u306E SC ID\uFF09\u3092\u8A18\u8F09\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
4728
+ config.validation.traceability.scNoTestSeverity,
4729
+ testsRoot,
4730
+ "traceability.scMustHaveTest",
4731
+ samples
4732
+ )
4733
+ );
4734
+ } else {
4735
+ deferredLayers.push({ layer, missing });
4736
+ }
4737
+ }
4738
+ if (deferredLayers.length > 0) {
4739
+ const summary = deferredLayers.map(
4740
+ (entry) => `layer=${entry.layer} missing=${entry.missing.length}`
4741
+ ).join(", ");
4493
4742
  issues.push(
4494
4743
  issue(
4495
- "QFAI-TRACE-010",
4496
- `SC \u304C\u30C6\u30B9\u30C8\u3067\u53C2\u7167\u3055\u308C\u3066\u3044\u307E\u305B\u3093: ${scWithoutTests.join(
4497
- ", "
4498
- )}\u3002testFileGlobs \u306B\u4E00\u81F4\u3059\u308B\u30C6\u30B9\u30C8\u30D5\u30A1\u30A4\u30EB\u3078 QFAI:SC-XXXX-XXXX\uFF08\u5BFE\u8C61\u306E SC ID\uFF09\u3092\u8A18\u8F09\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
4499
- config.validation.traceability.scNoTestSeverity,
4744
+ "QFAI-TRACE-041",
4745
+ `\u30C6\u30B9\u30C8\u8A3C\u8DE1\u304C\u672A\u691C\u51FA\u306E\u30EC\u30A4\u30E4\u30FC\u306F SC\u2192Test \u5224\u5B9A\u3092\u4FDD\u7559\u3057\u307E\u3057\u305F: ${summary}\u3002\u8A72\u5F53\u30EC\u30A4\u30E4\u30FC\u306B\u30C6\u30B9\u30C8\u8A3C\u8DE1\u304C1\u4EF6\u3067\u3082\u8FFD\u52A0\u3055\u308C\u305F\u6642\u70B9\u3067 100% \u3092\u5F37\u5236\u3057\u307E\u3059\u3002`,
4746
+ "info",
4500
4747
  testsRoot,
4501
- "traceability.scMustHaveTest",
4502
- scWithoutTests
4748
+ "traceability.scMustHaveTest"
4503
4749
  )
4504
4750
  );
4505
4751
  }
@@ -4511,9 +4757,7 @@ async function validateTraceability(root, config) {
4511
4757
  issues.push(
4512
4758
  issue(
4513
4759
  "QFAI-TRACE-011",
4514
- `\u30C6\u30B9\u30C8\u304C\u672A\u77E5\u306E SC \u3092\u30A2\u30CE\u30C6\u30FC\u30B7\u30E7\u30F3\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownScIds.join(
4515
- ", "
4516
- )}`,
4760
+ `\u30C6\u30B9\u30C8\u304C\u672A\u77E5\u306E SC \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownScIds.join(", ")}`,
4517
4761
  "error",
4518
4762
  testsRoot,
4519
4763
  "traceability.scUnknownInTests",
@@ -4838,6 +5082,7 @@ async function createReportData(root, validation, configResult) {
4838
5082
  const testStrategy = await collectTestStrategy(
4839
5083
  scenarioFiles,
4840
5084
  resolvedRoot,
5085
+ config,
4841
5086
  REPORT_TEST_STRATEGY_SAMPLE_LIMIT
4842
5087
  );
4843
5088
  const {
@@ -5256,6 +5501,27 @@ function formatReportMarkdown(data, options = {}) {
5256
5501
  `- s: ${data.testStrategy.size.s} / m: ${data.testStrategy.size.m} / l: ${data.testStrategy.size.l} / none: ${data.testStrategy.size.none} / unknown: ${data.testStrategy.size.unknown}`
5257
5502
  );
5258
5503
  lines.push("");
5504
+ const e2eHeading = data.testStrategy.e2e.ratioExceeded || data.testStrategy.e2e.countExceeded ? "### E2E guardrails (warning)" : "### E2E guardrails";
5505
+ lines.push(e2eHeading);
5506
+ lines.push("");
5507
+ lines.push(
5508
+ `- e2e: ${data.testStrategy.e2e.count} / ${data.testStrategy.totalScenarios} (ratio=${formatPercent(data.testStrategy.e2e.ratio)})`
5509
+ );
5510
+ lines.push(
5511
+ `- maxRatio: ${formatOptionalPercent(data.testStrategy.e2e.maxRatio)}`
5512
+ );
5513
+ lines.push(
5514
+ `- maxCount: ${formatOptionalNumber(data.testStrategy.e2e.maxCount)}`
5515
+ );
5516
+ if (data.testStrategy.e2e.ratioExceeded || data.testStrategy.e2e.countExceeded) {
5517
+ if (data.testStrategy.e2e.ratioExceeded) {
5518
+ lines.push("- warning: layer-e2e \u306E\u6BD4\u7387\u304C\u4E0A\u9650\u3092\u8D85\u904E\u3057\u3066\u3044\u307E\u3059\u3002");
5519
+ }
5520
+ if (data.testStrategy.e2e.countExceeded) {
5521
+ lines.push("- warning: layer-e2e \u306E\u4EF6\u6570\u304C\u4E0A\u9650\u3092\u8D85\u904E\u3057\u3066\u3044\u307E\u3059\u3002");
5522
+ }
5523
+ }
5524
+ lines.push("");
5259
5525
  lines.push("### Missing layer tags");
5260
5526
  lines.push("");
5261
5527
  lines.push(
@@ -5570,6 +5836,21 @@ function formatList(values) {
5570
5836
  }
5571
5837
  return values.join(", ");
5572
5838
  }
5839
+ function formatOptionalPercent(value) {
5840
+ if (value === null) {
5841
+ return "(unset)";
5842
+ }
5843
+ return formatPercent(value);
5844
+ }
5845
+ function formatOptionalNumber(value) {
5846
+ if (value === null) {
5847
+ return "(unset)";
5848
+ }
5849
+ return String(value);
5850
+ }
5851
+ function formatPercent(value) {
5852
+ return `${Math.round(value * 1e3) / 10}%`;
5853
+ }
5573
5854
  function formatMarkdownTable(headers, rows) {
5574
5855
  const widths = headers.map((header, index) => {
5575
5856
  const candidates = rows.map((row) => row[index] ?? "");
@@ -5662,7 +5943,7 @@ async function countScenarios(scenarioFiles) {
5662
5943
  }
5663
5944
  return total;
5664
5945
  }
5665
- async function collectTestStrategy(scenarioFiles, root, limit) {
5946
+ async function collectTestStrategy(scenarioFiles, root, config, limit) {
5666
5947
  const layerCounts = {
5667
5948
  unit: 0,
5668
5949
  component: 0,
@@ -5682,6 +5963,7 @@ async function collectTestStrategy(scenarioFiles, root, limit) {
5682
5963
  const missingLayer = [];
5683
5964
  const missingSize = [];
5684
5965
  let totalScenarios = 0;
5966
+ let e2eCount = 0;
5685
5967
  for (const file of scenarioFiles) {
5686
5968
  const text = await readFile15(file, "utf-8");
5687
5969
  const { document, errors } = parseScenarioDocument(text, file);
@@ -5701,6 +5983,9 @@ async function collectTestStrategy(scenarioFiles, root, limit) {
5701
5983
  if (layerBucket === "none") {
5702
5984
  missingLayer.push(label);
5703
5985
  }
5986
+ if (layerBucket === "e2e") {
5987
+ e2eCount += 1;
5988
+ }
5704
5989
  const sizeBucket = classifySize(scenario.tags);
5705
5990
  sizeCounts[sizeBucket] += 1;
5706
5991
  if (sizeBucket === "none") {
@@ -5710,6 +5995,9 @@ async function collectTestStrategy(scenarioFiles, root, limit) {
5710
5995
  }
5711
5996
  const layerSamples = missingLayer.slice(0, limit);
5712
5997
  const sizeSamples = missingSize.slice(0, limit);
5998
+ const ratio = totalScenarios === 0 ? 0 : e2eCount / totalScenarios;
5999
+ const maxRatio = config.validation.testStrategy.maxE2eScenarioRatio;
6000
+ const maxCount = config.validation.testStrategy.maxE2eScenarioCount;
5713
6001
  return {
5714
6002
  totalScenarios,
5715
6003
  limit,
@@ -5726,6 +6014,14 @@ async function collectTestStrategy(scenarioFiles, root, limit) {
5726
6014
  samples: sizeSamples,
5727
6015
  truncated: missingSize.length > sizeSamples.length
5728
6016
  }
6017
+ },
6018
+ e2e: {
6019
+ count: e2eCount,
6020
+ ratio,
6021
+ maxRatio,
6022
+ maxCount,
6023
+ ratioExceeded: maxRatio !== null && ratio > maxRatio,
6024
+ countExceeded: maxCount !== null && e2eCount > maxCount
5729
6025
  }
5730
6026
  };
5731
6027
  }