qfai 0.5.0 → 0.5.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.
@@ -139,6 +139,10 @@ function info(message) {
139
139
  process.stdout.write(`${message}
140
140
  `);
141
141
  }
142
+ function warn(message) {
143
+ process.stdout.write(`${message}
144
+ `);
145
+ }
142
146
  function error(message) {
143
147
  process.stderr.write(`${message}
144
148
  `);
@@ -178,7 +182,7 @@ function report(copied, skipped, dryRun, label) {
178
182
 
179
183
  // src/cli/commands/report.ts
180
184
  var import_promises16 = require("fs/promises");
181
- var import_node_path15 = __toESM(require("path"), 1);
185
+ var import_node_path16 = __toESM(require("path"), 1);
182
186
 
183
187
  // src/core/config.ts
184
188
  var import_promises2 = require("fs/promises");
@@ -213,7 +217,7 @@ var defaultConfig = {
213
217
  testFileGlobs: [],
214
218
  testFileExcludeGlobs: [],
215
219
  scNoTestSeverity: "error",
216
- allowOrphanContracts: false,
220
+ orphanContractsPolicy: "error",
217
221
  unknownContractIdSeverity: "error"
218
222
  }
219
223
  },
@@ -224,6 +228,26 @@ var defaultConfig = {
224
228
  function getConfigPath(root) {
225
229
  return import_node_path4.default.join(root, "qfai.config.yaml");
226
230
  }
231
+ async function findConfigRoot(startDir) {
232
+ const resolvedStart = import_node_path4.default.resolve(startDir);
233
+ let current = resolvedStart;
234
+ while (true) {
235
+ const configPath = getConfigPath(current);
236
+ if (await exists2(configPath)) {
237
+ return { root: current, configPath, found: true };
238
+ }
239
+ const parent = import_node_path4.default.dirname(current);
240
+ if (parent === current) {
241
+ break;
242
+ }
243
+ current = parent;
244
+ }
245
+ return {
246
+ root: resolvedStart,
247
+ configPath: getConfigPath(resolvedStart),
248
+ found: false
249
+ };
250
+ }
227
251
  async function loadConfig(root) {
228
252
  const configPath = getConfigPath(root);
229
253
  const issues = [];
@@ -413,10 +437,10 @@ function normalizeValidation(raw, configPath, issues) {
413
437
  configPath,
414
438
  issues
415
439
  ),
416
- allowOrphanContracts: readBoolean(
417
- traceabilityRaw?.allowOrphanContracts,
418
- base.traceability.allowOrphanContracts,
419
- "validation.traceability.allowOrphanContracts",
440
+ orphanContractsPolicy: readOrphanContractsPolicy(
441
+ traceabilityRaw?.orphanContractsPolicy,
442
+ base.traceability.orphanContractsPolicy,
443
+ "validation.traceability.orphanContractsPolicy",
420
444
  configPath,
421
445
  issues
422
446
  ),
@@ -512,6 +536,20 @@ function readTraceabilitySeverity(value, fallback, label, configPath, issues) {
512
536
  }
513
537
  return fallback;
514
538
  }
539
+ function readOrphanContractsPolicy(value, fallback, label, configPath, issues) {
540
+ if (value === "error" || value === "warning" || value === "allow") {
541
+ return value;
542
+ }
543
+ if (value !== void 0) {
544
+ issues.push(
545
+ configIssue(
546
+ configPath,
547
+ `${label} \u306F error|warning|allow \u306E\u3044\u305A\u308C\u304B\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002`
548
+ )
549
+ );
550
+ }
551
+ return fallback;
552
+ }
515
553
  function configIssue(file, message) {
516
554
  return {
517
555
  code: "QFAI_CONFIG_INVALID",
@@ -527,6 +565,14 @@ function isMissingFile(error2) {
527
565
  }
528
566
  return false;
529
567
  }
568
+ async function exists2(target) {
569
+ try {
570
+ await (0, import_promises2.access)(target);
571
+ return true;
572
+ } catch {
573
+ return false;
574
+ }
575
+ }
530
576
  function formatError(error2) {
531
577
  if (error2 instanceof Error) {
532
578
  return error2.message;
@@ -537,20 +583,76 @@ function isRecord(value) {
537
583
  return value !== null && typeof value === "object" && !Array.isArray(value);
538
584
  }
539
585
 
586
+ // src/core/paths.ts
587
+ var import_node_path5 = __toESM(require("path"), 1);
588
+ function toRelativePath(root, target) {
589
+ if (!target) {
590
+ return target;
591
+ }
592
+ if (!import_node_path5.default.isAbsolute(target)) {
593
+ return toPosixPath(target);
594
+ }
595
+ const relative = import_node_path5.default.relative(root, target);
596
+ if (!relative) {
597
+ return ".";
598
+ }
599
+ return toPosixPath(relative);
600
+ }
601
+ function toPosixPath(value) {
602
+ return value.replace(/\\/g, "/");
603
+ }
604
+
605
+ // src/core/normalize.ts
606
+ function normalizeIssuePaths(root, issues) {
607
+ return issues.map((issue7) => {
608
+ if (!issue7.file) {
609
+ return issue7;
610
+ }
611
+ const normalized = toRelativePath(root, issue7.file);
612
+ if (normalized === issue7.file) {
613
+ return issue7;
614
+ }
615
+ return {
616
+ ...issue7,
617
+ file: normalized
618
+ };
619
+ });
620
+ }
621
+ function normalizeScCoverage(root, sc) {
622
+ const refs = {};
623
+ for (const [scId, files] of Object.entries(sc.refs)) {
624
+ refs[scId] = files.map((file) => toRelativePath(root, file));
625
+ }
626
+ return {
627
+ ...sc,
628
+ refs
629
+ };
630
+ }
631
+ function normalizeValidationResult(root, result) {
632
+ return {
633
+ ...result,
634
+ issues: normalizeIssuePaths(root, result.issues),
635
+ traceability: {
636
+ ...result.traceability,
637
+ sc: normalizeScCoverage(root, result.traceability.sc)
638
+ }
639
+ };
640
+ }
641
+
540
642
  // src/core/report.ts
541
643
  var import_promises15 = require("fs/promises");
542
- var import_node_path14 = __toESM(require("path"), 1);
644
+ var import_node_path15 = __toESM(require("path"), 1);
543
645
 
544
646
  // src/core/contractIndex.ts
545
647
  var import_promises6 = require("fs/promises");
546
- var import_node_path7 = __toESM(require("path"), 1);
648
+ var import_node_path8 = __toESM(require("path"), 1);
547
649
 
548
650
  // src/core/discovery.ts
549
651
  var import_promises5 = require("fs/promises");
550
652
 
551
653
  // src/core/fs.ts
552
654
  var import_promises3 = require("fs/promises");
553
- var import_node_path5 = __toESM(require("path"), 1);
655
+ var import_node_path6 = __toESM(require("path"), 1);
554
656
  var import_fast_glob = __toESM(require("fast-glob"), 1);
555
657
  var DEFAULT_IGNORE_DIRS = /* @__PURE__ */ new Set([
556
658
  "node_modules",
@@ -562,7 +664,7 @@ var DEFAULT_IGNORE_DIRS = /* @__PURE__ */ new Set([
562
664
  ]);
563
665
  async function collectFiles(root, options = {}) {
564
666
  const entries = [];
565
- if (!await exists2(root)) {
667
+ if (!await exists3(root)) {
566
668
  return entries;
567
669
  }
568
670
  const ignoreDirs = /* @__PURE__ */ new Set([
@@ -588,7 +690,7 @@ async function collectFilesByGlobs(root, options) {
588
690
  async function walk(base, current, ignoreDirs, extensions, out) {
589
691
  const items = await (0, import_promises3.readdir)(current, { withFileTypes: true });
590
692
  for (const item of items) {
591
- const fullPath = import_node_path5.default.join(current, item.name);
693
+ const fullPath = import_node_path6.default.join(current, item.name);
592
694
  if (item.isDirectory()) {
593
695
  if (ignoreDirs.has(item.name)) {
594
696
  continue;
@@ -598,7 +700,7 @@ async function walk(base, current, ignoreDirs, extensions, out) {
598
700
  }
599
701
  if (item.isFile()) {
600
702
  if (extensions.length > 0) {
601
- const ext = import_node_path5.default.extname(item.name).toLowerCase();
703
+ const ext = import_node_path6.default.extname(item.name).toLowerCase();
602
704
  if (!extensions.includes(ext)) {
603
705
  continue;
604
706
  }
@@ -607,7 +709,7 @@ async function walk(base, current, ignoreDirs, extensions, out) {
607
709
  }
608
710
  }
609
711
  }
610
- async function exists2(target) {
712
+ async function exists3(target) {
611
713
  try {
612
714
  await (0, import_promises3.access)(target);
613
715
  return true;
@@ -618,22 +720,22 @@ async function exists2(target) {
618
720
 
619
721
  // src/core/specLayout.ts
620
722
  var import_promises4 = require("fs/promises");
621
- var import_node_path6 = __toESM(require("path"), 1);
723
+ var import_node_path7 = __toESM(require("path"), 1);
622
724
  var SPEC_DIR_RE = /^spec-\d{4}$/;
623
725
  async function collectSpecEntries(specsRoot) {
624
726
  const dirs = await listSpecDirs(specsRoot);
625
727
  const entries = dirs.map((dir) => ({
626
728
  dir,
627
- specPath: import_node_path6.default.join(dir, "spec.md"),
628
- deltaPath: import_node_path6.default.join(dir, "delta.md"),
629
- scenarioPath: import_node_path6.default.join(dir, "scenario.md")
729
+ specPath: import_node_path7.default.join(dir, "spec.md"),
730
+ deltaPath: import_node_path7.default.join(dir, "delta.md"),
731
+ scenarioPath: import_node_path7.default.join(dir, "scenario.md")
630
732
  }));
631
733
  return entries.sort((a, b) => a.dir.localeCompare(b.dir));
632
734
  }
633
735
  async function listSpecDirs(specsRoot) {
634
736
  try {
635
737
  const items = await (0, import_promises4.readdir)(specsRoot, { withFileTypes: true });
636
- return items.filter((item) => item.isDirectory()).map((item) => item.name).filter((name) => SPEC_DIR_RE.test(name.toLowerCase())).map((name) => import_node_path6.default.join(specsRoot, name));
738
+ return items.filter((item) => item.isDirectory()).map((item) => item.name).filter((name) => SPEC_DIR_RE.test(name.toLowerCase())).map((name) => import_node_path7.default.join(specsRoot, name));
637
739
  } catch (error2) {
638
740
  if (isMissingFileError(error2)) {
639
741
  return [];
@@ -681,13 +783,13 @@ async function collectContractFiles(uiRoot, apiRoot, dbRoot) {
681
783
  async function filterExisting(files) {
682
784
  const existing = [];
683
785
  for (const file of files) {
684
- if (await exists3(file)) {
786
+ if (await exists4(file)) {
685
787
  existing.push(file);
686
788
  }
687
789
  }
688
790
  return existing;
689
791
  }
690
- async function exists3(target) {
792
+ async function exists4(target) {
691
793
  try {
692
794
  await (0, import_promises5.access)(target);
693
795
  return true;
@@ -716,9 +818,9 @@ function stripContractDeclarationLines(text) {
716
818
  // src/core/contractIndex.ts
717
819
  async function buildContractIndex(root, config) {
718
820
  const contractsRoot = resolvePath(root, config, "contractsDir");
719
- const uiRoot = import_node_path7.default.join(contractsRoot, "ui");
720
- const apiRoot = import_node_path7.default.join(contractsRoot, "api");
721
- const dbRoot = import_node_path7.default.join(contractsRoot, "db");
821
+ const uiRoot = import_node_path8.default.join(contractsRoot, "ui");
822
+ const apiRoot = import_node_path8.default.join(contractsRoot, "api");
823
+ const dbRoot = import_node_path8.default.join(contractsRoot, "db");
722
824
  const [uiFiles, apiFiles, dbFiles] = await Promise.all([
723
825
  collectUiContractFiles(uiRoot),
724
826
  collectApiContractFiles(apiRoot),
@@ -800,6 +902,57 @@ function isValidId(value, prefix) {
800
902
  return strict.test(value);
801
903
  }
802
904
 
905
+ // src/core/parse/contractRefs.ts
906
+ var CONTRACT_REF_ID_RE = /^(?:API|UI|DB)-\d{4}$/;
907
+ function parseContractRefs(text, options = {}) {
908
+ const linePattern = buildLinePattern(options);
909
+ const lines = [];
910
+ for (const match of text.matchAll(linePattern)) {
911
+ lines.push((match[1] ?? "").trim());
912
+ }
913
+ const ids = [];
914
+ const invalidTokens = [];
915
+ let hasNone = false;
916
+ for (const line of lines) {
917
+ if (line.length === 0) {
918
+ invalidTokens.push("(empty)");
919
+ continue;
920
+ }
921
+ const tokens = line.split(",").map((token) => token.trim());
922
+ for (const token of tokens) {
923
+ if (token.length === 0) {
924
+ invalidTokens.push("(empty)");
925
+ continue;
926
+ }
927
+ if (token === "none") {
928
+ hasNone = true;
929
+ continue;
930
+ }
931
+ if (CONTRACT_REF_ID_RE.test(token)) {
932
+ ids.push(token);
933
+ continue;
934
+ }
935
+ invalidTokens.push(token);
936
+ }
937
+ }
938
+ return {
939
+ lines,
940
+ ids: unique2(ids),
941
+ invalidTokens: unique2(invalidTokens),
942
+ hasNone
943
+ };
944
+ }
945
+ function buildLinePattern(options) {
946
+ const prefix = options.allowCommentPrefix ? "#" : "";
947
+ return new RegExp(
948
+ `^[ \\t]*${prefix}[ \\t]*QFAI-CONTRACT-REF:[ \\t]*([^\\r\\n]*)[ \\t]*$`,
949
+ "gm"
950
+ );
951
+ }
952
+ function unique2(values) {
953
+ return Array.from(new Set(values));
954
+ }
955
+
803
956
  // src/core/parse/markdown.ts
804
957
  var HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
805
958
  function parseHeadings(md) {
@@ -846,8 +999,6 @@ var SPEC_ID_RE = /\bSPEC-\d{4}\b/;
846
999
  var BR_LINE_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\]\[(P[0-3])\]\s*(.+)$/;
847
1000
  var BR_LINE_ANY_PRIORITY_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\]\[(P[^\]]+)\]\s*(.+)$/;
848
1001
  var BR_LINE_NO_PRIORITY_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\](?!\s*\[P)\s*(.*\S.*)$/;
849
- var CONTRACT_REF_LINE_RE = /^[ \t]*QFAI-CONTRACT-REF:[ \t]*([^\r\n]*)[ \t]*$/gm;
850
- var CONTRACT_REF_ID_RE = /^(?:API|UI|DB)-\d{4}$/;
851
1002
  var BR_SECTION_TITLE = "\u696D\u52D9\u30EB\u30FC\u30EB";
852
1003
  var VALID_PRIORITIES = /* @__PURE__ */ new Set(["P0", "P1", "P2", "P3"]);
853
1004
  function parseSpec(md, file) {
@@ -920,50 +1071,10 @@ function parseSpec(md, file) {
920
1071
  }
921
1072
  return parsed;
922
1073
  }
923
- function parseContractRefs(md) {
924
- const lines = [];
925
- for (const match of md.matchAll(CONTRACT_REF_LINE_RE)) {
926
- lines.push((match[1] ?? "").trim());
927
- }
928
- const ids = [];
929
- const invalidTokens = [];
930
- let hasNone = false;
931
- for (const line of lines) {
932
- if (line.length === 0) {
933
- invalidTokens.push("(empty)");
934
- continue;
935
- }
936
- const tokens = line.split(",").map((token) => token.trim());
937
- for (const token of tokens) {
938
- if (token.length === 0) {
939
- invalidTokens.push("(empty)");
940
- continue;
941
- }
942
- if (token === "none") {
943
- hasNone = true;
944
- continue;
945
- }
946
- if (CONTRACT_REF_ID_RE.test(token)) {
947
- ids.push(token);
948
- continue;
949
- }
950
- invalidTokens.push(token);
951
- }
952
- }
953
- return {
954
- lines,
955
- ids: unique2(ids),
956
- invalidTokens: unique2(invalidTokens),
957
- hasNone
958
- };
959
- }
960
- function unique2(values) {
961
- return Array.from(new Set(values));
962
- }
963
1074
 
964
1075
  // src/core/traceability.ts
965
1076
  var import_promises7 = require("fs/promises");
966
- var import_node_path8 = __toESM(require("path"), 1);
1077
+ var import_node_path9 = __toESM(require("path"), 1);
967
1078
 
968
1079
  // src/core/gherkin/parse.ts
969
1080
  var import_gherkin = require("@cucumber/gherkin");
@@ -994,9 +1105,6 @@ function formatError2(error2) {
994
1105
  var SPEC_TAG_RE = /^SPEC-\d{4}$/;
995
1106
  var SC_TAG_RE = /^SC-\d{4}$/;
996
1107
  var BR_TAG_RE = /^BR-\d{4}$/;
997
- var UI_TAG_RE = /^UI-\d{4}$/;
998
- var API_TAG_RE = /^API-\d{4}$/;
999
- var DB_TAG_RE = /^DB-\d{4}$/;
1000
1108
  function parseScenarioDocument(text, uri) {
1001
1109
  const { gherkinDocument, errors } = parseGherkin(text, uri);
1002
1110
  if (!gherkinDocument) {
@@ -1021,31 +1129,21 @@ function parseScenarioDocument(text, uri) {
1021
1129
  errors
1022
1130
  };
1023
1131
  }
1024
- function buildScenarioAtoms(document) {
1132
+ function buildScenarioAtoms(document, contractIds = []) {
1133
+ const uniqueContractIds = unique3(contractIds).sort(
1134
+ (a, b) => a.localeCompare(b)
1135
+ );
1025
1136
  return document.scenarios.map((scenario) => {
1026
1137
  const specIds = scenario.tags.filter((tag) => SPEC_TAG_RE.test(tag));
1027
1138
  const scIds = scenario.tags.filter((tag) => SC_TAG_RE.test(tag));
1028
1139
  const brIds = unique3(scenario.tags.filter((tag) => BR_TAG_RE.test(tag)));
1029
- const contractIds = /* @__PURE__ */ new Set();
1030
- scenario.tags.forEach((tag) => {
1031
- if (UI_TAG_RE.test(tag) || API_TAG_RE.test(tag) || DB_TAG_RE.test(tag)) {
1032
- contractIds.add(tag);
1033
- }
1034
- });
1035
- for (const step of scenario.steps) {
1036
- for (const text of collectStepTexts(step)) {
1037
- extractIds(text, "UI").forEach((id) => contractIds.add(id));
1038
- extractIds(text, "API").forEach((id) => contractIds.add(id));
1039
- extractIds(text, "DB").forEach((id) => contractIds.add(id));
1040
- }
1041
- }
1042
1140
  const atom = {
1043
1141
  uri: document.uri,
1044
1142
  featureName: document.featureName ?? "",
1045
1143
  scenarioName: scenario.name,
1046
1144
  kind: scenario.kind,
1047
1145
  brIds,
1048
- contractIds: Array.from(contractIds).sort()
1146
+ contractIds: uniqueContractIds
1049
1147
  };
1050
1148
  if (scenario.line !== void 0) {
1051
1149
  atom.line = scenario.line;
@@ -1098,23 +1196,6 @@ function buildScenarioNode(scenario, featureTags, ruleTags) {
1098
1196
  function collectTagNames(tags) {
1099
1197
  return tags.map((tag) => tag.name.replace(/^@/, ""));
1100
1198
  }
1101
- function collectStepTexts(step) {
1102
- const texts = [];
1103
- if (step.text) {
1104
- texts.push(step.text);
1105
- }
1106
- if (step.docString?.content) {
1107
- texts.push(step.docString.content);
1108
- }
1109
- if (step.dataTable?.rows) {
1110
- for (const row of step.dataTable.rows) {
1111
- for (const cell of row.cells) {
1112
- texts.push(cell.value);
1113
- }
1114
- }
1115
- }
1116
- return texts;
1117
- }
1118
1199
  function unique3(values) {
1119
1200
  return Array.from(new Set(values));
1120
1201
  }
@@ -1216,7 +1297,7 @@ async function collectScTestReferences(root, globs, excludeGlobs) {
1216
1297
  };
1217
1298
  }
1218
1299
  const normalizedFiles = Array.from(
1219
- new Set(files.map((file) => import_node_path8.default.normalize(file)))
1300
+ new Set(files.map((file) => import_node_path9.default.normalize(file)))
1220
1301
  );
1221
1302
  for (const file of normalizedFiles) {
1222
1303
  const text = await (0, import_promises7.readFile)(file, "utf-8");
@@ -1277,11 +1358,11 @@ function formatError3(error2) {
1277
1358
 
1278
1359
  // src/core/version.ts
1279
1360
  var import_promises8 = require("fs/promises");
1280
- var import_node_path9 = __toESM(require("path"), 1);
1361
+ var import_node_path10 = __toESM(require("path"), 1);
1281
1362
  var import_node_url2 = require("url");
1282
1363
  async function resolveToolVersion() {
1283
- if ("0.5.0".length > 0) {
1284
- return "0.5.0";
1364
+ if ("0.5.2".length > 0) {
1365
+ return "0.5.2";
1285
1366
  }
1286
1367
  try {
1287
1368
  const packagePath = resolvePackageJsonPath();
@@ -1296,18 +1377,18 @@ async function resolveToolVersion() {
1296
1377
  function resolvePackageJsonPath() {
1297
1378
  const base = __filename;
1298
1379
  const basePath = base.startsWith("file:") ? (0, import_node_url2.fileURLToPath)(base) : base;
1299
- return import_node_path9.default.resolve(import_node_path9.default.dirname(basePath), "../../package.json");
1380
+ return import_node_path10.default.resolve(import_node_path10.default.dirname(basePath), "../../package.json");
1300
1381
  }
1301
1382
 
1302
1383
  // src/core/validators/contracts.ts
1303
1384
  var import_promises9 = require("fs/promises");
1304
- var import_node_path11 = __toESM(require("path"), 1);
1385
+ var import_node_path12 = __toESM(require("path"), 1);
1305
1386
 
1306
1387
  // src/core/contracts.ts
1307
- var import_node_path10 = __toESM(require("path"), 1);
1388
+ var import_node_path11 = __toESM(require("path"), 1);
1308
1389
  var import_yaml2 = require("yaml");
1309
1390
  function parseStructuredContract(file, text) {
1310
- const ext = import_node_path10.default.extname(file).toLowerCase();
1391
+ const ext = import_node_path11.default.extname(file).toLowerCase();
1311
1392
  if (ext === ".json") {
1312
1393
  return JSON.parse(text);
1313
1394
  }
@@ -1327,9 +1408,9 @@ var SQL_DANGEROUS_PATTERNS = [
1327
1408
  async function validateContracts(root, config) {
1328
1409
  const issues = [];
1329
1410
  const contractsRoot = resolvePath(root, config, "contractsDir");
1330
- issues.push(...await validateUiContracts(import_node_path11.default.join(contractsRoot, "ui")));
1331
- issues.push(...await validateApiContracts(import_node_path11.default.join(contractsRoot, "api")));
1332
- issues.push(...await validateDbContracts(import_node_path11.default.join(contractsRoot, "db")));
1411
+ issues.push(...await validateUiContracts(import_node_path12.default.join(contractsRoot, "ui")));
1412
+ issues.push(...await validateApiContracts(import_node_path12.default.join(contractsRoot, "api")));
1413
+ issues.push(...await validateDbContracts(import_node_path12.default.join(contractsRoot, "db")));
1333
1414
  const contractIndex = await buildContractIndex(root, config);
1334
1415
  issues.push(...validateDuplicateContractIds(contractIndex));
1335
1416
  return issues;
@@ -1612,7 +1693,7 @@ function issue(code, message, severity, file, rule, refs) {
1612
1693
 
1613
1694
  // src/core/validators/delta.ts
1614
1695
  var import_promises10 = require("fs/promises");
1615
- var import_node_path12 = __toESM(require("path"), 1);
1696
+ var import_node_path13 = __toESM(require("path"), 1);
1616
1697
  var SECTION_RE = /^##\s+変更区分/m;
1617
1698
  var COMPAT_LINE_RE = /^\s*-\s*\[[ xX]\]\s*Compatibility\b/m;
1618
1699
  var CHANGE_LINE_RE = /^\s*-\s*\[[ xX]\]\s*Change\/Improvement\b/m;
@@ -1626,7 +1707,7 @@ async function validateDeltas(root, config) {
1626
1707
  }
1627
1708
  const issues = [];
1628
1709
  for (const pack of packs) {
1629
- const deltaPath = import_node_path12.default.join(pack, "delta.md");
1710
+ const deltaPath = import_node_path13.default.join(pack, "delta.md");
1630
1711
  let text;
1631
1712
  try {
1632
1713
  text = await (0, import_promises10.readFile)(deltaPath, "utf-8");
@@ -1702,7 +1783,7 @@ function issue2(code, message, severity, file, rule, refs) {
1702
1783
 
1703
1784
  // src/core/validators/ids.ts
1704
1785
  var import_promises11 = require("fs/promises");
1705
- var import_node_path13 = __toESM(require("path"), 1);
1786
+ var import_node_path14 = __toESM(require("path"), 1);
1706
1787
  var SC_TAG_RE3 = /^SC-\d{4}$/;
1707
1788
  async function validateDefinedIds(root, config) {
1708
1789
  const issues = [];
@@ -1768,7 +1849,7 @@ function recordId(out, id, file) {
1768
1849
  }
1769
1850
  function formatFileList(files, root) {
1770
1851
  return files.map((file) => {
1771
- const relative = import_node_path13.default.relative(root, file);
1852
+ const relative = import_node_path14.default.relative(root, file);
1772
1853
  return relative.length > 0 ? relative : file;
1773
1854
  }).join(", ");
1774
1855
  }
@@ -2205,7 +2286,7 @@ async function validateTraceability(root, config) {
2205
2286
  if (contractRefs.hasNone && contractRefs.ids.length > 0) {
2206
2287
  issues.push(
2207
2288
  issue6(
2208
- "QFAI-TRACE-021",
2289
+ "QFAI-TRACE-023",
2209
2290
  "Spec \u306E QFAI-CONTRACT-REF \u306B none \u3068\u5951\u7D04 ID \u304C\u6DF7\u5728\u3057\u3066\u3044\u307E\u3059\u3002",
2210
2291
  "error",
2211
2292
  file,
@@ -2237,7 +2318,7 @@ async function validateTraceability(root, config) {
2237
2318
  if (unknownContractIds.length > 0) {
2238
2319
  issues.push(
2239
2320
  issue6(
2240
- "QFAI-TRACE-021",
2321
+ "QFAI-TRACE-024",
2241
2322
  `Spec \u304C\u672A\u77E5\u306E\u5951\u7D04 ID \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownContractIds.join(
2242
2323
  ", "
2243
2324
  )}`,
@@ -2252,11 +2333,62 @@ async function validateTraceability(root, config) {
2252
2333
  for (const file of scenarioFiles) {
2253
2334
  const text = await (0, import_promises14.readFile)(file, "utf-8");
2254
2335
  extractAllIds(text).forEach((id) => upstreamIds.add(id));
2336
+ const scenarioContractRefs = parseContractRefs(text, {
2337
+ allowCommentPrefix: true
2338
+ });
2339
+ if (scenarioContractRefs.lines.length === 0) {
2340
+ issues.push(
2341
+ issue6(
2342
+ "QFAI-TRACE-031",
2343
+ "Scenario \u306B QFAI-CONTRACT-REF \u304C\u3042\u308A\u307E\u305B\u3093\u3002",
2344
+ "error",
2345
+ file,
2346
+ "traceability.scenarioContractRefRequired"
2347
+ )
2348
+ );
2349
+ } else {
2350
+ if (scenarioContractRefs.hasNone && scenarioContractRefs.ids.length > 0) {
2351
+ issues.push(
2352
+ issue6(
2353
+ "QFAI-TRACE-033",
2354
+ "Scenario \u306E QFAI-CONTRACT-REF \u306B none \u3068\u5951\u7D04 ID \u304C\u6DF7\u5728\u3057\u3066\u3044\u307E\u3059\u3002",
2355
+ "error",
2356
+ file,
2357
+ "traceability.scenarioContractRefFormat"
2358
+ )
2359
+ );
2360
+ }
2361
+ if (scenarioContractRefs.invalidTokens.length > 0) {
2362
+ issues.push(
2363
+ issue6(
2364
+ "QFAI-TRACE-032",
2365
+ `Scenario \u306E\u5951\u7D04 ID \u304C\u4E0D\u6B63\u3067\u3059: ${scenarioContractRefs.invalidTokens.join(
2366
+ ", "
2367
+ )}`,
2368
+ "error",
2369
+ file,
2370
+ "traceability.scenarioContractRefFormat",
2371
+ scenarioContractRefs.invalidTokens
2372
+ )
2373
+ );
2374
+ }
2375
+ }
2255
2376
  const { document, errors } = parseScenarioDocument(text, file);
2256
2377
  if (!document || errors.length > 0) {
2257
2378
  continue;
2258
2379
  }
2259
- const atoms = buildScenarioAtoms(document);
2380
+ if (document.scenarios.length !== 1) {
2381
+ issues.push(
2382
+ issue6(
2383
+ "QFAI-TRACE-030",
2384
+ `Scenario \u30D5\u30A1\u30A4\u30EB\u306F 1\u30D5\u30A1\u30A4\u30EB=1\u30B7\u30CA\u30EA\u30AA\u3067\u3059\u3002\u73FE\u5728: ${document.scenarios.length}\u4EF6 (file=${file})`,
2385
+ "error",
2386
+ file,
2387
+ "traceability.scenarioOnePerFile"
2388
+ )
2389
+ );
2390
+ }
2391
+ const atoms = buildScenarioAtoms(document, scenarioContractRefs.ids);
2260
2392
  const scIdsInFile = /* @__PURE__ */ new Set();
2261
2393
  for (const [index, scenario] of document.scenarios.entries()) {
2262
2394
  const atom = atoms[index];
@@ -2401,7 +2533,7 @@ async function validateTraceability(root, config) {
2401
2533
  if (orphanBrIds.length > 0) {
2402
2534
  issues.push(
2403
2535
  issue6(
2404
- "QFAI_TRACE_BR_ORPHAN",
2536
+ "QFAI-TRACE-009",
2405
2537
  `BR \u304C SC \u306B\u7D10\u3065\u3044\u3066\u3044\u307E\u305B\u3093: ${orphanBrIds.join(", ")}`,
2406
2538
  "error",
2407
2539
  specsRoot,
@@ -2471,17 +2603,19 @@ async function validateTraceability(root, config) {
2471
2603
  );
2472
2604
  }
2473
2605
  }
2474
- if (!config.validation.traceability.allowOrphanContracts) {
2606
+ const orphanPolicy = config.validation.traceability.orphanContractsPolicy;
2607
+ if (orphanPolicy !== "allow") {
2475
2608
  if (contractIds.size > 0) {
2476
2609
  const orphanContracts = Array.from(contractIds).filter(
2477
2610
  (id) => !specContractIds.has(id)
2478
2611
  );
2479
2612
  if (orphanContracts.length > 0) {
2613
+ const severity = orphanPolicy === "warning" ? "warning" : "error";
2480
2614
  issues.push(
2481
2615
  issue6(
2482
2616
  "QFAI-TRACE-022",
2483
2617
  `\u5951\u7D04\u304C Spec \u304B\u3089\u53C2\u7167\u3055\u308C\u3066\u3044\u307E\u305B\u3093: ${orphanContracts.join(", ")}`,
2484
- "error",
2618
+ severity,
2485
2619
  specsRoot,
2486
2620
  "traceability.contractCoverage",
2487
2621
  orphanContracts
@@ -2606,16 +2740,17 @@ function countIssues(issues) {
2606
2740
  // src/core/report.ts
2607
2741
  var ID_PREFIXES2 = ["SPEC", "BR", "SC", "UI", "API", "DB"];
2608
2742
  async function createReportData(root, validation, configResult) {
2609
- const resolved = configResult ?? await loadConfig(root);
2743
+ const resolvedRoot = import_node_path15.default.resolve(root);
2744
+ const resolved = configResult ?? await loadConfig(resolvedRoot);
2610
2745
  const config = resolved.config;
2611
2746
  const configPath = resolved.configPath;
2612
- const specsRoot = resolvePath(root, config, "specsDir");
2613
- const contractsRoot = resolvePath(root, config, "contractsDir");
2614
- const apiRoot = import_node_path14.default.join(contractsRoot, "api");
2615
- const uiRoot = import_node_path14.default.join(contractsRoot, "ui");
2616
- const dbRoot = import_node_path14.default.join(contractsRoot, "db");
2617
- const srcRoot = resolvePath(root, config, "srcDir");
2618
- const testsRoot = resolvePath(root, config, "testsDir");
2747
+ const specsRoot = resolvePath(resolvedRoot, config, "specsDir");
2748
+ const contractsRoot = resolvePath(resolvedRoot, config, "contractsDir");
2749
+ const apiRoot = import_node_path15.default.join(contractsRoot, "api");
2750
+ const uiRoot = import_node_path15.default.join(contractsRoot, "ui");
2751
+ const dbRoot = import_node_path15.default.join(contractsRoot, "db");
2752
+ const srcRoot = resolvePath(resolvedRoot, config, "srcDir");
2753
+ const testsRoot = resolvePath(resolvedRoot, config, "testsDir");
2619
2754
  const specFiles = await collectSpecFiles(specsRoot);
2620
2755
  const scenarioFiles = await collectScenarioFiles(specsRoot);
2621
2756
  const {
@@ -2623,15 +2758,15 @@ async function createReportData(root, validation, configResult) {
2623
2758
  ui: uiFiles,
2624
2759
  db: dbFiles
2625
2760
  } = await collectContractFiles(uiRoot, apiRoot, dbRoot);
2626
- const contractIndex = await buildContractIndex(root, config);
2761
+ const contractIndex = await buildContractIndex(resolvedRoot, config);
2627
2762
  const contractIdList = Array.from(contractIndex.ids);
2628
2763
  const specContractRefs = await collectSpecContractRefs(
2629
2764
  specFiles,
2630
2765
  contractIdList
2631
2766
  );
2632
2767
  const referencedContracts = /* @__PURE__ */ new Set();
2633
- for (const ids of specContractRefs.specToContractIds.values()) {
2634
- ids.forEach((id) => referencedContracts.add(id));
2768
+ for (const entry of specContractRefs.specToContracts.values()) {
2769
+ entry.ids.forEach((id) => referencedContracts.add(id));
2635
2770
  }
2636
2771
  const referencedContractCount = contractIdList.filter(
2637
2772
  (id) => referencedContracts.has(id)
@@ -2640,8 +2775,8 @@ async function createReportData(root, validation, configResult) {
2640
2775
  (id) => !referencedContracts.has(id)
2641
2776
  ).length;
2642
2777
  const contractIdToSpecsRecord = mapToSortedRecord(specContractRefs.idToSpecs);
2643
- const specToContractIdsRecord = mapToSortedRecord(
2644
- specContractRefs.specToContractIds
2778
+ const specToContractsRecord = mapToSpecContractRecord(
2779
+ specContractRefs.specToContracts
2645
2780
  );
2646
2781
  const idsByPrefix = await collectIds([
2647
2782
  ...specFiles,
@@ -2659,24 +2794,26 @@ async function createReportData(root, validation, configResult) {
2659
2794
  srcRoot,
2660
2795
  testsRoot
2661
2796
  );
2662
- const scIds = await collectScIdsFromScenarioFiles(scenarioFiles);
2663
- const scRefsResult = await collectScTestReferences(
2664
- root,
2665
- config.validation.traceability.testFileGlobs,
2666
- config.validation.traceability.testFileExcludeGlobs
2797
+ const resolvedValidationRaw = validation ?? await validateProject(resolvedRoot, resolved);
2798
+ const normalizedValidation = normalizeValidationResult(
2799
+ resolvedRoot,
2800
+ resolvedValidationRaw
2667
2801
  );
2668
- const scCoverage = validation?.traceability?.sc ?? buildScCoverage(scIds, scRefsResult.refs);
2669
- const testFiles = validation?.traceability?.testFiles ?? scRefsResult.scan;
2802
+ const scCoverage = normalizedValidation.traceability.sc;
2803
+ const testFiles = normalizedValidation.traceability.testFiles;
2670
2804
  const scSources = await collectScIdSourcesFromScenarioFiles(scenarioFiles);
2671
- const scSourceRecord = mapToSortedRecord(scSources);
2672
- const resolvedValidation = validation ?? await validateProject(root, resolved);
2805
+ const scSourceRecord = mapToSortedRecord(
2806
+ normalizeScSources(resolvedRoot, scSources)
2807
+ );
2673
2808
  const version = await resolveToolVersion();
2809
+ const displayRoot = toRelativePath(resolvedRoot, resolvedRoot);
2810
+ const displayConfigPath = toRelativePath(resolvedRoot, configPath);
2674
2811
  return {
2675
2812
  tool: "qfai",
2676
2813
  version,
2677
2814
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2678
- root,
2679
- configPath,
2815
+ root: displayRoot,
2816
+ configPath: displayConfigPath,
2680
2817
  summary: {
2681
2818
  specs: specFiles.length,
2682
2819
  scenarios: scenarioFiles.length,
@@ -2685,7 +2822,7 @@ async function createReportData(root, validation, configResult) {
2685
2822
  ui: uiFiles.length,
2686
2823
  db: dbFiles.length
2687
2824
  },
2688
- counts: resolvedValidation.counts
2825
+ counts: normalizedValidation.counts
2689
2826
  },
2690
2827
  ids: {
2691
2828
  spec: idsByPrefix.SPEC,
@@ -2710,21 +2847,23 @@ async function createReportData(root, validation, configResult) {
2710
2847
  specs: {
2711
2848
  contractRefMissing: specContractRefs.missingRefSpecs.size,
2712
2849
  missingRefSpecs: toSortedArray2(specContractRefs.missingRefSpecs),
2713
- specToContractIds: specToContractIdsRecord
2850
+ specToContracts: specToContractsRecord
2714
2851
  }
2715
2852
  },
2716
- issues: resolvedValidation.issues
2853
+ issues: normalizedValidation.issues
2717
2854
  };
2718
2855
  }
2719
2856
  function formatReportMarkdown(data) {
2720
2857
  const lines = [];
2721
2858
  lines.push("# QFAI Report");
2859
+ lines.push("");
2722
2860
  lines.push(`- \u751F\u6210\u65E5\u6642: ${data.generatedAt}`);
2723
2861
  lines.push(`- \u30EB\u30FC\u30C8: ${data.root}`);
2724
2862
  lines.push(`- \u8A2D\u5B9A: ${data.configPath}`);
2725
2863
  lines.push(`- \u7248: ${data.version}`);
2726
2864
  lines.push("");
2727
2865
  lines.push("## \u6982\u8981");
2866
+ lines.push("");
2728
2867
  lines.push(`- specs: ${data.summary.specs}`);
2729
2868
  lines.push(`- scenarios: ${data.summary.scenarios}`);
2730
2869
  lines.push(
@@ -2735,6 +2874,7 @@ function formatReportMarkdown(data) {
2735
2874
  );
2736
2875
  lines.push("");
2737
2876
  lines.push("## ID\u96C6\u8A08");
2877
+ lines.push("");
2738
2878
  lines.push(formatIdLine("SPEC", data.ids.spec));
2739
2879
  lines.push(formatIdLine("BR", data.ids.br));
2740
2880
  lines.push(formatIdLine("SC", data.ids.sc));
@@ -2743,12 +2883,14 @@ function formatReportMarkdown(data) {
2743
2883
  lines.push(formatIdLine("DB", data.ids.db));
2744
2884
  lines.push("");
2745
2885
  lines.push("## \u30C8\u30EC\u30FC\u30B5\u30D3\u30EA\u30C6\u30A3");
2886
+ lines.push("");
2746
2887
  lines.push(`- \u4E0A\u6D41ID\u691C\u51FA\u6570: ${data.traceability.upstreamIdsFound}`);
2747
2888
  lines.push(
2748
2889
  `- \u30B3\u30FC\u30C9/\u30C6\u30B9\u30C8\u53C2\u7167: ${data.traceability.referencedInCodeOrTests ? "\u3042\u308A" : "\u306A\u3057"}`
2749
2890
  );
2750
2891
  lines.push("");
2751
2892
  lines.push("## \u5951\u7D04\u30AB\u30D0\u30EC\u30C3\u30B8");
2893
+ lines.push("");
2752
2894
  lines.push(`- total: ${data.traceability.contracts.total}`);
2753
2895
  lines.push(`- referenced: ${data.traceability.contracts.referenced}`);
2754
2896
  lines.push(`- orphan: ${data.traceability.contracts.orphan}`);
@@ -2757,6 +2899,7 @@ function formatReportMarkdown(data) {
2757
2899
  );
2758
2900
  lines.push("");
2759
2901
  lines.push("## \u5951\u7D04\u2192Spec");
2902
+ lines.push("");
2760
2903
  const contractToSpecs = data.traceability.contracts.idToSpecs;
2761
2904
  const contractIds = Object.keys(contractToSpecs).sort(
2762
2905
  (a, b) => a.localeCompare(b)
@@ -2775,24 +2918,25 @@ function formatReportMarkdown(data) {
2775
2918
  }
2776
2919
  lines.push("");
2777
2920
  lines.push("## Spec\u2192\u5951\u7D04");
2778
- const specToContracts = data.traceability.specs.specToContractIds;
2921
+ lines.push("");
2922
+ const specToContracts = data.traceability.specs.specToContracts;
2779
2923
  const specIds = Object.keys(specToContracts).sort(
2780
2924
  (a, b) => a.localeCompare(b)
2781
2925
  );
2782
2926
  if (specIds.length === 0) {
2783
2927
  lines.push("- (none)");
2784
2928
  } else {
2785
- for (const specId of specIds) {
2786
- const contractIds2 = specToContracts[specId] ?? [];
2787
- if (contractIds2.length === 0) {
2788
- lines.push(`- ${specId}: (none)`);
2789
- } else {
2790
- lines.push(`- ${specId}: ${contractIds2.join(", ")}`);
2791
- }
2792
- }
2929
+ const rows = specIds.map((specId) => {
2930
+ const entry = specToContracts[specId];
2931
+ const contracts = entry?.status === "missing" ? "(missing)" : entry && entry.ids.length > 0 ? entry.ids.join(", ") : "(none)";
2932
+ const status = entry?.status ?? "missing";
2933
+ return [specId, status, contracts];
2934
+ });
2935
+ lines.push(...formatMarkdownTable(["Spec", "Status", "Contracts"], rows));
2793
2936
  }
2794
2937
  lines.push("");
2795
2938
  lines.push("## Spec\u3067 contract-ref \u672A\u5BA3\u8A00");
2939
+ lines.push("");
2796
2940
  const missingRefSpecs = data.traceability.specs.missingRefSpecs;
2797
2941
  if (missingRefSpecs.length === 0) {
2798
2942
  lines.push("- (none)");
@@ -2803,6 +2947,7 @@ function formatReportMarkdown(data) {
2803
2947
  }
2804
2948
  lines.push("");
2805
2949
  lines.push("## SC\u30AB\u30D0\u30EC\u30C3\u30B8");
2950
+ lines.push("");
2806
2951
  lines.push(`- total: ${data.traceability.sc.total}`);
2807
2952
  lines.push(`- covered: ${data.traceability.sc.covered}`);
2808
2953
  lines.push(`- missing: ${data.traceability.sc.missing}`);
@@ -2832,6 +2977,7 @@ function formatReportMarkdown(data) {
2832
2977
  }
2833
2978
  lines.push("");
2834
2979
  lines.push("## SC\u2192\u53C2\u7167\u30C6\u30B9\u30C8");
2980
+ lines.push("");
2835
2981
  const scRefs = data.traceability.sc.refs;
2836
2982
  const scIds = Object.keys(scRefs).sort((a, b) => a.localeCompare(b));
2837
2983
  if (scIds.length === 0) {
@@ -2848,6 +2994,7 @@ function formatReportMarkdown(data) {
2848
2994
  }
2849
2995
  lines.push("");
2850
2996
  lines.push("## Spec:SC=1:1 \u9055\u53CD");
2997
+ lines.push("");
2851
2998
  const specScIssues = data.issues.filter(
2852
2999
  (item) => item.code === "QFAI-TRACE-012"
2853
3000
  );
@@ -2862,6 +3009,7 @@ function formatReportMarkdown(data) {
2862
3009
  }
2863
3010
  lines.push("");
2864
3011
  lines.push("## Hotspots");
3012
+ lines.push("");
2865
3013
  const hotspots = buildHotspots(data.issues);
2866
3014
  if (hotspots.length === 0) {
2867
3015
  lines.push("- (none)");
@@ -2874,6 +3022,7 @@ function formatReportMarkdown(data) {
2874
3022
  }
2875
3023
  lines.push("");
2876
3024
  lines.push("## \u30C8\u30EC\u30FC\u30B5\u30D3\u30EA\u30C6\u30A3\uFF08\u691C\u8A3C\uFF09");
3025
+ lines.push("");
2877
3026
  const traceIssues = data.issues.filter(
2878
3027
  (item) => item.rule?.startsWith("traceability.") || item.code.startsWith("QFAI_TRACE") || item.code.startsWith("QFAI-TRACE-")
2879
3028
  );
@@ -2889,6 +3038,7 @@ function formatReportMarkdown(data) {
2889
3038
  }
2890
3039
  lines.push("");
2891
3040
  lines.push("## \u691C\u8A3C\u7D50\u679C");
3041
+ lines.push("");
2892
3042
  if (data.issues.length === 0) {
2893
3043
  lines.push("- (none)");
2894
3044
  } else {
@@ -2906,7 +3056,7 @@ function formatReportJson(data) {
2906
3056
  return JSON.stringify(data, null, 2);
2907
3057
  }
2908
3058
  async function collectSpecContractRefs(specFiles, contractIdList) {
2909
- const specToContractIds = /* @__PURE__ */ new Map();
3059
+ const specToContracts = /* @__PURE__ */ new Map();
2910
3060
  const idToSpecs = /* @__PURE__ */ new Map();
2911
3061
  const missingRefSpecs = /* @__PURE__ */ new Set();
2912
3062
  for (const contractId of contractIdList) {
@@ -2915,24 +3065,31 @@ async function collectSpecContractRefs(specFiles, contractIdList) {
2915
3065
  for (const file of specFiles) {
2916
3066
  const text = await (0, import_promises15.readFile)(file, "utf-8");
2917
3067
  const parsed = parseSpec(text, file);
2918
- const specKey = parsed.specId ?? file;
3068
+ const specKey = parsed.specId;
3069
+ if (!specKey) {
3070
+ continue;
3071
+ }
2919
3072
  const refs = parsed.contractRefs;
2920
3073
  if (refs.lines.length === 0) {
2921
3074
  missingRefSpecs.add(specKey);
3075
+ specToContracts.set(specKey, { status: "missing", ids: /* @__PURE__ */ new Set() });
2922
3076
  continue;
2923
3077
  }
2924
- const currentContracts = specToContractIds.get(specKey) ?? /* @__PURE__ */ new Set();
3078
+ const current = specToContracts.get(specKey) ?? {
3079
+ status: "declared",
3080
+ ids: /* @__PURE__ */ new Set()
3081
+ };
2925
3082
  for (const id of refs.ids) {
2926
- currentContracts.add(id);
3083
+ current.ids.add(id);
2927
3084
  const specs = idToSpecs.get(id);
2928
3085
  if (specs) {
2929
3086
  specs.add(specKey);
2930
3087
  }
2931
3088
  }
2932
- specToContractIds.set(specKey, currentContracts);
3089
+ specToContracts.set(specKey, current);
2933
3090
  }
2934
3091
  return {
2935
- specToContractIds,
3092
+ specToContracts,
2936
3093
  idToSpecs,
2937
3094
  missingRefSpecs
2938
3095
  };
@@ -3009,6 +3166,20 @@ function formatList(values) {
3009
3166
  }
3010
3167
  return values.join(", ");
3011
3168
  }
3169
+ function formatMarkdownTable(headers, rows) {
3170
+ const widths = headers.map((header, index) => {
3171
+ const candidates = rows.map((row) => row[index] ?? "");
3172
+ return Math.max(header.length, ...candidates.map((item) => item.length));
3173
+ });
3174
+ const formatRow = (cells) => {
3175
+ const padded = cells.map(
3176
+ (cell, index) => (cell ?? "").padEnd(widths[index] ?? 0)
3177
+ );
3178
+ return `| ${padded.join(" | ")} |`;
3179
+ };
3180
+ const separator = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`;
3181
+ return [formatRow(headers), separator, ...rows.map(formatRow)];
3182
+ }
3012
3183
  function toSortedArray2(values) {
3013
3184
  return Array.from(values).sort((a, b) => a.localeCompare(b));
3014
3185
  }
@@ -3019,6 +3190,27 @@ function mapToSortedRecord(values) {
3019
3190
  }
3020
3191
  return record2;
3021
3192
  }
3193
+ function mapToSpecContractRecord(values) {
3194
+ const record2 = {};
3195
+ for (const [key, entry] of values.entries()) {
3196
+ record2[key] = {
3197
+ status: entry.status,
3198
+ ids: toSortedArray2(entry.ids)
3199
+ };
3200
+ }
3201
+ return record2;
3202
+ }
3203
+ function normalizeScSources(root, sources) {
3204
+ const normalized = /* @__PURE__ */ new Map();
3205
+ for (const [id, files] of sources.entries()) {
3206
+ const mapped = /* @__PURE__ */ new Set();
3207
+ for (const file of files) {
3208
+ mapped.add(toRelativePath(root, file));
3209
+ }
3210
+ normalized.set(id, mapped);
3211
+ }
3212
+ return normalized;
3213
+ }
3022
3214
  function buildHotspots(issues) {
3023
3215
  const map = /* @__PURE__ */ new Map();
3024
3216
  for (const issue7 of issues) {
@@ -3043,38 +3235,53 @@ function buildHotspots(issues) {
3043
3235
 
3044
3236
  // src/cli/commands/report.ts
3045
3237
  async function runReport(options) {
3046
- const root = import_node_path15.default.resolve(options.root);
3238
+ const root = import_node_path16.default.resolve(options.root);
3047
3239
  const configResult = await loadConfig(root);
3048
- const input = configResult.config.output.validateJsonPath;
3049
- const inputPath = import_node_path15.default.isAbsolute(input) ? input : import_node_path15.default.resolve(root, input);
3050
3240
  let validation;
3051
- try {
3052
- validation = await readValidationResult(inputPath);
3053
- } catch (err) {
3054
- if (isMissingFileError5(err)) {
3055
- error(
3056
- [
3057
- `qfai report: \u5165\u529B\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${inputPath}`,
3058
- "",
3059
- "\u307E\u305A qfai validate \u3092\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u4F8B:",
3060
- " qfai validate",
3061
- "\uFF08\u30C7\u30D5\u30A9\u30EB\u30C8\u306E\u51FA\u529B\u5148: .qfai/out/validate.json\uFF09",
3062
- "",
3063
- "GitHub Actions \u30C6\u30F3\u30D7\u30EC\u3092\u4F7F\u3063\u3066\u3044\u308B\u5834\u5408\u306F\u3001workflow \u306E validate \u30B8\u30E7\u30D6\u3092\u5148\u306B\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002"
3064
- ].join("\n")
3065
- );
3066
- process.exitCode = 2;
3067
- return;
3241
+ if (options.runValidate) {
3242
+ if (options.inputPath) {
3243
+ warn("report: --run-validate \u304C\u6307\u5B9A\u3055\u308C\u305F\u305F\u3081 --in \u306F\u7121\u8996\u3057\u307E\u3059\u3002");
3244
+ }
3245
+ const result = await validateProject(root, configResult);
3246
+ const normalized = normalizeValidationResult(root, result);
3247
+ await writeValidationResult(
3248
+ root,
3249
+ configResult.config.output.validateJsonPath,
3250
+ normalized
3251
+ );
3252
+ validation = normalized;
3253
+ } else {
3254
+ const input = options.inputPath ?? configResult.config.output.validateJsonPath;
3255
+ const inputPath = import_node_path16.default.isAbsolute(input) ? input : import_node_path16.default.resolve(root, input);
3256
+ try {
3257
+ validation = await readValidationResult(inputPath);
3258
+ } catch (err) {
3259
+ if (isMissingFileError5(err)) {
3260
+ error(
3261
+ [
3262
+ `qfai report: \u5165\u529B\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${inputPath}`,
3263
+ "",
3264
+ "\u307E\u305A qfai validate \u3092\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u4F8B:",
3265
+ " qfai validate",
3266
+ "\uFF08\u30C7\u30D5\u30A9\u30EB\u30C8\u306E\u51FA\u529B\u5148: .qfai/out/validate.json\uFF09",
3267
+ "",
3268
+ "\u307E\u305F\u306F report \u306B --run-validate \u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
3269
+ "GitHub Actions \u30C6\u30F3\u30D7\u30EC\u3092\u4F7F\u3063\u3066\u3044\u308B\u5834\u5408\u306F\u3001workflow \u306E validate \u30B8\u30E7\u30D6\u3092\u5148\u306B\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002"
3270
+ ].join("\n")
3271
+ );
3272
+ process.exitCode = 2;
3273
+ return;
3274
+ }
3275
+ throw err;
3068
3276
  }
3069
- throw err;
3070
3277
  }
3071
3278
  const data = await createReportData(root, validation, configResult);
3072
3279
  const output = options.format === "json" ? formatReportJson(data) : formatReportMarkdown(data);
3073
3280
  const outRoot = resolvePath(root, configResult.config, "outDir");
3074
- const defaultOut = options.format === "json" ? import_node_path15.default.join(outRoot, "report.json") : import_node_path15.default.join(outRoot, "report.md");
3281
+ const defaultOut = options.format === "json" ? import_node_path16.default.join(outRoot, "report.json") : import_node_path16.default.join(outRoot, "report.md");
3075
3282
  const out = options.outPath ?? defaultOut;
3076
- const outPath = import_node_path15.default.isAbsolute(out) ? out : import_node_path15.default.resolve(root, out);
3077
- await (0, import_promises16.mkdir)(import_node_path15.default.dirname(outPath), { recursive: true });
3283
+ const outPath = import_node_path16.default.isAbsolute(out) ? out : import_node_path16.default.resolve(root, out);
3284
+ await (0, import_promises16.mkdir)(import_node_path16.default.dirname(outPath), { recursive: true });
3078
3285
  await (0, import_promises16.writeFile)(outPath, `${output}
3079
3286
  `, "utf-8");
3080
3287
  info(
@@ -3138,10 +3345,16 @@ function isMissingFileError5(error2) {
3138
3345
  const record2 = error2;
3139
3346
  return record2.code === "ENOENT";
3140
3347
  }
3348
+ async function writeValidationResult(root, outputPath, result) {
3349
+ const abs = import_node_path16.default.isAbsolute(outputPath) ? outputPath : import_node_path16.default.resolve(root, outputPath);
3350
+ await (0, import_promises16.mkdir)(import_node_path16.default.dirname(abs), { recursive: true });
3351
+ await (0, import_promises16.writeFile)(abs, `${JSON.stringify(result, null, 2)}
3352
+ `, "utf-8");
3353
+ }
3141
3354
 
3142
3355
  // src/cli/commands/validate.ts
3143
3356
  var import_promises17 = require("fs/promises");
3144
- var import_node_path16 = __toESM(require("path"), 1);
3357
+ var import_node_path17 = __toESM(require("path"), 1);
3145
3358
 
3146
3359
  // src/cli/lib/failOn.ts
3147
3360
  function shouldFail(result, failOn) {
@@ -3156,19 +3369,24 @@ function shouldFail(result, failOn) {
3156
3369
 
3157
3370
  // src/cli/commands/validate.ts
3158
3371
  async function runValidate(options) {
3159
- const root = import_node_path16.default.resolve(options.root);
3372
+ const root = import_node_path17.default.resolve(options.root);
3160
3373
  const configResult = await loadConfig(root);
3161
3374
  const result = await validateProject(root, configResult);
3375
+ const normalized = normalizeValidationResult(root, result);
3162
3376
  const format = options.format ?? "text";
3163
3377
  if (format === "text") {
3164
- emitText(result);
3378
+ emitText(normalized);
3165
3379
  }
3166
3380
  if (format === "github") {
3167
- result.issues.forEach(emitGitHub);
3381
+ const jsonPath = resolveJsonPath(
3382
+ root,
3383
+ configResult.config.output.validateJsonPath
3384
+ );
3385
+ emitGitHubOutput(normalized, root, jsonPath);
3168
3386
  }
3169
- await emitJson(result, root, configResult.config.output.validateJsonPath);
3387
+ await emitJson(normalized, root, configResult.config.output.validateJsonPath);
3170
3388
  const failOn = resolveFailOn(options, configResult.config.validation.failOn);
3171
- return shouldFail(result, failOn) ? 1 : 0;
3389
+ return shouldFail(normalized, failOn) ? 1 : 0;
3172
3390
  }
3173
3391
  function resolveFailOn(options, fallback) {
3174
3392
  if (options.failOn) {
@@ -3193,6 +3411,22 @@ function emitText(result) {
3193
3411
  `
3194
3412
  );
3195
3413
  }
3414
+ function emitGitHubOutput(result, root, jsonPath) {
3415
+ const deduped = dedupeIssues(result.issues);
3416
+ const omitted = Math.max(deduped.length - GITHUB_ANNOTATION_LIMIT, 0);
3417
+ const dropped = Math.max(result.issues.length - deduped.length, 0);
3418
+ emitGitHubSummary(result, {
3419
+ total: deduped.length,
3420
+ omitted,
3421
+ dropped,
3422
+ jsonPath,
3423
+ root
3424
+ });
3425
+ const issues = deduped.slice(0, GITHUB_ANNOTATION_LIMIT);
3426
+ for (const issue7 of issues) {
3427
+ emitGitHub(issue7);
3428
+ }
3429
+ }
3196
3430
  function emitGitHub(issue7) {
3197
3431
  const level = issue7.severity === "error" ? "error" : issue7.severity === "warning" ? "warning" : "notice";
3198
3432
  const file = issue7.file ? `file=${issue7.file}` : "";
@@ -3204,22 +3438,74 @@ function emitGitHub(issue7) {
3204
3438
  `
3205
3439
  );
3206
3440
  }
3441
+ function emitGitHubSummary(result, options) {
3442
+ const summary = [
3443
+ "qfai validate summary:",
3444
+ `error=${result.counts.error}`,
3445
+ `warning=${result.counts.warning}`,
3446
+ `info=${result.counts.info}`,
3447
+ `annotations=${Math.min(options.total, GITHUB_ANNOTATION_LIMIT)}/${options.total}`
3448
+ ].join(" ");
3449
+ process.stdout.write(`${summary}
3450
+ `);
3451
+ if (options.dropped > 0 || options.omitted > 0) {
3452
+ const details = [
3453
+ "qfai validate note:",
3454
+ options.dropped > 0 ? `\u91CD\u8907\u9664\u5916=${options.dropped}` : null,
3455
+ options.omitted > 0 ? `\u4E0A\u9650\u7701\u7565=${options.omitted}` : null
3456
+ ].filter(Boolean).join(" ");
3457
+ process.stdout.write(`${details}
3458
+ `);
3459
+ }
3460
+ const relative = toRelativePath(options.root, options.jsonPath);
3461
+ process.stdout.write(
3462
+ `qfai validate note: \u8A73\u7D30\u306F ${relative} \u307E\u305F\u306F --format text \u3092\u53C2\u7167\u3057\u3066\u304F\u3060\u3055\u3044\u3002
3463
+ `
3464
+ );
3465
+ }
3466
+ function dedupeIssues(issues) {
3467
+ const seen = /* @__PURE__ */ new Set();
3468
+ const deduped = [];
3469
+ for (const issue7 of issues) {
3470
+ const key = issueKey(issue7);
3471
+ if (seen.has(key)) {
3472
+ continue;
3473
+ }
3474
+ seen.add(key);
3475
+ deduped.push(issue7);
3476
+ }
3477
+ return deduped;
3478
+ }
3479
+ function issueKey(issue7) {
3480
+ const file = issue7.file ?? "";
3481
+ const line = issue7.loc?.line ?? "";
3482
+ const column = issue7.loc?.column ?? "";
3483
+ return [issue7.code, issue7.severity, issue7.message, file, line, column].join(
3484
+ "|"
3485
+ );
3486
+ }
3207
3487
  async function emitJson(result, root, jsonPath) {
3208
- const abs = import_node_path16.default.isAbsolute(jsonPath) ? jsonPath : import_node_path16.default.resolve(root, jsonPath);
3209
- await (0, import_promises17.mkdir)(import_node_path16.default.dirname(abs), { recursive: true });
3488
+ const abs = resolveJsonPath(root, jsonPath);
3489
+ await (0, import_promises17.mkdir)(import_node_path17.default.dirname(abs), { recursive: true });
3210
3490
  await (0, import_promises17.writeFile)(abs, `${JSON.stringify(result, null, 2)}
3211
3491
  `, "utf-8");
3212
3492
  }
3493
+ function resolveJsonPath(root, jsonPath) {
3494
+ return import_node_path17.default.isAbsolute(jsonPath) ? jsonPath : import_node_path17.default.resolve(root, jsonPath);
3495
+ }
3496
+ var GITHUB_ANNOTATION_LIMIT = 100;
3213
3497
 
3214
3498
  // src/cli/lib/args.ts
3215
3499
  function parseArgs(argv, cwd) {
3216
3500
  const options = {
3217
3501
  root: cwd,
3502
+ rootExplicit: false,
3218
3503
  dir: cwd,
3219
3504
  force: false,
3220
3505
  yes: false,
3221
3506
  dryRun: false,
3222
3507
  reportFormat: "md",
3508
+ reportRunValidate: false,
3223
3509
  validateFormat: "text",
3224
3510
  strict: false,
3225
3511
  help: false
@@ -3235,6 +3521,7 @@ function parseArgs(argv, cwd) {
3235
3521
  switch (arg) {
3236
3522
  case "--root":
3237
3523
  options.root = args[i + 1] ?? options.root;
3524
+ options.rootExplicit = true;
3238
3525
  i += 1;
3239
3526
  break;
3240
3527
  case "--dir":
@@ -3276,6 +3563,18 @@ function parseArgs(argv, cwd) {
3276
3563
  }
3277
3564
  i += 1;
3278
3565
  break;
3566
+ case "--in":
3567
+ {
3568
+ const next = args[i + 1];
3569
+ if (next) {
3570
+ options.reportIn = next;
3571
+ }
3572
+ }
3573
+ i += 1;
3574
+ break;
3575
+ case "--run-validate":
3576
+ options.reportRunValidate = true;
3577
+ break;
3279
3578
  case "--help":
3280
3579
  case "-h":
3281
3580
  options.help = true;
@@ -3327,19 +3626,27 @@ async function run(argv, cwd) {
3327
3626
  });
3328
3627
  return;
3329
3628
  case "validate":
3330
- process.exitCode = await runValidate({
3331
- root: options.root,
3332
- strict: options.strict,
3333
- format: options.validateFormat,
3334
- ...options.failOn !== void 0 ? { failOn: options.failOn } : {}
3335
- });
3629
+ {
3630
+ const resolvedRoot = await resolveRoot(options);
3631
+ process.exitCode = await runValidate({
3632
+ root: resolvedRoot,
3633
+ strict: options.strict,
3634
+ format: options.validateFormat,
3635
+ ...options.failOn !== void 0 ? { failOn: options.failOn } : {}
3636
+ });
3637
+ }
3336
3638
  return;
3337
3639
  case "report":
3338
- await runReport({
3339
- root: options.root,
3340
- format: options.reportFormat,
3341
- ...options.reportOut !== void 0 ? { outPath: options.reportOut } : {}
3342
- });
3640
+ {
3641
+ const resolvedRoot = await resolveRoot(options);
3642
+ await runReport({
3643
+ root: resolvedRoot,
3644
+ format: options.reportFormat,
3645
+ ...options.reportOut !== void 0 ? { outPath: options.reportOut } : {},
3646
+ ...options.reportIn !== void 0 ? { inputPath: options.reportIn } : {},
3647
+ ...options.reportRunValidate ? { runValidate: true } : {}
3648
+ });
3649
+ }
3343
3650
  return;
3344
3651
  default:
3345
3652
  error(`Unknown command: ${command}`);
@@ -3366,9 +3673,23 @@ Options:
3366
3673
  --strict validate: warning \u4EE5\u4E0A\u3067 exit 1
3367
3674
  --fail-on <error|warning|never> validate: \u5931\u6557\u6761\u4EF6
3368
3675
  --out <path> report: \u51FA\u529B\u5148
3676
+ --in <path> report: validate.json \u306E\u5165\u529B\u5148\uFF08config\u3088\u308A\u512A\u5148\uFF09
3677
+ --run-validate report: validate \u3092\u5B9F\u884C\u3057\u3066\u304B\u3089 report \u3092\u751F\u6210
3369
3678
  -h, --help \u30D8\u30EB\u30D7\u8868\u793A
3370
3679
  `;
3371
3680
  }
3681
+ async function resolveRoot(options) {
3682
+ if (options.rootExplicit) {
3683
+ return options.root;
3684
+ }
3685
+ const search = await findConfigRoot(options.root);
3686
+ if (!search.found) {
3687
+ warn(
3688
+ `qfai: qfai.config.yaml \u304C\u898B\u3064\u304B\u3089\u306A\u3044\u305F\u3081 defaultConfig \u3092\u4F7F\u7528\u3057\u307E\u3059 (root=${search.root})`
3689
+ );
3690
+ }
3691
+ return search.root;
3692
+ }
3372
3693
 
3373
3694
  // src/cli/index.ts
3374
3695
  run(process.argv.slice(2), process.cwd()).catch((err) => {