qfai 0.4.9 → 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.
@@ -116,6 +116,10 @@ function info(message) {
116
116
  process.stdout.write(`${message}
117
117
  `);
118
118
  }
119
+ function warn(message) {
120
+ process.stdout.write(`${message}
121
+ `);
122
+ }
119
123
  function error(message) {
120
124
  process.stderr.write(`${message}
121
125
  `);
@@ -155,10 +159,10 @@ function report(copied, skipped, dryRun, label) {
155
159
 
156
160
  // src/cli/commands/report.ts
157
161
  import { mkdir as mkdir2, readFile as readFile12, writeFile } from "fs/promises";
158
- import path15 from "path";
162
+ import path16 from "path";
159
163
 
160
164
  // src/core/config.ts
161
- import { readFile } from "fs/promises";
165
+ import { access as access2, readFile } from "fs/promises";
162
166
  import path4 from "path";
163
167
  import { parse as parseYaml } from "yaml";
164
168
  var defaultConfig = {
@@ -190,7 +194,7 @@ var defaultConfig = {
190
194
  testFileGlobs: [],
191
195
  testFileExcludeGlobs: [],
192
196
  scNoTestSeverity: "error",
193
- allowOrphanContracts: false,
197
+ orphanContractsPolicy: "error",
194
198
  unknownContractIdSeverity: "error"
195
199
  }
196
200
  },
@@ -201,6 +205,26 @@ var defaultConfig = {
201
205
  function getConfigPath(root) {
202
206
  return path4.join(root, "qfai.config.yaml");
203
207
  }
208
+ async function findConfigRoot(startDir) {
209
+ const resolvedStart = path4.resolve(startDir);
210
+ let current = resolvedStart;
211
+ while (true) {
212
+ const configPath = getConfigPath(current);
213
+ if (await exists2(configPath)) {
214
+ return { root: current, configPath, found: true };
215
+ }
216
+ const parent = path4.dirname(current);
217
+ if (parent === current) {
218
+ break;
219
+ }
220
+ current = parent;
221
+ }
222
+ return {
223
+ root: resolvedStart,
224
+ configPath: getConfigPath(resolvedStart),
225
+ found: false
226
+ };
227
+ }
204
228
  async function loadConfig(root) {
205
229
  const configPath = getConfigPath(root);
206
230
  const issues = [];
@@ -390,10 +414,10 @@ function normalizeValidation(raw, configPath, issues) {
390
414
  configPath,
391
415
  issues
392
416
  ),
393
- allowOrphanContracts: readBoolean(
394
- traceabilityRaw?.allowOrphanContracts,
395
- base.traceability.allowOrphanContracts,
396
- "validation.traceability.allowOrphanContracts",
417
+ orphanContractsPolicy: readOrphanContractsPolicy(
418
+ traceabilityRaw?.orphanContractsPolicy,
419
+ base.traceability.orphanContractsPolicy,
420
+ "validation.traceability.orphanContractsPolicy",
397
421
  configPath,
398
422
  issues
399
423
  ),
@@ -489,6 +513,20 @@ function readTraceabilitySeverity(value, fallback, label, configPath, issues) {
489
513
  }
490
514
  return fallback;
491
515
  }
516
+ function readOrphanContractsPolicy(value, fallback, label, configPath, issues) {
517
+ if (value === "error" || value === "warning" || value === "allow") {
518
+ return value;
519
+ }
520
+ if (value !== void 0) {
521
+ issues.push(
522
+ configIssue(
523
+ configPath,
524
+ `${label} \u306F error|warning|allow \u306E\u3044\u305A\u308C\u304B\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002`
525
+ )
526
+ );
527
+ }
528
+ return fallback;
529
+ }
492
530
  function configIssue(file, message) {
493
531
  return {
494
532
  code: "QFAI_CONFIG_INVALID",
@@ -504,6 +542,14 @@ function isMissingFile(error2) {
504
542
  }
505
543
  return false;
506
544
  }
545
+ async function exists2(target) {
546
+ try {
547
+ await access2(target);
548
+ return true;
549
+ } catch {
550
+ return false;
551
+ }
552
+ }
507
553
  function formatError(error2) {
508
554
  if (error2 instanceof Error) {
509
555
  return error2.message;
@@ -514,20 +560,76 @@ function isRecord(value) {
514
560
  return value !== null && typeof value === "object" && !Array.isArray(value);
515
561
  }
516
562
 
563
+ // src/core/paths.ts
564
+ import path5 from "path";
565
+ function toRelativePath(root, target) {
566
+ if (!target) {
567
+ return target;
568
+ }
569
+ if (!path5.isAbsolute(target)) {
570
+ return toPosixPath(target);
571
+ }
572
+ const relative = path5.relative(root, target);
573
+ if (!relative) {
574
+ return ".";
575
+ }
576
+ return toPosixPath(relative);
577
+ }
578
+ function toPosixPath(value) {
579
+ return value.replace(/\\/g, "/");
580
+ }
581
+
582
+ // src/core/normalize.ts
583
+ function normalizeIssuePaths(root, issues) {
584
+ return issues.map((issue7) => {
585
+ if (!issue7.file) {
586
+ return issue7;
587
+ }
588
+ const normalized = toRelativePath(root, issue7.file);
589
+ if (normalized === issue7.file) {
590
+ return issue7;
591
+ }
592
+ return {
593
+ ...issue7,
594
+ file: normalized
595
+ };
596
+ });
597
+ }
598
+ function normalizeScCoverage(root, sc) {
599
+ const refs = {};
600
+ for (const [scId, files] of Object.entries(sc.refs)) {
601
+ refs[scId] = files.map((file) => toRelativePath(root, file));
602
+ }
603
+ return {
604
+ ...sc,
605
+ refs
606
+ };
607
+ }
608
+ function normalizeValidationResult(root, result) {
609
+ return {
610
+ ...result,
611
+ issues: normalizeIssuePaths(root, result.issues),
612
+ traceability: {
613
+ ...result.traceability,
614
+ sc: normalizeScCoverage(root, result.traceability.sc)
615
+ }
616
+ };
617
+ }
618
+
517
619
  // src/core/report.ts
518
620
  import { readFile as readFile11 } from "fs/promises";
519
- import path14 from "path";
621
+ import path15 from "path";
520
622
 
521
623
  // src/core/contractIndex.ts
522
624
  import { readFile as readFile2 } from "fs/promises";
523
- import path7 from "path";
625
+ import path8 from "path";
524
626
 
525
627
  // src/core/discovery.ts
526
- import { access as access3 } from "fs/promises";
628
+ import { access as access4 } from "fs/promises";
527
629
 
528
630
  // src/core/fs.ts
529
- import { access as access2, readdir as readdir2 } from "fs/promises";
530
- import path5 from "path";
631
+ import { access as access3, readdir as readdir2 } from "fs/promises";
632
+ import path6 from "path";
531
633
  import fg from "fast-glob";
532
634
  var DEFAULT_IGNORE_DIRS = /* @__PURE__ */ new Set([
533
635
  "node_modules",
@@ -539,7 +641,7 @@ var DEFAULT_IGNORE_DIRS = /* @__PURE__ */ new Set([
539
641
  ]);
540
642
  async function collectFiles(root, options = {}) {
541
643
  const entries = [];
542
- if (!await exists2(root)) {
644
+ if (!await exists3(root)) {
543
645
  return entries;
544
646
  }
545
647
  const ignoreDirs = /* @__PURE__ */ new Set([
@@ -565,7 +667,7 @@ async function collectFilesByGlobs(root, options) {
565
667
  async function walk(base, current, ignoreDirs, extensions, out) {
566
668
  const items = await readdir2(current, { withFileTypes: true });
567
669
  for (const item of items) {
568
- const fullPath = path5.join(current, item.name);
670
+ const fullPath = path6.join(current, item.name);
569
671
  if (item.isDirectory()) {
570
672
  if (ignoreDirs.has(item.name)) {
571
673
  continue;
@@ -575,7 +677,7 @@ async function walk(base, current, ignoreDirs, extensions, out) {
575
677
  }
576
678
  if (item.isFile()) {
577
679
  if (extensions.length > 0) {
578
- const ext = path5.extname(item.name).toLowerCase();
680
+ const ext = path6.extname(item.name).toLowerCase();
579
681
  if (!extensions.includes(ext)) {
580
682
  continue;
581
683
  }
@@ -584,9 +686,9 @@ async function walk(base, current, ignoreDirs, extensions, out) {
584
686
  }
585
687
  }
586
688
  }
587
- async function exists2(target) {
689
+ async function exists3(target) {
588
690
  try {
589
- await access2(target);
691
+ await access3(target);
590
692
  return true;
591
693
  } catch {
592
694
  return false;
@@ -595,22 +697,22 @@ async function exists2(target) {
595
697
 
596
698
  // src/core/specLayout.ts
597
699
  import { readdir as readdir3 } from "fs/promises";
598
- import path6 from "path";
700
+ import path7 from "path";
599
701
  var SPEC_DIR_RE = /^spec-\d{4}$/;
600
702
  async function collectSpecEntries(specsRoot) {
601
703
  const dirs = await listSpecDirs(specsRoot);
602
704
  const entries = dirs.map((dir) => ({
603
705
  dir,
604
- specPath: path6.join(dir, "spec.md"),
605
- deltaPath: path6.join(dir, "delta.md"),
606
- scenarioPath: path6.join(dir, "scenario.md")
706
+ specPath: path7.join(dir, "spec.md"),
707
+ deltaPath: path7.join(dir, "delta.md"),
708
+ scenarioPath: path7.join(dir, "scenario.md")
607
709
  }));
608
710
  return entries.sort((a, b) => a.dir.localeCompare(b.dir));
609
711
  }
610
712
  async function listSpecDirs(specsRoot) {
611
713
  try {
612
714
  const items = await readdir3(specsRoot, { withFileTypes: true });
613
- return items.filter((item) => item.isDirectory()).map((item) => item.name).filter((name) => SPEC_DIR_RE.test(name.toLowerCase())).map((name) => path6.join(specsRoot, name));
715
+ return items.filter((item) => item.isDirectory()).map((item) => item.name).filter((name) => SPEC_DIR_RE.test(name.toLowerCase())).map((name) => path7.join(specsRoot, name));
614
716
  } catch (error2) {
615
717
  if (isMissingFileError(error2)) {
616
718
  return [];
@@ -658,15 +760,15 @@ async function collectContractFiles(uiRoot, apiRoot, dbRoot) {
658
760
  async function filterExisting(files) {
659
761
  const existing = [];
660
762
  for (const file of files) {
661
- if (await exists3(file)) {
763
+ if (await exists4(file)) {
662
764
  existing.push(file);
663
765
  }
664
766
  }
665
767
  return existing;
666
768
  }
667
- async function exists3(target) {
769
+ async function exists4(target) {
668
770
  try {
669
- await access3(target);
771
+ await access4(target);
670
772
  return true;
671
773
  } catch {
672
774
  return false;
@@ -693,9 +795,9 @@ function stripContractDeclarationLines(text) {
693
795
  // src/core/contractIndex.ts
694
796
  async function buildContractIndex(root, config) {
695
797
  const contractsRoot = resolvePath(root, config, "contractsDir");
696
- const uiRoot = path7.join(contractsRoot, "ui");
697
- const apiRoot = path7.join(contractsRoot, "api");
698
- const dbRoot = path7.join(contractsRoot, "db");
798
+ const uiRoot = path8.join(contractsRoot, "ui");
799
+ const apiRoot = path8.join(contractsRoot, "api");
800
+ const dbRoot = path8.join(contractsRoot, "db");
699
801
  const [uiFiles, apiFiles, dbFiles] = await Promise.all([
700
802
  collectUiContractFiles(uiRoot),
701
803
  collectApiContractFiles(apiRoot),
@@ -777,6 +879,57 @@ function isValidId(value, prefix) {
777
879
  return strict.test(value);
778
880
  }
779
881
 
882
+ // src/core/parse/contractRefs.ts
883
+ var CONTRACT_REF_ID_RE = /^(?:API|UI|DB)-\d{4}$/;
884
+ function parseContractRefs(text, options = {}) {
885
+ const linePattern = buildLinePattern(options);
886
+ const lines = [];
887
+ for (const match of text.matchAll(linePattern)) {
888
+ lines.push((match[1] ?? "").trim());
889
+ }
890
+ const ids = [];
891
+ const invalidTokens = [];
892
+ let hasNone = false;
893
+ for (const line of lines) {
894
+ if (line.length === 0) {
895
+ invalidTokens.push("(empty)");
896
+ continue;
897
+ }
898
+ const tokens = line.split(",").map((token) => token.trim());
899
+ for (const token of tokens) {
900
+ if (token.length === 0) {
901
+ invalidTokens.push("(empty)");
902
+ continue;
903
+ }
904
+ if (token === "none") {
905
+ hasNone = true;
906
+ continue;
907
+ }
908
+ if (CONTRACT_REF_ID_RE.test(token)) {
909
+ ids.push(token);
910
+ continue;
911
+ }
912
+ invalidTokens.push(token);
913
+ }
914
+ }
915
+ return {
916
+ lines,
917
+ ids: unique2(ids),
918
+ invalidTokens: unique2(invalidTokens),
919
+ hasNone
920
+ };
921
+ }
922
+ function buildLinePattern(options) {
923
+ const prefix = options.allowCommentPrefix ? "#" : "";
924
+ return new RegExp(
925
+ `^[ \\t]*${prefix}[ \\t]*QFAI-CONTRACT-REF:[ \\t]*([^\\r\\n]*)[ \\t]*$`,
926
+ "gm"
927
+ );
928
+ }
929
+ function unique2(values) {
930
+ return Array.from(new Set(values));
931
+ }
932
+
780
933
  // src/core/parse/markdown.ts
781
934
  var HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
782
935
  function parseHeadings(md) {
@@ -823,8 +976,6 @@ var SPEC_ID_RE = /\bSPEC-\d{4}\b/;
823
976
  var BR_LINE_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\]\[(P[0-3])\]\s*(.+)$/;
824
977
  var BR_LINE_ANY_PRIORITY_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\]\[(P[^\]]+)\]\s*(.+)$/;
825
978
  var BR_LINE_NO_PRIORITY_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\](?!\s*\[P)\s*(.*\S.*)$/;
826
- var CONTRACT_REF_LINE_RE = /^[ \t]*QFAI-CONTRACT-REF:[ \t]*([^\r\n]*)[ \t]*$/gm;
827
- var CONTRACT_REF_ID_RE = /^(?:API|UI|DB)-\d{4}$/;
828
979
  var BR_SECTION_TITLE = "\u696D\u52D9\u30EB\u30FC\u30EB";
829
980
  var VALID_PRIORITIES = /* @__PURE__ */ new Set(["P0", "P1", "P2", "P3"]);
830
981
  function parseSpec(md, file) {
@@ -897,50 +1048,10 @@ function parseSpec(md, file) {
897
1048
  }
898
1049
  return parsed;
899
1050
  }
900
- function parseContractRefs(md) {
901
- const lines = [];
902
- for (const match of md.matchAll(CONTRACT_REF_LINE_RE)) {
903
- lines.push((match[1] ?? "").trim());
904
- }
905
- const ids = [];
906
- const invalidTokens = [];
907
- let hasNone = false;
908
- for (const line of lines) {
909
- if (line.length === 0) {
910
- invalidTokens.push("(empty)");
911
- continue;
912
- }
913
- const tokens = line.split(",").map((token) => token.trim());
914
- for (const token of tokens) {
915
- if (token.length === 0) {
916
- invalidTokens.push("(empty)");
917
- continue;
918
- }
919
- if (token === "none") {
920
- hasNone = true;
921
- continue;
922
- }
923
- if (CONTRACT_REF_ID_RE.test(token)) {
924
- ids.push(token);
925
- continue;
926
- }
927
- invalidTokens.push(token);
928
- }
929
- }
930
- return {
931
- lines,
932
- ids: unique2(ids),
933
- invalidTokens: unique2(invalidTokens),
934
- hasNone
935
- };
936
- }
937
- function unique2(values) {
938
- return Array.from(new Set(values));
939
- }
940
1051
 
941
1052
  // src/core/traceability.ts
942
1053
  import { readFile as readFile3 } from "fs/promises";
943
- import path8 from "path";
1054
+ import path9 from "path";
944
1055
 
945
1056
  // src/core/gherkin/parse.ts
946
1057
  import {
@@ -975,9 +1086,6 @@ function formatError2(error2) {
975
1086
  var SPEC_TAG_RE = /^SPEC-\d{4}$/;
976
1087
  var SC_TAG_RE = /^SC-\d{4}$/;
977
1088
  var BR_TAG_RE = /^BR-\d{4}$/;
978
- var UI_TAG_RE = /^UI-\d{4}$/;
979
- var API_TAG_RE = /^API-\d{4}$/;
980
- var DB_TAG_RE = /^DB-\d{4}$/;
981
1089
  function parseScenarioDocument(text, uri) {
982
1090
  const { gherkinDocument, errors } = parseGherkin(text, uri);
983
1091
  if (!gherkinDocument) {
@@ -1002,31 +1110,21 @@ function parseScenarioDocument(text, uri) {
1002
1110
  errors
1003
1111
  };
1004
1112
  }
1005
- function buildScenarioAtoms(document) {
1113
+ function buildScenarioAtoms(document, contractIds = []) {
1114
+ const uniqueContractIds = unique3(contractIds).sort(
1115
+ (a, b) => a.localeCompare(b)
1116
+ );
1006
1117
  return document.scenarios.map((scenario) => {
1007
1118
  const specIds = scenario.tags.filter((tag) => SPEC_TAG_RE.test(tag));
1008
1119
  const scIds = scenario.tags.filter((tag) => SC_TAG_RE.test(tag));
1009
1120
  const brIds = unique3(scenario.tags.filter((tag) => BR_TAG_RE.test(tag)));
1010
- const contractIds = /* @__PURE__ */ new Set();
1011
- scenario.tags.forEach((tag) => {
1012
- if (UI_TAG_RE.test(tag) || API_TAG_RE.test(tag) || DB_TAG_RE.test(tag)) {
1013
- contractIds.add(tag);
1014
- }
1015
- });
1016
- for (const step of scenario.steps) {
1017
- for (const text of collectStepTexts(step)) {
1018
- extractIds(text, "UI").forEach((id) => contractIds.add(id));
1019
- extractIds(text, "API").forEach((id) => contractIds.add(id));
1020
- extractIds(text, "DB").forEach((id) => contractIds.add(id));
1021
- }
1022
- }
1023
1121
  const atom = {
1024
1122
  uri: document.uri,
1025
1123
  featureName: document.featureName ?? "",
1026
1124
  scenarioName: scenario.name,
1027
1125
  kind: scenario.kind,
1028
1126
  brIds,
1029
- contractIds: Array.from(contractIds).sort()
1127
+ contractIds: uniqueContractIds
1030
1128
  };
1031
1129
  if (scenario.line !== void 0) {
1032
1130
  atom.line = scenario.line;
@@ -1079,23 +1177,6 @@ function buildScenarioNode(scenario, featureTags, ruleTags) {
1079
1177
  function collectTagNames(tags) {
1080
1178
  return tags.map((tag) => tag.name.replace(/^@/, ""));
1081
1179
  }
1082
- function collectStepTexts(step) {
1083
- const texts = [];
1084
- if (step.text) {
1085
- texts.push(step.text);
1086
- }
1087
- if (step.docString?.content) {
1088
- texts.push(step.docString.content);
1089
- }
1090
- if (step.dataTable?.rows) {
1091
- for (const row of step.dataTable.rows) {
1092
- for (const cell of row.cells) {
1093
- texts.push(cell.value);
1094
- }
1095
- }
1096
- }
1097
- return texts;
1098
- }
1099
1180
  function unique3(values) {
1100
1181
  return Array.from(new Set(values));
1101
1182
  }
@@ -1197,7 +1278,7 @@ async function collectScTestReferences(root, globs, excludeGlobs) {
1197
1278
  };
1198
1279
  }
1199
1280
  const normalizedFiles = Array.from(
1200
- new Set(files.map((file) => path8.normalize(file)))
1281
+ new Set(files.map((file) => path9.normalize(file)))
1201
1282
  );
1202
1283
  for (const file of normalizedFiles) {
1203
1284
  const text = await readFile3(file, "utf-8");
@@ -1258,11 +1339,11 @@ function formatError3(error2) {
1258
1339
 
1259
1340
  // src/core/version.ts
1260
1341
  import { readFile as readFile4 } from "fs/promises";
1261
- import path9 from "path";
1342
+ import path10 from "path";
1262
1343
  import { fileURLToPath as fileURLToPath2 } from "url";
1263
1344
  async function resolveToolVersion() {
1264
- if ("0.4.9".length > 0) {
1265
- return "0.4.9";
1345
+ if ("0.5.2".length > 0) {
1346
+ return "0.5.2";
1266
1347
  }
1267
1348
  try {
1268
1349
  const packagePath = resolvePackageJsonPath();
@@ -1277,18 +1358,18 @@ async function resolveToolVersion() {
1277
1358
  function resolvePackageJsonPath() {
1278
1359
  const base = import.meta.url;
1279
1360
  const basePath = base.startsWith("file:") ? fileURLToPath2(base) : base;
1280
- return path9.resolve(path9.dirname(basePath), "../../package.json");
1361
+ return path10.resolve(path10.dirname(basePath), "../../package.json");
1281
1362
  }
1282
1363
 
1283
1364
  // src/core/validators/contracts.ts
1284
1365
  import { readFile as readFile5 } from "fs/promises";
1285
- import path11 from "path";
1366
+ import path12 from "path";
1286
1367
 
1287
1368
  // src/core/contracts.ts
1288
- import path10 from "path";
1369
+ import path11 from "path";
1289
1370
  import { parse as parseYaml2 } from "yaml";
1290
1371
  function parseStructuredContract(file, text) {
1291
- const ext = path10.extname(file).toLowerCase();
1372
+ const ext = path11.extname(file).toLowerCase();
1292
1373
  if (ext === ".json") {
1293
1374
  return JSON.parse(text);
1294
1375
  }
@@ -1308,9 +1389,9 @@ var SQL_DANGEROUS_PATTERNS = [
1308
1389
  async function validateContracts(root, config) {
1309
1390
  const issues = [];
1310
1391
  const contractsRoot = resolvePath(root, config, "contractsDir");
1311
- issues.push(...await validateUiContracts(path11.join(contractsRoot, "ui")));
1312
- issues.push(...await validateApiContracts(path11.join(contractsRoot, "api")));
1313
- issues.push(...await validateDbContracts(path11.join(contractsRoot, "db")));
1392
+ issues.push(...await validateUiContracts(path12.join(contractsRoot, "ui")));
1393
+ issues.push(...await validateApiContracts(path12.join(contractsRoot, "api")));
1394
+ issues.push(...await validateDbContracts(path12.join(contractsRoot, "db")));
1314
1395
  const contractIndex = await buildContractIndex(root, config);
1315
1396
  issues.push(...validateDuplicateContractIds(contractIndex));
1316
1397
  return issues;
@@ -1593,7 +1674,7 @@ function issue(code, message, severity, file, rule, refs) {
1593
1674
 
1594
1675
  // src/core/validators/delta.ts
1595
1676
  import { readFile as readFile6 } from "fs/promises";
1596
- import path12 from "path";
1677
+ import path13 from "path";
1597
1678
  var SECTION_RE = /^##\s+変更区分/m;
1598
1679
  var COMPAT_LINE_RE = /^\s*-\s*\[[ xX]\]\s*Compatibility\b/m;
1599
1680
  var CHANGE_LINE_RE = /^\s*-\s*\[[ xX]\]\s*Change\/Improvement\b/m;
@@ -1607,7 +1688,7 @@ async function validateDeltas(root, config) {
1607
1688
  }
1608
1689
  const issues = [];
1609
1690
  for (const pack of packs) {
1610
- const deltaPath = path12.join(pack, "delta.md");
1691
+ const deltaPath = path13.join(pack, "delta.md");
1611
1692
  let text;
1612
1693
  try {
1613
1694
  text = await readFile6(deltaPath, "utf-8");
@@ -1683,7 +1764,7 @@ function issue2(code, message, severity, file, rule, refs) {
1683
1764
 
1684
1765
  // src/core/validators/ids.ts
1685
1766
  import { readFile as readFile7 } from "fs/promises";
1686
- import path13 from "path";
1767
+ import path14 from "path";
1687
1768
  var SC_TAG_RE3 = /^SC-\d{4}$/;
1688
1769
  async function validateDefinedIds(root, config) {
1689
1770
  const issues = [];
@@ -1749,7 +1830,7 @@ function recordId(out, id, file) {
1749
1830
  }
1750
1831
  function formatFileList(files, root) {
1751
1832
  return files.map((file) => {
1752
- const relative = path13.relative(root, file);
1833
+ const relative = path14.relative(root, file);
1753
1834
  return relative.length > 0 ? relative : file;
1754
1835
  }).join(", ");
1755
1836
  }
@@ -2186,7 +2267,7 @@ async function validateTraceability(root, config) {
2186
2267
  if (contractRefs.hasNone && contractRefs.ids.length > 0) {
2187
2268
  issues.push(
2188
2269
  issue6(
2189
- "QFAI-TRACE-021",
2270
+ "QFAI-TRACE-023",
2190
2271
  "Spec \u306E QFAI-CONTRACT-REF \u306B none \u3068\u5951\u7D04 ID \u304C\u6DF7\u5728\u3057\u3066\u3044\u307E\u3059\u3002",
2191
2272
  "error",
2192
2273
  file,
@@ -2218,7 +2299,7 @@ async function validateTraceability(root, config) {
2218
2299
  if (unknownContractIds.length > 0) {
2219
2300
  issues.push(
2220
2301
  issue6(
2221
- "QFAI-TRACE-021",
2302
+ "QFAI-TRACE-024",
2222
2303
  `Spec \u304C\u672A\u77E5\u306E\u5951\u7D04 ID \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownContractIds.join(
2223
2304
  ", "
2224
2305
  )}`,
@@ -2233,11 +2314,62 @@ async function validateTraceability(root, config) {
2233
2314
  for (const file of scenarioFiles) {
2234
2315
  const text = await readFile10(file, "utf-8");
2235
2316
  extractAllIds(text).forEach((id) => upstreamIds.add(id));
2317
+ const scenarioContractRefs = parseContractRefs(text, {
2318
+ allowCommentPrefix: true
2319
+ });
2320
+ if (scenarioContractRefs.lines.length === 0) {
2321
+ issues.push(
2322
+ issue6(
2323
+ "QFAI-TRACE-031",
2324
+ "Scenario \u306B QFAI-CONTRACT-REF \u304C\u3042\u308A\u307E\u305B\u3093\u3002",
2325
+ "error",
2326
+ file,
2327
+ "traceability.scenarioContractRefRequired"
2328
+ )
2329
+ );
2330
+ } else {
2331
+ if (scenarioContractRefs.hasNone && scenarioContractRefs.ids.length > 0) {
2332
+ issues.push(
2333
+ issue6(
2334
+ "QFAI-TRACE-033",
2335
+ "Scenario \u306E QFAI-CONTRACT-REF \u306B none \u3068\u5951\u7D04 ID \u304C\u6DF7\u5728\u3057\u3066\u3044\u307E\u3059\u3002",
2336
+ "error",
2337
+ file,
2338
+ "traceability.scenarioContractRefFormat"
2339
+ )
2340
+ );
2341
+ }
2342
+ if (scenarioContractRefs.invalidTokens.length > 0) {
2343
+ issues.push(
2344
+ issue6(
2345
+ "QFAI-TRACE-032",
2346
+ `Scenario \u306E\u5951\u7D04 ID \u304C\u4E0D\u6B63\u3067\u3059: ${scenarioContractRefs.invalidTokens.join(
2347
+ ", "
2348
+ )}`,
2349
+ "error",
2350
+ file,
2351
+ "traceability.scenarioContractRefFormat",
2352
+ scenarioContractRefs.invalidTokens
2353
+ )
2354
+ );
2355
+ }
2356
+ }
2236
2357
  const { document, errors } = parseScenarioDocument(text, file);
2237
2358
  if (!document || errors.length > 0) {
2238
2359
  continue;
2239
2360
  }
2240
- const atoms = buildScenarioAtoms(document);
2361
+ if (document.scenarios.length !== 1) {
2362
+ issues.push(
2363
+ issue6(
2364
+ "QFAI-TRACE-030",
2365
+ `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})`,
2366
+ "error",
2367
+ file,
2368
+ "traceability.scenarioOnePerFile"
2369
+ )
2370
+ );
2371
+ }
2372
+ const atoms = buildScenarioAtoms(document, scenarioContractRefs.ids);
2241
2373
  const scIdsInFile = /* @__PURE__ */ new Set();
2242
2374
  for (const [index, scenario] of document.scenarios.entries()) {
2243
2375
  const atom = atoms[index];
@@ -2382,7 +2514,7 @@ async function validateTraceability(root, config) {
2382
2514
  if (orphanBrIds.length > 0) {
2383
2515
  issues.push(
2384
2516
  issue6(
2385
- "QFAI_TRACE_BR_ORPHAN",
2517
+ "QFAI-TRACE-009",
2386
2518
  `BR \u304C SC \u306B\u7D10\u3065\u3044\u3066\u3044\u307E\u305B\u3093: ${orphanBrIds.join(", ")}`,
2387
2519
  "error",
2388
2520
  specsRoot,
@@ -2452,17 +2584,19 @@ async function validateTraceability(root, config) {
2452
2584
  );
2453
2585
  }
2454
2586
  }
2455
- if (!config.validation.traceability.allowOrphanContracts) {
2587
+ const orphanPolicy = config.validation.traceability.orphanContractsPolicy;
2588
+ if (orphanPolicy !== "allow") {
2456
2589
  if (contractIds.size > 0) {
2457
2590
  const orphanContracts = Array.from(contractIds).filter(
2458
2591
  (id) => !specContractIds.has(id)
2459
2592
  );
2460
2593
  if (orphanContracts.length > 0) {
2594
+ const severity = orphanPolicy === "warning" ? "warning" : "error";
2461
2595
  issues.push(
2462
2596
  issue6(
2463
2597
  "QFAI-TRACE-022",
2464
2598
  `\u5951\u7D04\u304C Spec \u304B\u3089\u53C2\u7167\u3055\u308C\u3066\u3044\u307E\u305B\u3093: ${orphanContracts.join(", ")}`,
2465
- "error",
2599
+ severity,
2466
2600
  specsRoot,
2467
2601
  "traceability.contractCoverage",
2468
2602
  orphanContracts
@@ -2587,16 +2721,17 @@ function countIssues(issues) {
2587
2721
  // src/core/report.ts
2588
2722
  var ID_PREFIXES2 = ["SPEC", "BR", "SC", "UI", "API", "DB"];
2589
2723
  async function createReportData(root, validation, configResult) {
2590
- const resolved = configResult ?? await loadConfig(root);
2724
+ const resolvedRoot = path15.resolve(root);
2725
+ const resolved = configResult ?? await loadConfig(resolvedRoot);
2591
2726
  const config = resolved.config;
2592
2727
  const configPath = resolved.configPath;
2593
- const specsRoot = resolvePath(root, config, "specsDir");
2594
- const contractsRoot = resolvePath(root, config, "contractsDir");
2595
- const apiRoot = path14.join(contractsRoot, "api");
2596
- const uiRoot = path14.join(contractsRoot, "ui");
2597
- const dbRoot = path14.join(contractsRoot, "db");
2598
- const srcRoot = resolvePath(root, config, "srcDir");
2599
- const testsRoot = resolvePath(root, config, "testsDir");
2728
+ const specsRoot = resolvePath(resolvedRoot, config, "specsDir");
2729
+ const contractsRoot = resolvePath(resolvedRoot, config, "contractsDir");
2730
+ const apiRoot = path15.join(contractsRoot, "api");
2731
+ const uiRoot = path15.join(contractsRoot, "ui");
2732
+ const dbRoot = path15.join(contractsRoot, "db");
2733
+ const srcRoot = resolvePath(resolvedRoot, config, "srcDir");
2734
+ const testsRoot = resolvePath(resolvedRoot, config, "testsDir");
2600
2735
  const specFiles = await collectSpecFiles(specsRoot);
2601
2736
  const scenarioFiles = await collectScenarioFiles(specsRoot);
2602
2737
  const {
@@ -2604,12 +2739,15 @@ async function createReportData(root, validation, configResult) {
2604
2739
  ui: uiFiles,
2605
2740
  db: dbFiles
2606
2741
  } = await collectContractFiles(uiRoot, apiRoot, dbRoot);
2607
- const contractIndex = await buildContractIndex(root, config);
2608
- const specContractRefs = await collectSpecContractRefs(specFiles);
2742
+ const contractIndex = await buildContractIndex(resolvedRoot, config);
2609
2743
  const contractIdList = Array.from(contractIndex.ids);
2744
+ const specContractRefs = await collectSpecContractRefs(
2745
+ specFiles,
2746
+ contractIdList
2747
+ );
2610
2748
  const referencedContracts = /* @__PURE__ */ new Set();
2611
- for (const ids of specContractRefs.specToContractIds.values()) {
2612
- ids.forEach((id) => referencedContracts.add(id));
2749
+ for (const entry of specContractRefs.specToContracts.values()) {
2750
+ entry.ids.forEach((id) => referencedContracts.add(id));
2613
2751
  }
2614
2752
  const referencedContractCount = contractIdList.filter(
2615
2753
  (id) => referencedContracts.has(id)
@@ -2618,8 +2756,8 @@ async function createReportData(root, validation, configResult) {
2618
2756
  (id) => !referencedContracts.has(id)
2619
2757
  ).length;
2620
2758
  const contractIdToSpecsRecord = mapToSortedRecord(specContractRefs.idToSpecs);
2621
- const specToContractIdsRecord = mapToSortedRecord(
2622
- specContractRefs.specToContractIds
2759
+ const specToContractsRecord = mapToSpecContractRecord(
2760
+ specContractRefs.specToContracts
2623
2761
  );
2624
2762
  const idsByPrefix = await collectIds([
2625
2763
  ...specFiles,
@@ -2637,24 +2775,26 @@ async function createReportData(root, validation, configResult) {
2637
2775
  srcRoot,
2638
2776
  testsRoot
2639
2777
  );
2640
- const scIds = await collectScIdsFromScenarioFiles(scenarioFiles);
2641
- const scRefsResult = await collectScTestReferences(
2642
- root,
2643
- config.validation.traceability.testFileGlobs,
2644
- config.validation.traceability.testFileExcludeGlobs
2778
+ const resolvedValidationRaw = validation ?? await validateProject(resolvedRoot, resolved);
2779
+ const normalizedValidation = normalizeValidationResult(
2780
+ resolvedRoot,
2781
+ resolvedValidationRaw
2645
2782
  );
2646
- const scCoverage = validation?.traceability?.sc ?? buildScCoverage(scIds, scRefsResult.refs);
2647
- const testFiles = validation?.traceability?.testFiles ?? scRefsResult.scan;
2783
+ const scCoverage = normalizedValidation.traceability.sc;
2784
+ const testFiles = normalizedValidation.traceability.testFiles;
2648
2785
  const scSources = await collectScIdSourcesFromScenarioFiles(scenarioFiles);
2649
- const scSourceRecord = mapToSortedRecord(scSources);
2650
- const resolvedValidation = validation ?? await validateProject(root, resolved);
2786
+ const scSourceRecord = mapToSortedRecord(
2787
+ normalizeScSources(resolvedRoot, scSources)
2788
+ );
2651
2789
  const version = await resolveToolVersion();
2790
+ const displayRoot = toRelativePath(resolvedRoot, resolvedRoot);
2791
+ const displayConfigPath = toRelativePath(resolvedRoot, configPath);
2652
2792
  return {
2653
2793
  tool: "qfai",
2654
2794
  version,
2655
2795
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2656
- root,
2657
- configPath,
2796
+ root: displayRoot,
2797
+ configPath: displayConfigPath,
2658
2798
  summary: {
2659
2799
  specs: specFiles.length,
2660
2800
  scenarios: scenarioFiles.length,
@@ -2663,7 +2803,7 @@ async function createReportData(root, validation, configResult) {
2663
2803
  ui: uiFiles.length,
2664
2804
  db: dbFiles.length
2665
2805
  },
2666
- counts: resolvedValidation.counts
2806
+ counts: normalizedValidation.counts
2667
2807
  },
2668
2808
  ids: {
2669
2809
  spec: idsByPrefix.SPEC,
@@ -2687,21 +2827,24 @@ async function createReportData(root, validation, configResult) {
2687
2827
  },
2688
2828
  specs: {
2689
2829
  contractRefMissing: specContractRefs.missingRefSpecs.size,
2690
- specToContractIds: specToContractIdsRecord
2830
+ missingRefSpecs: toSortedArray2(specContractRefs.missingRefSpecs),
2831
+ specToContracts: specToContractsRecord
2691
2832
  }
2692
2833
  },
2693
- issues: resolvedValidation.issues
2834
+ issues: normalizedValidation.issues
2694
2835
  };
2695
2836
  }
2696
2837
  function formatReportMarkdown(data) {
2697
2838
  const lines = [];
2698
2839
  lines.push("# QFAI Report");
2840
+ lines.push("");
2699
2841
  lines.push(`- \u751F\u6210\u65E5\u6642: ${data.generatedAt}`);
2700
2842
  lines.push(`- \u30EB\u30FC\u30C8: ${data.root}`);
2701
2843
  lines.push(`- \u8A2D\u5B9A: ${data.configPath}`);
2702
2844
  lines.push(`- \u7248: ${data.version}`);
2703
2845
  lines.push("");
2704
2846
  lines.push("## \u6982\u8981");
2847
+ lines.push("");
2705
2848
  lines.push(`- specs: ${data.summary.specs}`);
2706
2849
  lines.push(`- scenarios: ${data.summary.scenarios}`);
2707
2850
  lines.push(
@@ -2712,6 +2855,7 @@ function formatReportMarkdown(data) {
2712
2855
  );
2713
2856
  lines.push("");
2714
2857
  lines.push("## ID\u96C6\u8A08");
2858
+ lines.push("");
2715
2859
  lines.push(formatIdLine("SPEC", data.ids.spec));
2716
2860
  lines.push(formatIdLine("BR", data.ids.br));
2717
2861
  lines.push(formatIdLine("SC", data.ids.sc));
@@ -2720,12 +2864,14 @@ function formatReportMarkdown(data) {
2720
2864
  lines.push(formatIdLine("DB", data.ids.db));
2721
2865
  lines.push("");
2722
2866
  lines.push("## \u30C8\u30EC\u30FC\u30B5\u30D3\u30EA\u30C6\u30A3");
2867
+ lines.push("");
2723
2868
  lines.push(`- \u4E0A\u6D41ID\u691C\u51FA\u6570: ${data.traceability.upstreamIdsFound}`);
2724
2869
  lines.push(
2725
2870
  `- \u30B3\u30FC\u30C9/\u30C6\u30B9\u30C8\u53C2\u7167: ${data.traceability.referencedInCodeOrTests ? "\u3042\u308A" : "\u306A\u3057"}`
2726
2871
  );
2727
2872
  lines.push("");
2728
2873
  lines.push("## \u5951\u7D04\u30AB\u30D0\u30EC\u30C3\u30B8");
2874
+ lines.push("");
2729
2875
  lines.push(`- total: ${data.traceability.contracts.total}`);
2730
2876
  lines.push(`- referenced: ${data.traceability.contracts.referenced}`);
2731
2877
  lines.push(`- orphan: ${data.traceability.contracts.orphan}`);
@@ -2734,6 +2880,7 @@ function formatReportMarkdown(data) {
2734
2880
  );
2735
2881
  lines.push("");
2736
2882
  lines.push("## \u5951\u7D04\u2192Spec");
2883
+ lines.push("");
2737
2884
  const contractToSpecs = data.traceability.contracts.idToSpecs;
2738
2885
  const contractIds = Object.keys(contractToSpecs).sort(
2739
2886
  (a, b) => a.localeCompare(b)
@@ -2752,24 +2899,36 @@ function formatReportMarkdown(data) {
2752
2899
  }
2753
2900
  lines.push("");
2754
2901
  lines.push("## Spec\u2192\u5951\u7D04");
2755
- const specToContracts = data.traceability.specs.specToContractIds;
2902
+ lines.push("");
2903
+ const specToContracts = data.traceability.specs.specToContracts;
2756
2904
  const specIds = Object.keys(specToContracts).sort(
2757
2905
  (a, b) => a.localeCompare(b)
2758
2906
  );
2759
2907
  if (specIds.length === 0) {
2760
2908
  lines.push("- (none)");
2761
2909
  } else {
2762
- for (const specId of specIds) {
2763
- const contractIds2 = specToContracts[specId] ?? [];
2764
- if (contractIds2.length === 0) {
2765
- lines.push(`- ${specId}: (none)`);
2766
- } else {
2767
- lines.push(`- ${specId}: ${contractIds2.join(", ")}`);
2768
- }
2910
+ const rows = specIds.map((specId) => {
2911
+ const entry = specToContracts[specId];
2912
+ const contracts = entry?.status === "missing" ? "(missing)" : entry && entry.ids.length > 0 ? entry.ids.join(", ") : "(none)";
2913
+ const status = entry?.status ?? "missing";
2914
+ return [specId, status, contracts];
2915
+ });
2916
+ lines.push(...formatMarkdownTable(["Spec", "Status", "Contracts"], rows));
2917
+ }
2918
+ lines.push("");
2919
+ lines.push("## Spec\u3067 contract-ref \u672A\u5BA3\u8A00");
2920
+ lines.push("");
2921
+ const missingRefSpecs = data.traceability.specs.missingRefSpecs;
2922
+ if (missingRefSpecs.length === 0) {
2923
+ lines.push("- (none)");
2924
+ } else {
2925
+ for (const specId of missingRefSpecs) {
2926
+ lines.push(`- ${specId}`);
2769
2927
  }
2770
2928
  }
2771
2929
  lines.push("");
2772
2930
  lines.push("## SC\u30AB\u30D0\u30EC\u30C3\u30B8");
2931
+ lines.push("");
2773
2932
  lines.push(`- total: ${data.traceability.sc.total}`);
2774
2933
  lines.push(`- covered: ${data.traceability.sc.covered}`);
2775
2934
  lines.push(`- missing: ${data.traceability.sc.missing}`);
@@ -2799,6 +2958,7 @@ function formatReportMarkdown(data) {
2799
2958
  }
2800
2959
  lines.push("");
2801
2960
  lines.push("## SC\u2192\u53C2\u7167\u30C6\u30B9\u30C8");
2961
+ lines.push("");
2802
2962
  const scRefs = data.traceability.sc.refs;
2803
2963
  const scIds = Object.keys(scRefs).sort((a, b) => a.localeCompare(b));
2804
2964
  if (scIds.length === 0) {
@@ -2815,6 +2975,7 @@ function formatReportMarkdown(data) {
2815
2975
  }
2816
2976
  lines.push("");
2817
2977
  lines.push("## Spec:SC=1:1 \u9055\u53CD");
2978
+ lines.push("");
2818
2979
  const specScIssues = data.issues.filter(
2819
2980
  (item) => item.code === "QFAI-TRACE-012"
2820
2981
  );
@@ -2829,6 +2990,7 @@ function formatReportMarkdown(data) {
2829
2990
  }
2830
2991
  lines.push("");
2831
2992
  lines.push("## Hotspots");
2993
+ lines.push("");
2832
2994
  const hotspots = buildHotspots(data.issues);
2833
2995
  if (hotspots.length === 0) {
2834
2996
  lines.push("- (none)");
@@ -2841,6 +3003,7 @@ function formatReportMarkdown(data) {
2841
3003
  }
2842
3004
  lines.push("");
2843
3005
  lines.push("## \u30C8\u30EC\u30FC\u30B5\u30D3\u30EA\u30C6\u30A3\uFF08\u691C\u8A3C\uFF09");
3006
+ lines.push("");
2844
3007
  const traceIssues = data.issues.filter(
2845
3008
  (item) => item.rule?.startsWith("traceability.") || item.code.startsWith("QFAI_TRACE") || item.code.startsWith("QFAI-TRACE-")
2846
3009
  );
@@ -2856,6 +3019,7 @@ function formatReportMarkdown(data) {
2856
3019
  }
2857
3020
  lines.push("");
2858
3021
  lines.push("## \u691C\u8A3C\u7D50\u679C");
3022
+ lines.push("");
2859
3023
  if (data.issues.length === 0) {
2860
3024
  lines.push("- (none)");
2861
3025
  } else {
@@ -2872,29 +3036,41 @@ function formatReportMarkdown(data) {
2872
3036
  function formatReportJson(data) {
2873
3037
  return JSON.stringify(data, null, 2);
2874
3038
  }
2875
- async function collectSpecContractRefs(specFiles) {
2876
- const specToContractIds = /* @__PURE__ */ new Map();
3039
+ async function collectSpecContractRefs(specFiles, contractIdList) {
3040
+ const specToContracts = /* @__PURE__ */ new Map();
2877
3041
  const idToSpecs = /* @__PURE__ */ new Map();
2878
3042
  const missingRefSpecs = /* @__PURE__ */ new Set();
3043
+ for (const contractId of contractIdList) {
3044
+ idToSpecs.set(contractId, /* @__PURE__ */ new Set());
3045
+ }
2879
3046
  for (const file of specFiles) {
2880
3047
  const text = await readFile11(file, "utf-8");
2881
3048
  const parsed = parseSpec(text, file);
2882
- const specKey = parsed.specId ?? file;
3049
+ const specKey = parsed.specId;
3050
+ if (!specKey) {
3051
+ continue;
3052
+ }
2883
3053
  const refs = parsed.contractRefs;
2884
3054
  if (refs.lines.length === 0) {
2885
3055
  missingRefSpecs.add(specKey);
3056
+ specToContracts.set(specKey, { status: "missing", ids: /* @__PURE__ */ new Set() });
3057
+ continue;
2886
3058
  }
2887
- const currentContracts = specToContractIds.get(specKey) ?? /* @__PURE__ */ new Set();
3059
+ const current = specToContracts.get(specKey) ?? {
3060
+ status: "declared",
3061
+ ids: /* @__PURE__ */ new Set()
3062
+ };
2888
3063
  for (const id of refs.ids) {
2889
- currentContracts.add(id);
2890
- const specs = idToSpecs.get(id) ?? /* @__PURE__ */ new Set();
2891
- specs.add(specKey);
2892
- idToSpecs.set(id, specs);
3064
+ current.ids.add(id);
3065
+ const specs = idToSpecs.get(id);
3066
+ if (specs) {
3067
+ specs.add(specKey);
3068
+ }
2893
3069
  }
2894
- specToContractIds.set(specKey, currentContracts);
3070
+ specToContracts.set(specKey, current);
2895
3071
  }
2896
3072
  return {
2897
- specToContractIds,
3073
+ specToContracts,
2898
3074
  idToSpecs,
2899
3075
  missingRefSpecs
2900
3076
  };
@@ -2971,6 +3147,20 @@ function formatList(values) {
2971
3147
  }
2972
3148
  return values.join(", ");
2973
3149
  }
3150
+ function formatMarkdownTable(headers, rows) {
3151
+ const widths = headers.map((header, index) => {
3152
+ const candidates = rows.map((row) => row[index] ?? "");
3153
+ return Math.max(header.length, ...candidates.map((item) => item.length));
3154
+ });
3155
+ const formatRow = (cells) => {
3156
+ const padded = cells.map(
3157
+ (cell, index) => (cell ?? "").padEnd(widths[index] ?? 0)
3158
+ );
3159
+ return `| ${padded.join(" | ")} |`;
3160
+ };
3161
+ const separator = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`;
3162
+ return [formatRow(headers), separator, ...rows.map(formatRow)];
3163
+ }
2974
3164
  function toSortedArray2(values) {
2975
3165
  return Array.from(values).sort((a, b) => a.localeCompare(b));
2976
3166
  }
@@ -2981,6 +3171,27 @@ function mapToSortedRecord(values) {
2981
3171
  }
2982
3172
  return record2;
2983
3173
  }
3174
+ function mapToSpecContractRecord(values) {
3175
+ const record2 = {};
3176
+ for (const [key, entry] of values.entries()) {
3177
+ record2[key] = {
3178
+ status: entry.status,
3179
+ ids: toSortedArray2(entry.ids)
3180
+ };
3181
+ }
3182
+ return record2;
3183
+ }
3184
+ function normalizeScSources(root, sources) {
3185
+ const normalized = /* @__PURE__ */ new Map();
3186
+ for (const [id, files] of sources.entries()) {
3187
+ const mapped = /* @__PURE__ */ new Set();
3188
+ for (const file of files) {
3189
+ mapped.add(toRelativePath(root, file));
3190
+ }
3191
+ normalized.set(id, mapped);
3192
+ }
3193
+ return normalized;
3194
+ }
2984
3195
  function buildHotspots(issues) {
2985
3196
  const map = /* @__PURE__ */ new Map();
2986
3197
  for (const issue7 of issues) {
@@ -3005,38 +3216,53 @@ function buildHotspots(issues) {
3005
3216
 
3006
3217
  // src/cli/commands/report.ts
3007
3218
  async function runReport(options) {
3008
- const root = path15.resolve(options.root);
3219
+ const root = path16.resolve(options.root);
3009
3220
  const configResult = await loadConfig(root);
3010
- const input = configResult.config.output.validateJsonPath;
3011
- const inputPath = path15.isAbsolute(input) ? input : path15.resolve(root, input);
3012
3221
  let validation;
3013
- try {
3014
- validation = await readValidationResult(inputPath);
3015
- } catch (err) {
3016
- if (isMissingFileError5(err)) {
3017
- error(
3018
- [
3019
- `qfai report: \u5165\u529B\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${inputPath}`,
3020
- "",
3021
- "\u307E\u305A qfai validate \u3092\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u4F8B:",
3022
- " qfai validate",
3023
- "\uFF08\u30C7\u30D5\u30A9\u30EB\u30C8\u306E\u51FA\u529B\u5148: .qfai/out/validate.json\uFF09",
3024
- "",
3025
- "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"
3026
- ].join("\n")
3027
- );
3028
- process.exitCode = 2;
3029
- return;
3222
+ if (options.runValidate) {
3223
+ if (options.inputPath) {
3224
+ warn("report: --run-validate \u304C\u6307\u5B9A\u3055\u308C\u305F\u305F\u3081 --in \u306F\u7121\u8996\u3057\u307E\u3059\u3002");
3225
+ }
3226
+ const result = await validateProject(root, configResult);
3227
+ const normalized = normalizeValidationResult(root, result);
3228
+ await writeValidationResult(
3229
+ root,
3230
+ configResult.config.output.validateJsonPath,
3231
+ normalized
3232
+ );
3233
+ validation = normalized;
3234
+ } else {
3235
+ const input = options.inputPath ?? configResult.config.output.validateJsonPath;
3236
+ const inputPath = path16.isAbsolute(input) ? input : path16.resolve(root, input);
3237
+ try {
3238
+ validation = await readValidationResult(inputPath);
3239
+ } catch (err) {
3240
+ if (isMissingFileError5(err)) {
3241
+ error(
3242
+ [
3243
+ `qfai report: \u5165\u529B\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${inputPath}`,
3244
+ "",
3245
+ "\u307E\u305A qfai validate \u3092\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u4F8B:",
3246
+ " qfai validate",
3247
+ "\uFF08\u30C7\u30D5\u30A9\u30EB\u30C8\u306E\u51FA\u529B\u5148: .qfai/out/validate.json\uFF09",
3248
+ "",
3249
+ "\u307E\u305F\u306F report \u306B --run-validate \u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
3250
+ "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"
3251
+ ].join("\n")
3252
+ );
3253
+ process.exitCode = 2;
3254
+ return;
3255
+ }
3256
+ throw err;
3030
3257
  }
3031
- throw err;
3032
3258
  }
3033
3259
  const data = await createReportData(root, validation, configResult);
3034
3260
  const output = options.format === "json" ? formatReportJson(data) : formatReportMarkdown(data);
3035
3261
  const outRoot = resolvePath(root, configResult.config, "outDir");
3036
- const defaultOut = options.format === "json" ? path15.join(outRoot, "report.json") : path15.join(outRoot, "report.md");
3262
+ const defaultOut = options.format === "json" ? path16.join(outRoot, "report.json") : path16.join(outRoot, "report.md");
3037
3263
  const out = options.outPath ?? defaultOut;
3038
- const outPath = path15.isAbsolute(out) ? out : path15.resolve(root, out);
3039
- await mkdir2(path15.dirname(outPath), { recursive: true });
3264
+ const outPath = path16.isAbsolute(out) ? out : path16.resolve(root, out);
3265
+ await mkdir2(path16.dirname(outPath), { recursive: true });
3040
3266
  await writeFile(outPath, `${output}
3041
3267
  `, "utf-8");
3042
3268
  info(
@@ -3100,10 +3326,16 @@ function isMissingFileError5(error2) {
3100
3326
  const record2 = error2;
3101
3327
  return record2.code === "ENOENT";
3102
3328
  }
3329
+ async function writeValidationResult(root, outputPath, result) {
3330
+ const abs = path16.isAbsolute(outputPath) ? outputPath : path16.resolve(root, outputPath);
3331
+ await mkdir2(path16.dirname(abs), { recursive: true });
3332
+ await writeFile(abs, `${JSON.stringify(result, null, 2)}
3333
+ `, "utf-8");
3334
+ }
3103
3335
 
3104
3336
  // src/cli/commands/validate.ts
3105
3337
  import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
3106
- import path16 from "path";
3338
+ import path17 from "path";
3107
3339
 
3108
3340
  // src/cli/lib/failOn.ts
3109
3341
  function shouldFail(result, failOn) {
@@ -3118,19 +3350,24 @@ function shouldFail(result, failOn) {
3118
3350
 
3119
3351
  // src/cli/commands/validate.ts
3120
3352
  async function runValidate(options) {
3121
- const root = path16.resolve(options.root);
3353
+ const root = path17.resolve(options.root);
3122
3354
  const configResult = await loadConfig(root);
3123
3355
  const result = await validateProject(root, configResult);
3356
+ const normalized = normalizeValidationResult(root, result);
3124
3357
  const format = options.format ?? "text";
3125
3358
  if (format === "text") {
3126
- emitText(result);
3359
+ emitText(normalized);
3127
3360
  }
3128
3361
  if (format === "github") {
3129
- result.issues.forEach(emitGitHub);
3362
+ const jsonPath = resolveJsonPath(
3363
+ root,
3364
+ configResult.config.output.validateJsonPath
3365
+ );
3366
+ emitGitHubOutput(normalized, root, jsonPath);
3130
3367
  }
3131
- await emitJson(result, root, configResult.config.output.validateJsonPath);
3368
+ await emitJson(normalized, root, configResult.config.output.validateJsonPath);
3132
3369
  const failOn = resolveFailOn(options, configResult.config.validation.failOn);
3133
- return shouldFail(result, failOn) ? 1 : 0;
3370
+ return shouldFail(normalized, failOn) ? 1 : 0;
3134
3371
  }
3135
3372
  function resolveFailOn(options, fallback) {
3136
3373
  if (options.failOn) {
@@ -3155,6 +3392,22 @@ function emitText(result) {
3155
3392
  `
3156
3393
  );
3157
3394
  }
3395
+ function emitGitHubOutput(result, root, jsonPath) {
3396
+ const deduped = dedupeIssues(result.issues);
3397
+ const omitted = Math.max(deduped.length - GITHUB_ANNOTATION_LIMIT, 0);
3398
+ const dropped = Math.max(result.issues.length - deduped.length, 0);
3399
+ emitGitHubSummary(result, {
3400
+ total: deduped.length,
3401
+ omitted,
3402
+ dropped,
3403
+ jsonPath,
3404
+ root
3405
+ });
3406
+ const issues = deduped.slice(0, GITHUB_ANNOTATION_LIMIT);
3407
+ for (const issue7 of issues) {
3408
+ emitGitHub(issue7);
3409
+ }
3410
+ }
3158
3411
  function emitGitHub(issue7) {
3159
3412
  const level = issue7.severity === "error" ? "error" : issue7.severity === "warning" ? "warning" : "notice";
3160
3413
  const file = issue7.file ? `file=${issue7.file}` : "";
@@ -3166,22 +3419,74 @@ function emitGitHub(issue7) {
3166
3419
  `
3167
3420
  );
3168
3421
  }
3422
+ function emitGitHubSummary(result, options) {
3423
+ const summary = [
3424
+ "qfai validate summary:",
3425
+ `error=${result.counts.error}`,
3426
+ `warning=${result.counts.warning}`,
3427
+ `info=${result.counts.info}`,
3428
+ `annotations=${Math.min(options.total, GITHUB_ANNOTATION_LIMIT)}/${options.total}`
3429
+ ].join(" ");
3430
+ process.stdout.write(`${summary}
3431
+ `);
3432
+ if (options.dropped > 0 || options.omitted > 0) {
3433
+ const details = [
3434
+ "qfai validate note:",
3435
+ options.dropped > 0 ? `\u91CD\u8907\u9664\u5916=${options.dropped}` : null,
3436
+ options.omitted > 0 ? `\u4E0A\u9650\u7701\u7565=${options.omitted}` : null
3437
+ ].filter(Boolean).join(" ");
3438
+ process.stdout.write(`${details}
3439
+ `);
3440
+ }
3441
+ const relative = toRelativePath(options.root, options.jsonPath);
3442
+ process.stdout.write(
3443
+ `qfai validate note: \u8A73\u7D30\u306F ${relative} \u307E\u305F\u306F --format text \u3092\u53C2\u7167\u3057\u3066\u304F\u3060\u3055\u3044\u3002
3444
+ `
3445
+ );
3446
+ }
3447
+ function dedupeIssues(issues) {
3448
+ const seen = /* @__PURE__ */ new Set();
3449
+ const deduped = [];
3450
+ for (const issue7 of issues) {
3451
+ const key = issueKey(issue7);
3452
+ if (seen.has(key)) {
3453
+ continue;
3454
+ }
3455
+ seen.add(key);
3456
+ deduped.push(issue7);
3457
+ }
3458
+ return deduped;
3459
+ }
3460
+ function issueKey(issue7) {
3461
+ const file = issue7.file ?? "";
3462
+ const line = issue7.loc?.line ?? "";
3463
+ const column = issue7.loc?.column ?? "";
3464
+ return [issue7.code, issue7.severity, issue7.message, file, line, column].join(
3465
+ "|"
3466
+ );
3467
+ }
3169
3468
  async function emitJson(result, root, jsonPath) {
3170
- const abs = path16.isAbsolute(jsonPath) ? jsonPath : path16.resolve(root, jsonPath);
3171
- await mkdir3(path16.dirname(abs), { recursive: true });
3469
+ const abs = resolveJsonPath(root, jsonPath);
3470
+ await mkdir3(path17.dirname(abs), { recursive: true });
3172
3471
  await writeFile2(abs, `${JSON.stringify(result, null, 2)}
3173
3472
  `, "utf-8");
3174
3473
  }
3474
+ function resolveJsonPath(root, jsonPath) {
3475
+ return path17.isAbsolute(jsonPath) ? jsonPath : path17.resolve(root, jsonPath);
3476
+ }
3477
+ var GITHUB_ANNOTATION_LIMIT = 100;
3175
3478
 
3176
3479
  // src/cli/lib/args.ts
3177
3480
  function parseArgs(argv, cwd) {
3178
3481
  const options = {
3179
3482
  root: cwd,
3483
+ rootExplicit: false,
3180
3484
  dir: cwd,
3181
3485
  force: false,
3182
3486
  yes: false,
3183
3487
  dryRun: false,
3184
3488
  reportFormat: "md",
3489
+ reportRunValidate: false,
3185
3490
  validateFormat: "text",
3186
3491
  strict: false,
3187
3492
  help: false
@@ -3197,6 +3502,7 @@ function parseArgs(argv, cwd) {
3197
3502
  switch (arg) {
3198
3503
  case "--root":
3199
3504
  options.root = args[i + 1] ?? options.root;
3505
+ options.rootExplicit = true;
3200
3506
  i += 1;
3201
3507
  break;
3202
3508
  case "--dir":
@@ -3238,6 +3544,18 @@ function parseArgs(argv, cwd) {
3238
3544
  }
3239
3545
  i += 1;
3240
3546
  break;
3547
+ case "--in":
3548
+ {
3549
+ const next = args[i + 1];
3550
+ if (next) {
3551
+ options.reportIn = next;
3552
+ }
3553
+ }
3554
+ i += 1;
3555
+ break;
3556
+ case "--run-validate":
3557
+ options.reportRunValidate = true;
3558
+ break;
3241
3559
  case "--help":
3242
3560
  case "-h":
3243
3561
  options.help = true;
@@ -3289,19 +3607,27 @@ async function run(argv, cwd) {
3289
3607
  });
3290
3608
  return;
3291
3609
  case "validate":
3292
- process.exitCode = await runValidate({
3293
- root: options.root,
3294
- strict: options.strict,
3295
- format: options.validateFormat,
3296
- ...options.failOn !== void 0 ? { failOn: options.failOn } : {}
3297
- });
3610
+ {
3611
+ const resolvedRoot = await resolveRoot(options);
3612
+ process.exitCode = await runValidate({
3613
+ root: resolvedRoot,
3614
+ strict: options.strict,
3615
+ format: options.validateFormat,
3616
+ ...options.failOn !== void 0 ? { failOn: options.failOn } : {}
3617
+ });
3618
+ }
3298
3619
  return;
3299
3620
  case "report":
3300
- await runReport({
3301
- root: options.root,
3302
- format: options.reportFormat,
3303
- ...options.reportOut !== void 0 ? { outPath: options.reportOut } : {}
3304
- });
3621
+ {
3622
+ const resolvedRoot = await resolveRoot(options);
3623
+ await runReport({
3624
+ root: resolvedRoot,
3625
+ format: options.reportFormat,
3626
+ ...options.reportOut !== void 0 ? { outPath: options.reportOut } : {},
3627
+ ...options.reportIn !== void 0 ? { inputPath: options.reportIn } : {},
3628
+ ...options.reportRunValidate ? { runValidate: true } : {}
3629
+ });
3630
+ }
3305
3631
  return;
3306
3632
  default:
3307
3633
  error(`Unknown command: ${command}`);
@@ -3328,9 +3654,23 @@ Options:
3328
3654
  --strict validate: warning \u4EE5\u4E0A\u3067 exit 1
3329
3655
  --fail-on <error|warning|never> validate: \u5931\u6557\u6761\u4EF6
3330
3656
  --out <path> report: \u51FA\u529B\u5148
3657
+ --in <path> report: validate.json \u306E\u5165\u529B\u5148\uFF08config\u3088\u308A\u512A\u5148\uFF09
3658
+ --run-validate report: validate \u3092\u5B9F\u884C\u3057\u3066\u304B\u3089 report \u3092\u751F\u6210
3331
3659
  -h, --help \u30D8\u30EB\u30D7\u8868\u793A
3332
3660
  `;
3333
3661
  }
3662
+ async function resolveRoot(options) {
3663
+ if (options.rootExplicit) {
3664
+ return options.root;
3665
+ }
3666
+ const search = await findConfigRoot(options.root);
3667
+ if (!search.found) {
3668
+ warn(
3669
+ `qfai: qfai.config.yaml \u304C\u898B\u3064\u304B\u3089\u306A\u3044\u305F\u3081 defaultConfig \u3092\u4F7F\u7528\u3057\u307E\u3059 (root=${search.root})`
3670
+ );
3671
+ }
3672
+ return search.root;
3673
+ }
3334
3674
 
3335
3675
  // src/cli/index.ts
3336
3676
  run(process.argv.slice(2), process.cwd()).catch((err) => {