qfai 0.5.0 → 0.6.0

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.
@@ -1,165 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/cli/commands/init.ts
4
- import path3 from "path";
5
-
6
- // src/cli/lib/fs.ts
7
- import { access, copyFile, mkdir, readdir } from "fs/promises";
8
- import path from "path";
9
- async function copyTemplateTree(sourceRoot, destRoot, options) {
10
- const files = await collectTemplateFiles(sourceRoot);
11
- return copyFiles(files, sourceRoot, destRoot, options);
12
- }
13
- async function copyFiles(files, sourceRoot, destRoot, options) {
14
- const copied = [];
15
- const skipped = [];
16
- const conflicts = [];
17
- if (!options.force) {
18
- for (const file of files) {
19
- const relative = path.relative(sourceRoot, file);
20
- const dest = path.join(destRoot, relative);
21
- if (!await shouldWrite(dest, options.force)) {
22
- conflicts.push(dest);
23
- }
24
- }
25
- if (conflicts.length > 0) {
26
- throw new Error(formatConflictMessage(conflicts));
27
- }
28
- }
29
- for (const file of files) {
30
- const relative = path.relative(sourceRoot, file);
31
- const dest = path.join(destRoot, relative);
32
- if (!await shouldWrite(dest, options.force)) {
33
- skipped.push(dest);
34
- continue;
35
- }
36
- if (!options.dryRun) {
37
- await mkdir(path.dirname(dest), { recursive: true });
38
- await copyFile(file, dest);
39
- }
40
- copied.push(dest);
41
- }
42
- return { copied, skipped };
43
- }
44
- function formatConflictMessage(conflicts) {
45
- return [
46
- "\u65E2\u5B58\u30D5\u30A1\u30A4\u30EB\u3068\u885D\u7A81\u3057\u307E\u3057\u305F\u3002\u5B89\u5168\u306E\u305F\u3081\u505C\u6B62\u3057\u307E\u3059\u3002",
47
- "",
48
- "\u885D\u7A81\u30D5\u30A1\u30A4\u30EB:",
49
- ...conflicts.map((conflict) => `- ${conflict}`),
50
- "",
51
- "\u4E0A\u66F8\u304D\u3057\u3066\u7D9A\u884C\u3059\u308B\u5834\u5408\u306F --force \u3092\u4ED8\u3051\u3066\u518D\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002"
52
- ].join("\n");
53
- }
54
- async function collectTemplateFiles(root) {
55
- const entries = [];
56
- if (!await exists(root)) {
57
- return entries;
58
- }
59
- const items = await readdir(root, { withFileTypes: true });
60
- for (const item of items) {
61
- const fullPath = path.join(root, item.name);
62
- if (item.isDirectory()) {
63
- const nested = await collectTemplateFiles(fullPath);
64
- entries.push(...nested);
65
- continue;
66
- }
67
- if (item.isFile()) {
68
- entries.push(fullPath);
69
- }
70
- }
71
- return entries;
72
- }
73
- async function shouldWrite(target, force) {
74
- if (force) {
75
- return true;
76
- }
77
- return !await exists(target);
78
- }
79
- async function exists(target) {
80
- try {
81
- await access(target);
82
- return true;
83
- } catch {
84
- return false;
85
- }
86
- }
87
-
88
- // src/cli/lib/assets.ts
89
- import { existsSync } from "fs";
90
- import path2 from "path";
91
- import { fileURLToPath } from "url";
92
- function getInitAssetsDir() {
93
- const base = import.meta.url;
94
- const basePath = base.startsWith("file:") ? fileURLToPath(base) : base;
95
- const baseDir = path2.dirname(basePath);
96
- const candidates = [
97
- path2.resolve(baseDir, "../../../assets/init"),
98
- path2.resolve(baseDir, "../../assets/init")
99
- ];
100
- for (const candidate of candidates) {
101
- if (existsSync(candidate)) {
102
- return candidate;
103
- }
104
- }
105
- throw new Error(
106
- [
107
- "init \u7528\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002Template assets not found.",
108
- "\u78BA\u8A8D\u3057\u305F\u30D1\u30B9 / Checked paths:",
109
- ...candidates.map((candidate) => `- ${candidate}`)
110
- ].join("\n")
111
- );
112
- }
113
-
114
- // src/cli/lib/logger.ts
115
- function info(message) {
116
- process.stdout.write(`${message}
117
- `);
118
- }
119
- function error(message) {
120
- process.stderr.write(`${message}
121
- `);
122
- }
123
-
124
- // src/cli/commands/init.ts
125
- async function runInit(options) {
126
- const assetsRoot = getInitAssetsDir();
127
- const rootAssets = path3.join(assetsRoot, "root");
128
- const qfaiAssets = path3.join(assetsRoot, ".qfai");
129
- const destRoot = path3.resolve(options.dir);
130
- const destQfai = path3.join(destRoot, ".qfai");
131
- const rootResult = await copyTemplateTree(rootAssets, destRoot, {
132
- force: options.force,
133
- dryRun: options.dryRun
134
- });
135
- const qfaiResult = await copyTemplateTree(qfaiAssets, destQfai, {
136
- force: options.force,
137
- dryRun: options.dryRun
138
- });
139
- report(
140
- [...rootResult.copied, ...qfaiResult.copied],
141
- [...rootResult.skipped, ...qfaiResult.skipped],
142
- options.dryRun,
143
- "init"
144
- );
145
- }
146
- function report(copied, skipped, dryRun, label) {
147
- info(`qfai ${label}: ${dryRun ? "dry-run" : "done"}`);
148
- if (copied.length > 0) {
149
- info(` created: ${copied.length}`);
150
- }
151
- if (skipped.length > 0) {
152
- info(` skipped: ${skipped.length}`);
153
- }
154
- }
3
+ // src/cli/commands/doctor.ts
4
+ import { mkdir, writeFile } from "fs/promises";
5
+ import path8 from "path";
155
6
 
156
- // src/cli/commands/report.ts
157
- import { mkdir as mkdir2, readFile as readFile12, writeFile } from "fs/promises";
158
- import path15 from "path";
7
+ // src/core/doctor.ts
8
+ import { access as access4 } from "fs/promises";
9
+ import path7 from "path";
159
10
 
160
11
  // src/core/config.ts
161
- import { readFile } from "fs/promises";
162
- import path4 from "path";
12
+ import { access, readFile } from "fs/promises";
13
+ import path from "path";
163
14
  import { parse as parseYaml } from "yaml";
164
15
  var defaultConfig = {
165
16
  paths: {
@@ -190,7 +41,7 @@ var defaultConfig = {
190
41
  testFileGlobs: [],
191
42
  testFileExcludeGlobs: [],
192
43
  scNoTestSeverity: "error",
193
- allowOrphanContracts: false,
44
+ orphanContractsPolicy: "error",
194
45
  unknownContractIdSeverity: "error"
195
46
  }
196
47
  },
@@ -199,7 +50,27 @@ var defaultConfig = {
199
50
  }
200
51
  };
201
52
  function getConfigPath(root) {
202
- return path4.join(root, "qfai.config.yaml");
53
+ return path.join(root, "qfai.config.yaml");
54
+ }
55
+ async function findConfigRoot(startDir) {
56
+ const resolvedStart = path.resolve(startDir);
57
+ let current = resolvedStart;
58
+ while (true) {
59
+ const configPath = getConfigPath(current);
60
+ if (await exists(configPath)) {
61
+ return { root: current, configPath, found: true };
62
+ }
63
+ const parent = path.dirname(current);
64
+ if (parent === current) {
65
+ break;
66
+ }
67
+ current = parent;
68
+ }
69
+ return {
70
+ root: resolvedStart,
71
+ configPath: getConfigPath(resolvedStart),
72
+ found: false
73
+ };
203
74
  }
204
75
  async function loadConfig(root) {
205
76
  const configPath = getConfigPath(root);
@@ -219,7 +90,7 @@ async function loadConfig(root) {
219
90
  return { config: normalized, issues, configPath };
220
91
  }
221
92
  function resolvePath(root, config, key) {
222
- return path4.resolve(root, config.paths[key]);
93
+ return path.resolve(root, config.paths[key]);
223
94
  }
224
95
  function normalizeConfig(raw, configPath, issues) {
225
96
  if (!isRecord(raw)) {
@@ -390,10 +261,10 @@ function normalizeValidation(raw, configPath, issues) {
390
261
  configPath,
391
262
  issues
392
263
  ),
393
- allowOrphanContracts: readBoolean(
394
- traceabilityRaw?.allowOrphanContracts,
395
- base.traceability.allowOrphanContracts,
396
- "validation.traceability.allowOrphanContracts",
264
+ orphanContractsPolicy: readOrphanContractsPolicy(
265
+ traceabilityRaw?.orphanContractsPolicy,
266
+ base.traceability.orphanContractsPolicy,
267
+ "validation.traceability.orphanContractsPolicy",
397
268
  configPath,
398
269
  issues
399
270
  ),
@@ -489,6 +360,20 @@ function readTraceabilitySeverity(value, fallback, label, configPath, issues) {
489
360
  }
490
361
  return fallback;
491
362
  }
363
+ function readOrphanContractsPolicy(value, fallback, label, configPath, issues) {
364
+ if (value === "error" || value === "warning" || value === "allow") {
365
+ return value;
366
+ }
367
+ if (value !== void 0) {
368
+ issues.push(
369
+ configIssue(
370
+ configPath,
371
+ `${label} \u306F error|warning|allow \u306E\u3044\u305A\u308C\u304B\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002`
372
+ )
373
+ );
374
+ }
375
+ return fallback;
376
+ }
492
377
  function configIssue(file, message) {
493
378
  return {
494
379
  code: "QFAI_CONFIG_INVALID",
@@ -504,6 +389,14 @@ function isMissingFile(error2) {
504
389
  }
505
390
  return false;
506
391
  }
392
+ async function exists(target) {
393
+ try {
394
+ await access(target);
395
+ return true;
396
+ } catch {
397
+ return false;
398
+ }
399
+ }
507
400
  function formatError(error2) {
508
401
  if (error2 instanceof Error) {
509
402
  return error2.message;
@@ -514,20 +407,12 @@ function isRecord(value) {
514
407
  return value !== null && typeof value === "object" && !Array.isArray(value);
515
408
  }
516
409
 
517
- // src/core/report.ts
518
- import { readFile as readFile11 } from "fs/promises";
519
- import path14 from "path";
520
-
521
- // src/core/contractIndex.ts
522
- import { readFile as readFile2 } from "fs/promises";
523
- import path7 from "path";
524
-
525
410
  // src/core/discovery.ts
526
411
  import { access as access3 } from "fs/promises";
527
412
 
528
413
  // src/core/fs.ts
529
- import { access as access2, readdir as readdir2 } from "fs/promises";
530
- import path5 from "path";
414
+ import { access as access2, readdir } from "fs/promises";
415
+ import path2 from "path";
531
416
  import fg from "fast-glob";
532
417
  var DEFAULT_IGNORE_DIRS = /* @__PURE__ */ new Set([
533
418
  "node_modules",
@@ -563,9 +448,9 @@ async function collectFilesByGlobs(root, options) {
563
448
  });
564
449
  }
565
450
  async function walk(base, current, ignoreDirs, extensions, out) {
566
- const items = await readdir2(current, { withFileTypes: true });
451
+ const items = await readdir(current, { withFileTypes: true });
567
452
  for (const item of items) {
568
- const fullPath = path5.join(current, item.name);
453
+ const fullPath = path2.join(current, item.name);
569
454
  if (item.isDirectory()) {
570
455
  if (ignoreDirs.has(item.name)) {
571
456
  continue;
@@ -575,7 +460,7 @@ async function walk(base, current, ignoreDirs, extensions, out) {
575
460
  }
576
461
  if (item.isFile()) {
577
462
  if (extensions.length > 0) {
578
- const ext = path5.extname(item.name).toLowerCase();
463
+ const ext = path2.extname(item.name).toLowerCase();
579
464
  if (!extensions.includes(ext)) {
580
465
  continue;
581
466
  }
@@ -594,23 +479,23 @@ async function exists2(target) {
594
479
  }
595
480
 
596
481
  // src/core/specLayout.ts
597
- import { readdir as readdir3 } from "fs/promises";
598
- import path6 from "path";
482
+ import { readdir as readdir2 } from "fs/promises";
483
+ import path3 from "path";
599
484
  var SPEC_DIR_RE = /^spec-\d{4}$/;
600
485
  async function collectSpecEntries(specsRoot) {
601
486
  const dirs = await listSpecDirs(specsRoot);
602
487
  const entries = dirs.map((dir) => ({
603
488
  dir,
604
- specPath: path6.join(dir, "spec.md"),
605
- deltaPath: path6.join(dir, "delta.md"),
606
- scenarioPath: path6.join(dir, "scenario.md")
489
+ specPath: path3.join(dir, "spec.md"),
490
+ deltaPath: path3.join(dir, "delta.md"),
491
+ scenarioPath: path3.join(dir, "scenario.md")
607
492
  }));
608
493
  return entries.sort((a, b) => a.dir.localeCompare(b.dir));
609
494
  }
610
495
  async function listSpecDirs(specsRoot) {
611
496
  try {
612
- 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));
497
+ const items = await readdir2(specsRoot, { withFileTypes: true });
498
+ return items.filter((item) => item.isDirectory()).map((item) => item.name).filter((name) => SPEC_DIR_RE.test(name.toLowerCase())).map((name) => path3.join(specsRoot, name));
614
499
  } catch (error2) {
615
500
  if (isMissingFileError(error2)) {
616
501
  return [];
@@ -673,311 +558,62 @@ async function exists3(target) {
673
558
  }
674
559
  }
675
560
 
676
- // src/core/contractsDecl.ts
677
- var CONTRACT_DECLARATION_RE = /^\s*(?:#|\/\/|--|\/\*+|\*+)?\s*QFAI-CONTRACT-ID:\s*((?:API|UI|DB)-\d{4})\s*(?:\*\/)?\s*$/gm;
678
- var CONTRACT_DECLARATION_LINE_RE = /^\s*(?:#|\/\/|--|\/\*+|\*+)?\s*QFAI-CONTRACT-ID:\s*(?:API|UI|DB)-\d{4}\s*(?:\*\/)?\s*$/;
679
- function extractDeclaredContractIds(text) {
680
- const ids = [];
681
- for (const match of text.matchAll(CONTRACT_DECLARATION_RE)) {
682
- const id = match[1];
683
- if (id) {
684
- ids.push(id);
685
- }
561
+ // src/core/paths.ts
562
+ import path4 from "path";
563
+ function toRelativePath(root, target) {
564
+ if (!target) {
565
+ return target;
686
566
  }
687
- return ids;
567
+ if (!path4.isAbsolute(target)) {
568
+ return toPosixPath(target);
569
+ }
570
+ const relative = path4.relative(root, target);
571
+ if (!relative) {
572
+ return ".";
573
+ }
574
+ return toPosixPath(relative);
688
575
  }
689
- function stripContractDeclarationLines(text) {
690
- return text.split(/\r?\n/).filter((line) => !CONTRACT_DECLARATION_LINE_RE.test(line)).join("\n");
576
+ function toPosixPath(value) {
577
+ return value.replace(/\\/g, "/");
691
578
  }
692
579
 
693
- // src/core/contractIndex.ts
694
- async function buildContractIndex(root, config) {
695
- 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");
699
- const [uiFiles, apiFiles, dbFiles] = await Promise.all([
700
- collectUiContractFiles(uiRoot),
701
- collectApiContractFiles(apiRoot),
702
- collectDbContractFiles(dbRoot)
703
- ]);
704
- const index = {
705
- ids: /* @__PURE__ */ new Set(),
706
- idToFiles: /* @__PURE__ */ new Map(),
707
- files: { ui: uiFiles, api: apiFiles, db: dbFiles }
708
- };
709
- await indexContractFiles(uiFiles, index);
710
- await indexContractFiles(apiFiles, index);
711
- await indexContractFiles(dbFiles, index);
712
- return index;
713
- }
714
- async function indexContractFiles(files, index) {
715
- for (const file of files) {
716
- const text = await readFile2(file, "utf-8");
717
- extractDeclaredContractIds(text).forEach((id) => record(index, id, file));
580
+ // src/core/traceability.ts
581
+ import { readFile as readFile2 } from "fs/promises";
582
+ import path5 from "path";
583
+
584
+ // src/core/gherkin/parse.ts
585
+ import {
586
+ AstBuilder,
587
+ GherkinClassicTokenMatcher,
588
+ Parser
589
+ } from "@cucumber/gherkin";
590
+ import { randomUUID } from "crypto";
591
+ function parseGherkin(source, uri) {
592
+ const errors = [];
593
+ const uuidFn = () => randomUUID();
594
+ const builder = new AstBuilder(uuidFn);
595
+ const matcher = new GherkinClassicTokenMatcher();
596
+ const parser = new Parser(builder, matcher);
597
+ try {
598
+ const gherkinDocument = parser.parse(source);
599
+ gherkinDocument.uri = uri;
600
+ return { gherkinDocument, errors };
601
+ } catch (error2) {
602
+ errors.push(formatError2(error2));
603
+ return { gherkinDocument: null, errors };
718
604
  }
719
605
  }
720
- function record(index, id, file) {
721
- index.ids.add(id);
722
- const current = index.idToFiles.get(id) ?? /* @__PURE__ */ new Set();
723
- current.add(file);
724
- index.idToFiles.set(id, current);
725
- }
726
-
727
- // src/core/ids.ts
728
- var ID_PREFIXES = ["SPEC", "BR", "SC", "UI", "API", "DB"];
729
- var STRICT_ID_PATTERNS = {
730
- SPEC: /\bSPEC-\d{4}\b/g,
731
- BR: /\bBR-\d{4}\b/g,
732
- SC: /\bSC-\d{4}\b/g,
733
- UI: /\bUI-\d{4}\b/g,
734
- API: /\bAPI-\d{4}\b/g,
735
- DB: /\bDB-\d{4}\b/g,
736
- ADR: /\bADR-\d{4}\b/g
737
- };
738
- var LOOSE_ID_PATTERNS = {
739
- SPEC: /\bSPEC-[A-Za-z0-9_-]+\b/gi,
740
- BR: /\bBR-[A-Za-z0-9_-]+\b/gi,
741
- SC: /\bSC-[A-Za-z0-9_-]+\b/gi,
742
- UI: /\bUI-[A-Za-z0-9_-]+\b/gi,
743
- API: /\bAPI-[A-Za-z0-9_-]+\b/gi,
744
- DB: /\bDB-[A-Za-z0-9_-]+\b/gi,
745
- ADR: /\bADR-[A-Za-z0-9_-]+\b/gi
746
- };
747
- function extractIds(text, prefix) {
748
- const pattern = STRICT_ID_PATTERNS[prefix];
749
- const matches = text.match(pattern);
750
- return unique(matches ?? []);
751
- }
752
- function extractAllIds(text) {
753
- const all = [];
754
- ID_PREFIXES.forEach((prefix) => {
755
- all.push(...extractIds(text, prefix));
756
- });
757
- return unique(all);
758
- }
759
- function extractInvalidIds(text, prefixes) {
760
- const invalid = [];
761
- for (const prefix of prefixes) {
762
- const candidates = text.match(LOOSE_ID_PATTERNS[prefix]) ?? [];
763
- for (const candidate of candidates) {
764
- if (!isValidId(candidate, prefix)) {
765
- invalid.push(candidate);
766
- }
767
- }
768
- }
769
- return unique(invalid);
770
- }
771
- function unique(values) {
772
- return Array.from(new Set(values));
773
- }
774
- function isValidId(value, prefix) {
775
- const pattern = STRICT_ID_PATTERNS[prefix];
776
- const strict = new RegExp(pattern.source);
777
- return strict.test(value);
778
- }
779
-
780
- // src/core/parse/markdown.ts
781
- var HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
782
- function parseHeadings(md) {
783
- const lines = md.split(/\r?\n/);
784
- const headings = [];
785
- for (let i = 0; i < lines.length; i++) {
786
- const line = lines[i] ?? "";
787
- const match = line.match(HEADING_RE);
788
- if (!match) continue;
789
- const levelToken = match[1];
790
- const title = match[2];
791
- if (!levelToken || !title) continue;
792
- headings.push({
793
- level: levelToken.length,
794
- title: title.trim(),
795
- line: i + 1
796
- });
797
- }
798
- return headings;
799
- }
800
- function extractH2Sections(md) {
801
- const lines = md.split(/\r?\n/);
802
- const headings = parseHeadings(md).filter((heading) => heading.level === 2);
803
- const sections = /* @__PURE__ */ new Map();
804
- for (let i = 0; i < headings.length; i++) {
805
- const current = headings[i];
806
- if (!current) continue;
807
- const next = headings[i + 1];
808
- const startLine = current.line + 1;
809
- const endLine = (next?.line ?? lines.length + 1) - 1;
810
- const body = startLine <= endLine ? lines.slice(startLine - 1, endLine).join("\n") : "";
811
- sections.set(current.title.trim(), {
812
- title: current.title.trim(),
813
- startLine,
814
- endLine,
815
- body
816
- });
817
- }
818
- return sections;
819
- }
820
-
821
- // src/core/parse/spec.ts
822
- var SPEC_ID_RE = /\bSPEC-\d{4}\b/;
823
- var BR_LINE_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\]\[(P[0-3])\]\s*(.+)$/;
824
- var BR_LINE_ANY_PRIORITY_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\]\[(P[^\]]+)\]\s*(.+)$/;
825
- 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
- var BR_SECTION_TITLE = "\u696D\u52D9\u30EB\u30FC\u30EB";
829
- var VALID_PRIORITIES = /* @__PURE__ */ new Set(["P0", "P1", "P2", "P3"]);
830
- function parseSpec(md, file) {
831
- const headings = parseHeadings(md);
832
- const h1 = headings.find((heading) => heading.level === 1);
833
- const specId = h1?.title.match(SPEC_ID_RE)?.[0];
834
- const sections = extractH2Sections(md);
835
- const sectionNames = new Set(Array.from(sections.keys()));
836
- const brSection = sections.get(BR_SECTION_TITLE);
837
- const brLines = brSection ? brSection.body.split(/\r?\n/) : [];
838
- const startLine = brSection?.startLine ?? 1;
839
- const brs = [];
840
- const brsWithoutPriority = [];
841
- const brsWithInvalidPriority = [];
842
- for (let i = 0; i < brLines.length; i++) {
843
- const lineText = brLines[i] ?? "";
844
- const lineNumber = startLine + i;
845
- const validMatch = lineText.match(BR_LINE_RE);
846
- if (validMatch) {
847
- const id = validMatch[1];
848
- const priority = validMatch[2];
849
- const text = validMatch[3];
850
- if (!id || !priority || !text) continue;
851
- brs.push({
852
- id,
853
- priority,
854
- text: text.trim(),
855
- line: lineNumber
856
- });
857
- continue;
858
- }
859
- const anyPriorityMatch = lineText.match(BR_LINE_ANY_PRIORITY_RE);
860
- if (anyPriorityMatch) {
861
- const id = anyPriorityMatch[1];
862
- const priority = anyPriorityMatch[2];
863
- const text = anyPriorityMatch[3];
864
- if (!id || !priority || !text) continue;
865
- if (!VALID_PRIORITIES.has(priority)) {
866
- brsWithInvalidPriority.push({
867
- id,
868
- priority,
869
- text: text.trim(),
870
- line: lineNumber
871
- });
872
- }
873
- continue;
874
- }
875
- const noPriorityMatch = lineText.match(BR_LINE_NO_PRIORITY_RE);
876
- if (noPriorityMatch) {
877
- const id = noPriorityMatch[1];
878
- const text = noPriorityMatch[2];
879
- if (!id || !text) continue;
880
- brsWithoutPriority.push({
881
- id,
882
- text: text.trim(),
883
- line: lineNumber
884
- });
885
- }
886
- }
887
- const parsed = {
888
- file,
889
- sections: sectionNames,
890
- brs,
891
- brsWithoutPriority,
892
- brsWithInvalidPriority,
893
- contractRefs: parseContractRefs(md)
894
- };
895
- if (specId) {
896
- parsed.specId = specId;
897
- }
898
- return parsed;
899
- }
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
-
941
- // src/core/traceability.ts
942
- import { readFile as readFile3 } from "fs/promises";
943
- import path8 from "path";
944
-
945
- // src/core/gherkin/parse.ts
946
- import {
947
- AstBuilder,
948
- GherkinClassicTokenMatcher,
949
- Parser
950
- } from "@cucumber/gherkin";
951
- import { randomUUID } from "crypto";
952
- function parseGherkin(source, uri) {
953
- const errors = [];
954
- const uuidFn = () => randomUUID();
955
- const builder = new AstBuilder(uuidFn);
956
- const matcher = new GherkinClassicTokenMatcher();
957
- const parser = new Parser(builder, matcher);
958
- try {
959
- const gherkinDocument = parser.parse(source);
960
- gherkinDocument.uri = uri;
961
- return { gherkinDocument, errors };
962
- } catch (error2) {
963
- errors.push(formatError2(error2));
964
- return { gherkinDocument: null, errors };
965
- }
966
- }
967
- function formatError2(error2) {
968
- if (error2 instanceof Error) {
969
- return error2.message;
970
- }
971
- return String(error2);
606
+ function formatError2(error2) {
607
+ if (error2 instanceof Error) {
608
+ return error2.message;
609
+ }
610
+ return String(error2);
972
611
  }
973
612
 
974
613
  // src/core/scenarioModel.ts
975
614
  var SPEC_TAG_RE = /^SPEC-\d{4}$/;
976
615
  var SC_TAG_RE = /^SC-\d{4}$/;
977
616
  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
617
  function parseScenarioDocument(text, uri) {
982
618
  const { gherkinDocument, errors } = parseGherkin(text, uri);
983
619
  if (!gherkinDocument) {
@@ -1002,31 +638,21 @@ function parseScenarioDocument(text, uri) {
1002
638
  errors
1003
639
  };
1004
640
  }
1005
- function buildScenarioAtoms(document) {
641
+ function buildScenarioAtoms(document, contractIds = []) {
642
+ const uniqueContractIds = unique(contractIds).sort(
643
+ (a, b) => a.localeCompare(b)
644
+ );
1006
645
  return document.scenarios.map((scenario) => {
1007
646
  const specIds = scenario.tags.filter((tag) => SPEC_TAG_RE.test(tag));
1008
647
  const scIds = scenario.tags.filter((tag) => SC_TAG_RE.test(tag));
1009
- 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
- }
648
+ const brIds = unique(scenario.tags.filter((tag) => BR_TAG_RE.test(tag)));
1023
649
  const atom = {
1024
650
  uri: document.uri,
1025
651
  featureName: document.featureName ?? "",
1026
652
  scenarioName: scenario.name,
1027
653
  kind: scenario.kind,
1028
654
  brIds,
1029
- contractIds: Array.from(contractIds).sort()
655
+ contractIds: uniqueContractIds
1030
656
  };
1031
657
  if (scenario.line !== void 0) {
1032
658
  atom.line = scenario.line;
@@ -1079,24 +705,7 @@ function buildScenarioNode(scenario, featureTags, ruleTags) {
1079
705
  function collectTagNames(tags) {
1080
706
  return tags.map((tag) => tag.name.replace(/^@/, ""));
1081
707
  }
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
- function unique3(values) {
708
+ function unique(values) {
1100
709
  return Array.from(new Set(values));
1101
710
  }
1102
711
 
@@ -1126,7 +735,7 @@ function extractAnnotatedScIds(text) {
1126
735
  async function collectScIdsFromScenarioFiles(scenarioFiles) {
1127
736
  const scIds = /* @__PURE__ */ new Set();
1128
737
  for (const file of scenarioFiles) {
1129
- const text = await readFile3(file, "utf-8");
738
+ const text = await readFile2(file, "utf-8");
1130
739
  const { document, errors } = parseScenarioDocument(text, file);
1131
740
  if (!document || errors.length > 0) {
1132
741
  continue;
@@ -1144,7 +753,7 @@ async function collectScIdsFromScenarioFiles(scenarioFiles) {
1144
753
  async function collectScIdSourcesFromScenarioFiles(scenarioFiles) {
1145
754
  const sources = /* @__PURE__ */ new Map();
1146
755
  for (const file of scenarioFiles) {
1147
- const text = await readFile3(file, "utf-8");
756
+ const text = await readFile2(file, "utf-8");
1148
757
  const { document, errors } = parseScenarioDocument(text, file);
1149
758
  if (!document || errors.length > 0) {
1150
759
  continue;
@@ -1197,98 +806,785 @@ async function collectScTestReferences(root, globs, excludeGlobs) {
1197
806
  };
1198
807
  }
1199
808
  const normalizedFiles = Array.from(
1200
- new Set(files.map((file) => path8.normalize(file)))
809
+ new Set(files.map((file) => path5.normalize(file)))
1201
810
  );
1202
811
  for (const file of normalizedFiles) {
1203
- const text = await readFile3(file, "utf-8");
812
+ const text = await readFile2(file, "utf-8");
1204
813
  const scIds = extractAnnotatedScIds(text);
1205
814
  if (scIds.length === 0) {
1206
815
  continue;
1207
816
  }
1208
- for (const scId of scIds) {
1209
- const current = refs.get(scId) ?? /* @__PURE__ */ new Set();
1210
- current.add(file);
1211
- refs.set(scId, current);
1212
- }
1213
- }
1214
- return {
1215
- refs,
1216
- scan: {
1217
- globs: normalizedGlobs,
1218
- excludeGlobs: mergedExcludeGlobs,
1219
- matchedFileCount: normalizedFiles.length
817
+ for (const scId of scIds) {
818
+ const current = refs.get(scId) ?? /* @__PURE__ */ new Set();
819
+ current.add(file);
820
+ refs.set(scId, current);
821
+ }
822
+ }
823
+ return {
824
+ refs,
825
+ scan: {
826
+ globs: normalizedGlobs,
827
+ excludeGlobs: mergedExcludeGlobs,
828
+ matchedFileCount: normalizedFiles.length
829
+ }
830
+ };
831
+ }
832
+ function buildScCoverage(scIds, refs) {
833
+ const sortedScIds = toSortedArray(scIds);
834
+ const refsRecord = {};
835
+ const missingIds = [];
836
+ let covered = 0;
837
+ for (const scId of sortedScIds) {
838
+ const files = refs.get(scId);
839
+ const sortedFiles = files ? toSortedArray(files) : [];
840
+ refsRecord[scId] = sortedFiles;
841
+ if (sortedFiles.length === 0) {
842
+ missingIds.push(scId);
843
+ } else {
844
+ covered += 1;
845
+ }
846
+ }
847
+ return {
848
+ total: sortedScIds.length,
849
+ covered,
850
+ missing: missingIds.length,
851
+ missingIds,
852
+ refs: refsRecord
853
+ };
854
+ }
855
+ function toSortedArray(values) {
856
+ return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
857
+ }
858
+ function normalizeGlobs(globs) {
859
+ return globs.map((glob) => glob.trim()).filter((glob) => glob.length > 0);
860
+ }
861
+ function formatError3(error2) {
862
+ if (error2 instanceof Error) {
863
+ return error2.message;
864
+ }
865
+ return String(error2);
866
+ }
867
+
868
+ // src/core/version.ts
869
+ import { readFile as readFile3 } from "fs/promises";
870
+ import path6 from "path";
871
+ import { fileURLToPath } from "url";
872
+ async function resolveToolVersion() {
873
+ if ("0.6.0".length > 0) {
874
+ return "0.6.0";
875
+ }
876
+ try {
877
+ const packagePath = resolvePackageJsonPath();
878
+ const raw = await readFile3(packagePath, "utf-8");
879
+ const parsed = JSON.parse(raw);
880
+ const version = typeof parsed.version === "string" ? parsed.version : "";
881
+ return version.length > 0 ? version : "unknown";
882
+ } catch {
883
+ return "unknown";
884
+ }
885
+ }
886
+ function resolvePackageJsonPath() {
887
+ const base = import.meta.url;
888
+ const basePath = base.startsWith("file:") ? fileURLToPath(base) : base;
889
+ return path6.resolve(path6.dirname(basePath), "../../package.json");
890
+ }
891
+
892
+ // src/core/doctor.ts
893
+ async function exists4(target) {
894
+ try {
895
+ await access4(target);
896
+ return true;
897
+ } catch {
898
+ return false;
899
+ }
900
+ }
901
+ function addCheck(checks, check) {
902
+ checks.push(check);
903
+ }
904
+ function summarize(checks) {
905
+ const summary = { ok: 0, warning: 0, error: 0 };
906
+ for (const check of checks) {
907
+ summary[check.severity] += 1;
908
+ }
909
+ return summary;
910
+ }
911
+ function normalizeGlobs2(values) {
912
+ return values.map((glob) => glob.trim()).filter((glob) => glob.length > 0);
913
+ }
914
+ async function createDoctorData(options) {
915
+ const startDir = path7.resolve(options.startDir);
916
+ const checks = [];
917
+ const configPath = getConfigPath(startDir);
918
+ const search = options.rootExplicit ? {
919
+ root: startDir,
920
+ configPath,
921
+ found: await exists4(configPath)
922
+ } : await findConfigRoot(startDir);
923
+ const root = search.root;
924
+ const version = await resolveToolVersion();
925
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
926
+ addCheck(checks, {
927
+ id: "config.search",
928
+ severity: search.found ? "ok" : "warning",
929
+ title: "Config search",
930
+ message: search.found ? "qfai.config.yaml found" : "qfai.config.yaml not found (default config will be used)",
931
+ details: { configPath: toRelativePath(root, search.configPath) }
932
+ });
933
+ const {
934
+ config,
935
+ issues,
936
+ configPath: resolvedConfigPath
937
+ } = await loadConfig(root);
938
+ if (issues.length === 0) {
939
+ addCheck(checks, {
940
+ id: "config.load",
941
+ severity: "ok",
942
+ title: "Config load",
943
+ message: "Loaded and normalized with 0 issues",
944
+ details: { configPath: toRelativePath(root, resolvedConfigPath) }
945
+ });
946
+ } else {
947
+ addCheck(checks, {
948
+ id: "config.load",
949
+ severity: "warning",
950
+ title: "Config load",
951
+ message: `Loaded with ${issues.length} issue(s) (normalized with defaults when needed)`,
952
+ details: {
953
+ configPath: toRelativePath(root, resolvedConfigPath),
954
+ issues
955
+ }
956
+ });
957
+ }
958
+ const pathKeys = [
959
+ "specsDir",
960
+ "contractsDir",
961
+ "outDir",
962
+ "srcDir",
963
+ "testsDir",
964
+ "rulesDir",
965
+ "promptsDir"
966
+ ];
967
+ for (const key of pathKeys) {
968
+ const resolved = resolvePath(root, config, key);
969
+ const ok = await exists4(resolved);
970
+ addCheck(checks, {
971
+ id: `paths.${key}`,
972
+ severity: ok ? "ok" : "warning",
973
+ title: `Path exists: ${key}`,
974
+ message: ok ? `${key} exists` : `${key} is missing (did you run 'qfai init'?)`,
975
+ details: { path: toRelativePath(root, resolved) }
976
+ });
977
+ }
978
+ const specsRoot = resolvePath(root, config, "specsDir");
979
+ const entries = await collectSpecEntries(specsRoot);
980
+ let missingFiles = 0;
981
+ for (const entry of entries) {
982
+ const requiredFiles = [entry.specPath, entry.deltaPath, entry.scenarioPath];
983
+ for (const filePath of requiredFiles) {
984
+ if (!await exists4(filePath)) {
985
+ missingFiles += 1;
986
+ }
987
+ }
988
+ }
989
+ addCheck(checks, {
990
+ id: "spec.layout",
991
+ severity: missingFiles === 0 ? "ok" : "warning",
992
+ title: "Spec pack shape",
993
+ message: missingFiles === 0 ? `All spec packs have required files (count=${entries.length})` : `Missing required files in spec packs (missingFiles=${missingFiles})`,
994
+ details: { specPacks: entries.length, missingFiles }
995
+ });
996
+ const validateJsonAbs = path7.isAbsolute(config.output.validateJsonPath) ? config.output.validateJsonPath : path7.resolve(root, config.output.validateJsonPath);
997
+ const validateJsonExists = await exists4(validateJsonAbs);
998
+ addCheck(checks, {
999
+ id: "output.validateJson",
1000
+ severity: validateJsonExists ? "ok" : "warning",
1001
+ title: "validate.json",
1002
+ message: validateJsonExists ? "validate.json exists (report can run)" : "validate.json is missing (run 'qfai validate' before 'qfai report')",
1003
+ details: { path: toRelativePath(root, validateJsonAbs) }
1004
+ });
1005
+ const scenarioFiles = await collectScenarioFiles(specsRoot);
1006
+ const globs = normalizeGlobs2(config.validation.traceability.testFileGlobs);
1007
+ const exclude = normalizeGlobs2([
1008
+ ...DEFAULT_TEST_FILE_EXCLUDE_GLOBS,
1009
+ ...config.validation.traceability.testFileExcludeGlobs
1010
+ ]);
1011
+ try {
1012
+ const matched = globs.length === 0 ? [] : await collectFilesByGlobs(root, { globs, ignore: exclude });
1013
+ const matchedCount = matched.length;
1014
+ const severity = globs.length === 0 ? "warning" : scenarioFiles.length > 0 && config.validation.traceability.scMustHaveTest && matchedCount === 0 ? "warning" : "ok";
1015
+ addCheck(checks, {
1016
+ id: "traceability.testGlobs",
1017
+ severity,
1018
+ title: "Test file globs",
1019
+ message: globs.length === 0 ? "testFileGlobs is empty (SC\u2192Test cannot be verified)" : `matchedFileCount=${matchedCount}`,
1020
+ details: {
1021
+ globs,
1022
+ excludeGlobs: exclude,
1023
+ scenarioFiles: scenarioFiles.length,
1024
+ scMustHaveTest: config.validation.traceability.scMustHaveTest
1025
+ }
1026
+ });
1027
+ } catch (error2) {
1028
+ addCheck(checks, {
1029
+ id: "traceability.testGlobs",
1030
+ severity: "error",
1031
+ title: "Test file globs",
1032
+ message: "Glob scan failed (invalid pattern or filesystem error)",
1033
+ details: { globs, excludeGlobs: exclude, error: String(error2) }
1034
+ });
1035
+ }
1036
+ const outDirAbs = resolvePath(root, config, "outDir");
1037
+ const rel = path7.relative(outDirAbs, validateJsonAbs);
1038
+ const inside = rel !== "" && !rel.startsWith("..") && !path7.isAbsolute(rel);
1039
+ addCheck(checks, {
1040
+ id: "output.pathAlignment",
1041
+ severity: inside ? "ok" : "warning",
1042
+ title: "Output path alignment",
1043
+ message: inside ? "validateJsonPath is under outDir" : "validateJsonPath is not under outDir (may be intended, but check configuration)",
1044
+ details: {
1045
+ outDir: toRelativePath(root, outDirAbs),
1046
+ validateJsonPath: toRelativePath(root, validateJsonAbs)
1047
+ }
1048
+ });
1049
+ return {
1050
+ tool: "qfai",
1051
+ version,
1052
+ doctorFormatVersion: 1,
1053
+ generatedAt,
1054
+ root: toRelativePath(process.cwd(), root),
1055
+ config: {
1056
+ startDir: toRelativePath(process.cwd(), startDir),
1057
+ found: search.found,
1058
+ configPath: toRelativePath(root, search.configPath) || "qfai.config.yaml"
1059
+ },
1060
+ summary: summarize(checks),
1061
+ checks
1062
+ };
1063
+ }
1064
+
1065
+ // src/cli/lib/logger.ts
1066
+ function info(message) {
1067
+ process.stdout.write(`${message}
1068
+ `);
1069
+ }
1070
+ function warn(message) {
1071
+ process.stdout.write(`${message}
1072
+ `);
1073
+ }
1074
+ function error(message) {
1075
+ process.stderr.write(`${message}
1076
+ `);
1077
+ }
1078
+
1079
+ // src/cli/commands/doctor.ts
1080
+ function formatDoctorText(data) {
1081
+ const lines = [];
1082
+ lines.push(
1083
+ `qfai doctor: root=${data.root} config=${data.config.configPath} (${data.config.found ? "found" : "missing"})`
1084
+ );
1085
+ for (const check of data.checks) {
1086
+ lines.push(`[${check.severity}] ${check.id}: ${check.message}`);
1087
+ }
1088
+ lines.push(
1089
+ `summary: ok=${data.summary.ok} warning=${data.summary.warning} error=${data.summary.error}`
1090
+ );
1091
+ return lines.join("\n");
1092
+ }
1093
+ function formatDoctorJson(data) {
1094
+ return JSON.stringify(data, null, 2);
1095
+ }
1096
+ async function runDoctor(options) {
1097
+ const data = await createDoctorData({
1098
+ startDir: options.root,
1099
+ rootExplicit: options.rootExplicit
1100
+ });
1101
+ const output = options.format === "json" ? formatDoctorJson(data) : formatDoctorText(data);
1102
+ if (options.outPath) {
1103
+ const outAbs = path8.isAbsolute(options.outPath) ? options.outPath : path8.resolve(process.cwd(), options.outPath);
1104
+ await mkdir(path8.dirname(outAbs), { recursive: true });
1105
+ await writeFile(outAbs, `${output}
1106
+ `, "utf-8");
1107
+ info(`doctor: wrote ${outAbs}`);
1108
+ return;
1109
+ }
1110
+ info(output);
1111
+ }
1112
+
1113
+ // src/cli/commands/init.ts
1114
+ import path11 from "path";
1115
+
1116
+ // src/cli/lib/fs.ts
1117
+ import { access as access5, copyFile, mkdir as mkdir2, readdir as readdir3 } from "fs/promises";
1118
+ import path9 from "path";
1119
+ async function copyTemplateTree(sourceRoot, destRoot, options) {
1120
+ const files = await collectTemplateFiles(sourceRoot);
1121
+ return copyFiles(files, sourceRoot, destRoot, options);
1122
+ }
1123
+ async function copyFiles(files, sourceRoot, destRoot, options) {
1124
+ const copied = [];
1125
+ const skipped = [];
1126
+ const conflicts = [];
1127
+ if (!options.force) {
1128
+ for (const file of files) {
1129
+ const relative = path9.relative(sourceRoot, file);
1130
+ const dest = path9.join(destRoot, relative);
1131
+ if (!await shouldWrite(dest, options.force)) {
1132
+ conflicts.push(dest);
1133
+ }
1134
+ }
1135
+ if (conflicts.length > 0) {
1136
+ throw new Error(formatConflictMessage(conflicts));
1137
+ }
1138
+ }
1139
+ for (const file of files) {
1140
+ const relative = path9.relative(sourceRoot, file);
1141
+ const dest = path9.join(destRoot, relative);
1142
+ if (!await shouldWrite(dest, options.force)) {
1143
+ skipped.push(dest);
1144
+ continue;
1145
+ }
1146
+ if (!options.dryRun) {
1147
+ await mkdir2(path9.dirname(dest), { recursive: true });
1148
+ await copyFile(file, dest);
1149
+ }
1150
+ copied.push(dest);
1151
+ }
1152
+ return { copied, skipped };
1153
+ }
1154
+ function formatConflictMessage(conflicts) {
1155
+ return [
1156
+ "\u65E2\u5B58\u30D5\u30A1\u30A4\u30EB\u3068\u885D\u7A81\u3057\u307E\u3057\u305F\u3002\u5B89\u5168\u306E\u305F\u3081\u505C\u6B62\u3057\u307E\u3059\u3002",
1157
+ "",
1158
+ "\u885D\u7A81\u30D5\u30A1\u30A4\u30EB:",
1159
+ ...conflicts.map((conflict) => `- ${conflict}`),
1160
+ "",
1161
+ "\u4E0A\u66F8\u304D\u3057\u3066\u7D9A\u884C\u3059\u308B\u5834\u5408\u306F --force \u3092\u4ED8\u3051\u3066\u518D\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002"
1162
+ ].join("\n");
1163
+ }
1164
+ async function collectTemplateFiles(root) {
1165
+ const entries = [];
1166
+ if (!await exists5(root)) {
1167
+ return entries;
1168
+ }
1169
+ const items = await readdir3(root, { withFileTypes: true });
1170
+ for (const item of items) {
1171
+ const fullPath = path9.join(root, item.name);
1172
+ if (item.isDirectory()) {
1173
+ const nested = await collectTemplateFiles(fullPath);
1174
+ entries.push(...nested);
1175
+ continue;
1176
+ }
1177
+ if (item.isFile()) {
1178
+ entries.push(fullPath);
1179
+ }
1180
+ }
1181
+ return entries;
1182
+ }
1183
+ async function shouldWrite(target, force) {
1184
+ if (force) {
1185
+ return true;
1186
+ }
1187
+ return !await exists5(target);
1188
+ }
1189
+ async function exists5(target) {
1190
+ try {
1191
+ await access5(target);
1192
+ return true;
1193
+ } catch {
1194
+ return false;
1195
+ }
1196
+ }
1197
+
1198
+ // src/cli/lib/assets.ts
1199
+ import { existsSync } from "fs";
1200
+ import path10 from "path";
1201
+ import { fileURLToPath as fileURLToPath2 } from "url";
1202
+ function getInitAssetsDir() {
1203
+ const base = import.meta.url;
1204
+ const basePath = base.startsWith("file:") ? fileURLToPath2(base) : base;
1205
+ const baseDir = path10.dirname(basePath);
1206
+ const candidates = [
1207
+ path10.resolve(baseDir, "../../../assets/init"),
1208
+ path10.resolve(baseDir, "../../assets/init")
1209
+ ];
1210
+ for (const candidate of candidates) {
1211
+ if (existsSync(candidate)) {
1212
+ return candidate;
1213
+ }
1214
+ }
1215
+ throw new Error(
1216
+ [
1217
+ "init \u7528\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002Template assets not found.",
1218
+ "\u78BA\u8A8D\u3057\u305F\u30D1\u30B9 / Checked paths:",
1219
+ ...candidates.map((candidate) => `- ${candidate}`)
1220
+ ].join("\n")
1221
+ );
1222
+ }
1223
+
1224
+ // src/cli/commands/init.ts
1225
+ async function runInit(options) {
1226
+ const assetsRoot = getInitAssetsDir();
1227
+ const rootAssets = path11.join(assetsRoot, "root");
1228
+ const qfaiAssets = path11.join(assetsRoot, ".qfai");
1229
+ const destRoot = path11.resolve(options.dir);
1230
+ const destQfai = path11.join(destRoot, ".qfai");
1231
+ const rootResult = await copyTemplateTree(rootAssets, destRoot, {
1232
+ force: options.force,
1233
+ dryRun: options.dryRun
1234
+ });
1235
+ const qfaiResult = await copyTemplateTree(qfaiAssets, destQfai, {
1236
+ force: options.force,
1237
+ dryRun: options.dryRun
1238
+ });
1239
+ report(
1240
+ [...rootResult.copied, ...qfaiResult.copied],
1241
+ [...rootResult.skipped, ...qfaiResult.skipped],
1242
+ options.dryRun,
1243
+ "init"
1244
+ );
1245
+ }
1246
+ function report(copied, skipped, dryRun, label) {
1247
+ info(`qfai ${label}: ${dryRun ? "dry-run" : "done"}`);
1248
+ if (copied.length > 0) {
1249
+ info(` created: ${copied.length}`);
1250
+ }
1251
+ if (skipped.length > 0) {
1252
+ info(` skipped: ${skipped.length}`);
1253
+ }
1254
+ }
1255
+
1256
+ // src/cli/commands/report.ts
1257
+ import { mkdir as mkdir3, readFile as readFile12, writeFile as writeFile2 } from "fs/promises";
1258
+ import path18 from "path";
1259
+
1260
+ // src/core/normalize.ts
1261
+ function normalizeIssuePaths(root, issues) {
1262
+ return issues.map((issue7) => {
1263
+ if (!issue7.file) {
1264
+ return issue7;
1265
+ }
1266
+ const normalized = toRelativePath(root, issue7.file);
1267
+ if (normalized === issue7.file) {
1268
+ return issue7;
1269
+ }
1270
+ return {
1271
+ ...issue7,
1272
+ file: normalized
1273
+ };
1274
+ });
1275
+ }
1276
+ function normalizeScCoverage(root, sc) {
1277
+ const refs = {};
1278
+ for (const [scId, files] of Object.entries(sc.refs)) {
1279
+ refs[scId] = files.map((file) => toRelativePath(root, file));
1280
+ }
1281
+ return {
1282
+ ...sc,
1283
+ refs
1284
+ };
1285
+ }
1286
+ function normalizeValidationResult(root, result) {
1287
+ return {
1288
+ ...result,
1289
+ issues: normalizeIssuePaths(root, result.issues),
1290
+ traceability: {
1291
+ ...result.traceability,
1292
+ sc: normalizeScCoverage(root, result.traceability.sc)
1293
+ }
1294
+ };
1295
+ }
1296
+
1297
+ // src/core/report.ts
1298
+ import { readFile as readFile11 } from "fs/promises";
1299
+ import path17 from "path";
1300
+
1301
+ // src/core/contractIndex.ts
1302
+ import { readFile as readFile4 } from "fs/promises";
1303
+ import path12 from "path";
1304
+
1305
+ // src/core/contractsDecl.ts
1306
+ var CONTRACT_DECLARATION_RE = /^\s*(?:#|\/\/|--|\/\*+|\*+)?\s*QFAI-CONTRACT-ID:\s*((?:API|UI|DB)-\d{4})\s*(?:\*\/)?\s*$/gm;
1307
+ var CONTRACT_DECLARATION_LINE_RE = /^\s*(?:#|\/\/|--|\/\*+|\*+)?\s*QFAI-CONTRACT-ID:\s*(?:API|UI|DB)-\d{4}\s*(?:\*\/)?\s*$/;
1308
+ function extractDeclaredContractIds(text) {
1309
+ const ids = [];
1310
+ for (const match of text.matchAll(CONTRACT_DECLARATION_RE)) {
1311
+ const id = match[1];
1312
+ if (id) {
1313
+ ids.push(id);
1314
+ }
1315
+ }
1316
+ return ids;
1317
+ }
1318
+ function stripContractDeclarationLines(text) {
1319
+ return text.split(/\r?\n/).filter((line) => !CONTRACT_DECLARATION_LINE_RE.test(line)).join("\n");
1320
+ }
1321
+
1322
+ // src/core/contractIndex.ts
1323
+ async function buildContractIndex(root, config) {
1324
+ const contractsRoot = resolvePath(root, config, "contractsDir");
1325
+ const uiRoot = path12.join(contractsRoot, "ui");
1326
+ const apiRoot = path12.join(contractsRoot, "api");
1327
+ const dbRoot = path12.join(contractsRoot, "db");
1328
+ const [uiFiles, apiFiles, dbFiles] = await Promise.all([
1329
+ collectUiContractFiles(uiRoot),
1330
+ collectApiContractFiles(apiRoot),
1331
+ collectDbContractFiles(dbRoot)
1332
+ ]);
1333
+ const index = {
1334
+ ids: /* @__PURE__ */ new Set(),
1335
+ idToFiles: /* @__PURE__ */ new Map(),
1336
+ files: { ui: uiFiles, api: apiFiles, db: dbFiles }
1337
+ };
1338
+ await indexContractFiles(uiFiles, index);
1339
+ await indexContractFiles(apiFiles, index);
1340
+ await indexContractFiles(dbFiles, index);
1341
+ return index;
1342
+ }
1343
+ async function indexContractFiles(files, index) {
1344
+ for (const file of files) {
1345
+ const text = await readFile4(file, "utf-8");
1346
+ extractDeclaredContractIds(text).forEach((id) => record(index, id, file));
1347
+ }
1348
+ }
1349
+ function record(index, id, file) {
1350
+ index.ids.add(id);
1351
+ const current = index.idToFiles.get(id) ?? /* @__PURE__ */ new Set();
1352
+ current.add(file);
1353
+ index.idToFiles.set(id, current);
1354
+ }
1355
+
1356
+ // src/core/ids.ts
1357
+ var ID_PREFIXES = ["SPEC", "BR", "SC", "UI", "API", "DB"];
1358
+ var STRICT_ID_PATTERNS = {
1359
+ SPEC: /\bSPEC-\d{4}\b/g,
1360
+ BR: /\bBR-\d{4}\b/g,
1361
+ SC: /\bSC-\d{4}\b/g,
1362
+ UI: /\bUI-\d{4}\b/g,
1363
+ API: /\bAPI-\d{4}\b/g,
1364
+ DB: /\bDB-\d{4}\b/g,
1365
+ ADR: /\bADR-\d{4}\b/g
1366
+ };
1367
+ var LOOSE_ID_PATTERNS = {
1368
+ SPEC: /\bSPEC-[A-Za-z0-9_-]+\b/gi,
1369
+ BR: /\bBR-[A-Za-z0-9_-]+\b/gi,
1370
+ SC: /\bSC-[A-Za-z0-9_-]+\b/gi,
1371
+ UI: /\bUI-[A-Za-z0-9_-]+\b/gi,
1372
+ API: /\bAPI-[A-Za-z0-9_-]+\b/gi,
1373
+ DB: /\bDB-[A-Za-z0-9_-]+\b/gi,
1374
+ ADR: /\bADR-[A-Za-z0-9_-]+\b/gi
1375
+ };
1376
+ function extractIds(text, prefix) {
1377
+ const pattern = STRICT_ID_PATTERNS[prefix];
1378
+ const matches = text.match(pattern);
1379
+ return unique2(matches ?? []);
1380
+ }
1381
+ function extractAllIds(text) {
1382
+ const all = [];
1383
+ ID_PREFIXES.forEach((prefix) => {
1384
+ all.push(...extractIds(text, prefix));
1385
+ });
1386
+ return unique2(all);
1387
+ }
1388
+ function extractInvalidIds(text, prefixes) {
1389
+ const invalid = [];
1390
+ for (const prefix of prefixes) {
1391
+ const candidates = text.match(LOOSE_ID_PATTERNS[prefix]) ?? [];
1392
+ for (const candidate of candidates) {
1393
+ if (!isValidId(candidate, prefix)) {
1394
+ invalid.push(candidate);
1395
+ }
1396
+ }
1397
+ }
1398
+ return unique2(invalid);
1399
+ }
1400
+ function unique2(values) {
1401
+ return Array.from(new Set(values));
1402
+ }
1403
+ function isValidId(value, prefix) {
1404
+ const pattern = STRICT_ID_PATTERNS[prefix];
1405
+ const strict = new RegExp(pattern.source);
1406
+ return strict.test(value);
1407
+ }
1408
+
1409
+ // src/core/parse/contractRefs.ts
1410
+ var CONTRACT_REF_ID_RE = /^(?:API|UI|DB)-\d{4}$/;
1411
+ function parseContractRefs(text, options = {}) {
1412
+ const linePattern = buildLinePattern(options);
1413
+ const lines = [];
1414
+ for (const match of text.matchAll(linePattern)) {
1415
+ lines.push((match[1] ?? "").trim());
1416
+ }
1417
+ const ids = [];
1418
+ const invalidTokens = [];
1419
+ let hasNone = false;
1420
+ for (const line of lines) {
1421
+ if (line.length === 0) {
1422
+ invalidTokens.push("(empty)");
1423
+ continue;
1424
+ }
1425
+ const tokens = line.split(",").map((token) => token.trim());
1426
+ for (const token of tokens) {
1427
+ if (token.length === 0) {
1428
+ invalidTokens.push("(empty)");
1429
+ continue;
1430
+ }
1431
+ if (token === "none") {
1432
+ hasNone = true;
1433
+ continue;
1434
+ }
1435
+ if (CONTRACT_REF_ID_RE.test(token)) {
1436
+ ids.push(token);
1437
+ continue;
1438
+ }
1439
+ invalidTokens.push(token);
1440
+ }
1441
+ }
1442
+ return {
1443
+ lines,
1444
+ ids: unique3(ids),
1445
+ invalidTokens: unique3(invalidTokens),
1446
+ hasNone
1447
+ };
1448
+ }
1449
+ function buildLinePattern(options) {
1450
+ const prefix = options.allowCommentPrefix ? "#" : "";
1451
+ return new RegExp(
1452
+ `^[ \\t]*${prefix}[ \\t]*QFAI-CONTRACT-REF:[ \\t]*([^\\r\\n]*)[ \\t]*$`,
1453
+ "gm"
1454
+ );
1455
+ }
1456
+ function unique3(values) {
1457
+ return Array.from(new Set(values));
1458
+ }
1459
+
1460
+ // src/core/parse/markdown.ts
1461
+ var HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
1462
+ function parseHeadings(md) {
1463
+ const lines = md.split(/\r?\n/);
1464
+ const headings = [];
1465
+ for (let i = 0; i < lines.length; i++) {
1466
+ const line = lines[i] ?? "";
1467
+ const match = line.match(HEADING_RE);
1468
+ if (!match) continue;
1469
+ const levelToken = match[1];
1470
+ const title = match[2];
1471
+ if (!levelToken || !title) continue;
1472
+ headings.push({
1473
+ level: levelToken.length,
1474
+ title: title.trim(),
1475
+ line: i + 1
1476
+ });
1477
+ }
1478
+ return headings;
1479
+ }
1480
+ function extractH2Sections(md) {
1481
+ const lines = md.split(/\r?\n/);
1482
+ const headings = parseHeadings(md).filter((heading) => heading.level === 2);
1483
+ const sections = /* @__PURE__ */ new Map();
1484
+ for (let i = 0; i < headings.length; i++) {
1485
+ const current = headings[i];
1486
+ if (!current) continue;
1487
+ const next = headings[i + 1];
1488
+ const startLine = current.line + 1;
1489
+ const endLine = (next?.line ?? lines.length + 1) - 1;
1490
+ const body = startLine <= endLine ? lines.slice(startLine - 1, endLine).join("\n") : "";
1491
+ sections.set(current.title.trim(), {
1492
+ title: current.title.trim(),
1493
+ startLine,
1494
+ endLine,
1495
+ body
1496
+ });
1497
+ }
1498
+ return sections;
1499
+ }
1500
+
1501
+ // src/core/parse/spec.ts
1502
+ var SPEC_ID_RE = /\bSPEC-\d{4}\b/;
1503
+ var BR_LINE_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\]\[(P[0-3])\]\s*(.+)$/;
1504
+ var BR_LINE_ANY_PRIORITY_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\]\[(P[^\]]+)\]\s*(.+)$/;
1505
+ var BR_LINE_NO_PRIORITY_RE = /^\s*(?:[-*]\s*)?\[(BR-\d{4})\](?!\s*\[P)\s*(.*\S.*)$/;
1506
+ var BR_SECTION_TITLE = "\u696D\u52D9\u30EB\u30FC\u30EB";
1507
+ var VALID_PRIORITIES = /* @__PURE__ */ new Set(["P0", "P1", "P2", "P3"]);
1508
+ function parseSpec(md, file) {
1509
+ const headings = parseHeadings(md);
1510
+ const h1 = headings.find((heading) => heading.level === 1);
1511
+ const specId = h1?.title.match(SPEC_ID_RE)?.[0];
1512
+ const sections = extractH2Sections(md);
1513
+ const sectionNames = new Set(Array.from(sections.keys()));
1514
+ const brSection = sections.get(BR_SECTION_TITLE);
1515
+ const brLines = brSection ? brSection.body.split(/\r?\n/) : [];
1516
+ const startLine = brSection?.startLine ?? 1;
1517
+ const brs = [];
1518
+ const brsWithoutPriority = [];
1519
+ const brsWithInvalidPriority = [];
1520
+ for (let i = 0; i < brLines.length; i++) {
1521
+ const lineText = brLines[i] ?? "";
1522
+ const lineNumber = startLine + i;
1523
+ const validMatch = lineText.match(BR_LINE_RE);
1524
+ if (validMatch) {
1525
+ const id = validMatch[1];
1526
+ const priority = validMatch[2];
1527
+ const text = validMatch[3];
1528
+ if (!id || !priority || !text) continue;
1529
+ brs.push({
1530
+ id,
1531
+ priority,
1532
+ text: text.trim(),
1533
+ line: lineNumber
1534
+ });
1535
+ continue;
1536
+ }
1537
+ const anyPriorityMatch = lineText.match(BR_LINE_ANY_PRIORITY_RE);
1538
+ if (anyPriorityMatch) {
1539
+ const id = anyPriorityMatch[1];
1540
+ const priority = anyPriorityMatch[2];
1541
+ const text = anyPriorityMatch[3];
1542
+ if (!id || !priority || !text) continue;
1543
+ if (!VALID_PRIORITIES.has(priority)) {
1544
+ brsWithInvalidPriority.push({
1545
+ id,
1546
+ priority,
1547
+ text: text.trim(),
1548
+ line: lineNumber
1549
+ });
1550
+ }
1551
+ continue;
1220
1552
  }
1221
- };
1222
- }
1223
- function buildScCoverage(scIds, refs) {
1224
- const sortedScIds = toSortedArray(scIds);
1225
- const refsRecord = {};
1226
- const missingIds = [];
1227
- let covered = 0;
1228
- for (const scId of sortedScIds) {
1229
- const files = refs.get(scId);
1230
- const sortedFiles = files ? toSortedArray(files) : [];
1231
- refsRecord[scId] = sortedFiles;
1232
- if (sortedFiles.length === 0) {
1233
- missingIds.push(scId);
1234
- } else {
1235
- covered += 1;
1553
+ const noPriorityMatch = lineText.match(BR_LINE_NO_PRIORITY_RE);
1554
+ if (noPriorityMatch) {
1555
+ const id = noPriorityMatch[1];
1556
+ const text = noPriorityMatch[2];
1557
+ if (!id || !text) continue;
1558
+ brsWithoutPriority.push({
1559
+ id,
1560
+ text: text.trim(),
1561
+ line: lineNumber
1562
+ });
1236
1563
  }
1237
1564
  }
1238
- return {
1239
- total: sortedScIds.length,
1240
- covered,
1241
- missing: missingIds.length,
1242
- missingIds,
1243
- refs: refsRecord
1565
+ const parsed = {
1566
+ file,
1567
+ sections: sectionNames,
1568
+ brs,
1569
+ brsWithoutPriority,
1570
+ brsWithInvalidPriority,
1571
+ contractRefs: parseContractRefs(md)
1244
1572
  };
1245
- }
1246
- function toSortedArray(values) {
1247
- return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
1248
- }
1249
- function normalizeGlobs(globs) {
1250
- return globs.map((glob) => glob.trim()).filter((glob) => glob.length > 0);
1251
- }
1252
- function formatError3(error2) {
1253
- if (error2 instanceof Error) {
1254
- return error2.message;
1255
- }
1256
- return String(error2);
1257
- }
1258
-
1259
- // src/core/version.ts
1260
- import { readFile as readFile4 } from "fs/promises";
1261
- import path9 from "path";
1262
- import { fileURLToPath as fileURLToPath2 } from "url";
1263
- async function resolveToolVersion() {
1264
- if ("0.5.0".length > 0) {
1265
- return "0.5.0";
1266
- }
1267
- try {
1268
- const packagePath = resolvePackageJsonPath();
1269
- const raw = await readFile4(packagePath, "utf-8");
1270
- const parsed = JSON.parse(raw);
1271
- const version = typeof parsed.version === "string" ? parsed.version : "";
1272
- return version.length > 0 ? version : "unknown";
1273
- } catch {
1274
- return "unknown";
1573
+ if (specId) {
1574
+ parsed.specId = specId;
1275
1575
  }
1276
- }
1277
- function resolvePackageJsonPath() {
1278
- const base = import.meta.url;
1279
- const basePath = base.startsWith("file:") ? fileURLToPath2(base) : base;
1280
- return path9.resolve(path9.dirname(basePath), "../../package.json");
1576
+ return parsed;
1281
1577
  }
1282
1578
 
1283
1579
  // src/core/validators/contracts.ts
1284
1580
  import { readFile as readFile5 } from "fs/promises";
1285
- import path11 from "path";
1581
+ import path14 from "path";
1286
1582
 
1287
1583
  // src/core/contracts.ts
1288
- import path10 from "path";
1584
+ import path13 from "path";
1289
1585
  import { parse as parseYaml2 } from "yaml";
1290
1586
  function parseStructuredContract(file, text) {
1291
- const ext = path10.extname(file).toLowerCase();
1587
+ const ext = path13.extname(file).toLowerCase();
1292
1588
  if (ext === ".json") {
1293
1589
  return JSON.parse(text);
1294
1590
  }
@@ -1308,9 +1604,9 @@ var SQL_DANGEROUS_PATTERNS = [
1308
1604
  async function validateContracts(root, config) {
1309
1605
  const issues = [];
1310
1606
  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")));
1607
+ issues.push(...await validateUiContracts(path14.join(contractsRoot, "ui")));
1608
+ issues.push(...await validateApiContracts(path14.join(contractsRoot, "api")));
1609
+ issues.push(...await validateDbContracts(path14.join(contractsRoot, "db")));
1314
1610
  const contractIndex = await buildContractIndex(root, config);
1315
1611
  issues.push(...validateDuplicateContractIds(contractIndex));
1316
1612
  return issues;
@@ -1593,7 +1889,7 @@ function issue(code, message, severity, file, rule, refs) {
1593
1889
 
1594
1890
  // src/core/validators/delta.ts
1595
1891
  import { readFile as readFile6 } from "fs/promises";
1596
- import path12 from "path";
1892
+ import path15 from "path";
1597
1893
  var SECTION_RE = /^##\s+変更区分/m;
1598
1894
  var COMPAT_LINE_RE = /^\s*-\s*\[[ xX]\]\s*Compatibility\b/m;
1599
1895
  var CHANGE_LINE_RE = /^\s*-\s*\[[ xX]\]\s*Change\/Improvement\b/m;
@@ -1607,7 +1903,7 @@ async function validateDeltas(root, config) {
1607
1903
  }
1608
1904
  const issues = [];
1609
1905
  for (const pack of packs) {
1610
- const deltaPath = path12.join(pack, "delta.md");
1906
+ const deltaPath = path15.join(pack, "delta.md");
1611
1907
  let text;
1612
1908
  try {
1613
1909
  text = await readFile6(deltaPath, "utf-8");
@@ -1683,7 +1979,7 @@ function issue2(code, message, severity, file, rule, refs) {
1683
1979
 
1684
1980
  // src/core/validators/ids.ts
1685
1981
  import { readFile as readFile7 } from "fs/promises";
1686
- import path13 from "path";
1982
+ import path16 from "path";
1687
1983
  var SC_TAG_RE3 = /^SC-\d{4}$/;
1688
1984
  async function validateDefinedIds(root, config) {
1689
1985
  const issues = [];
@@ -1749,7 +2045,7 @@ function recordId(out, id, file) {
1749
2045
  }
1750
2046
  function formatFileList(files, root) {
1751
2047
  return files.map((file) => {
1752
- const relative = path13.relative(root, file);
2048
+ const relative = path16.relative(root, file);
1753
2049
  return relative.length > 0 ? relative : file;
1754
2050
  }).join(", ");
1755
2051
  }
@@ -2186,7 +2482,7 @@ async function validateTraceability(root, config) {
2186
2482
  if (contractRefs.hasNone && contractRefs.ids.length > 0) {
2187
2483
  issues.push(
2188
2484
  issue6(
2189
- "QFAI-TRACE-021",
2485
+ "QFAI-TRACE-023",
2190
2486
  "Spec \u306E QFAI-CONTRACT-REF \u306B none \u3068\u5951\u7D04 ID \u304C\u6DF7\u5728\u3057\u3066\u3044\u307E\u3059\u3002",
2191
2487
  "error",
2192
2488
  file,
@@ -2218,7 +2514,7 @@ async function validateTraceability(root, config) {
2218
2514
  if (unknownContractIds.length > 0) {
2219
2515
  issues.push(
2220
2516
  issue6(
2221
- "QFAI-TRACE-021",
2517
+ "QFAI-TRACE-024",
2222
2518
  `Spec \u304C\u672A\u77E5\u306E\u5951\u7D04 ID \u3092\u53C2\u7167\u3057\u3066\u3044\u307E\u3059: ${unknownContractIds.join(
2223
2519
  ", "
2224
2520
  )}`,
@@ -2233,11 +2529,62 @@ async function validateTraceability(root, config) {
2233
2529
  for (const file of scenarioFiles) {
2234
2530
  const text = await readFile10(file, "utf-8");
2235
2531
  extractAllIds(text).forEach((id) => upstreamIds.add(id));
2532
+ const scenarioContractRefs = parseContractRefs(text, {
2533
+ allowCommentPrefix: true
2534
+ });
2535
+ if (scenarioContractRefs.lines.length === 0) {
2536
+ issues.push(
2537
+ issue6(
2538
+ "QFAI-TRACE-031",
2539
+ "Scenario \u306B QFAI-CONTRACT-REF \u304C\u3042\u308A\u307E\u305B\u3093\u3002",
2540
+ "error",
2541
+ file,
2542
+ "traceability.scenarioContractRefRequired"
2543
+ )
2544
+ );
2545
+ } else {
2546
+ if (scenarioContractRefs.hasNone && scenarioContractRefs.ids.length > 0) {
2547
+ issues.push(
2548
+ issue6(
2549
+ "QFAI-TRACE-033",
2550
+ "Scenario \u306E QFAI-CONTRACT-REF \u306B none \u3068\u5951\u7D04 ID \u304C\u6DF7\u5728\u3057\u3066\u3044\u307E\u3059\u3002",
2551
+ "error",
2552
+ file,
2553
+ "traceability.scenarioContractRefFormat"
2554
+ )
2555
+ );
2556
+ }
2557
+ if (scenarioContractRefs.invalidTokens.length > 0) {
2558
+ issues.push(
2559
+ issue6(
2560
+ "QFAI-TRACE-032",
2561
+ `Scenario \u306E\u5951\u7D04 ID \u304C\u4E0D\u6B63\u3067\u3059: ${scenarioContractRefs.invalidTokens.join(
2562
+ ", "
2563
+ )}`,
2564
+ "error",
2565
+ file,
2566
+ "traceability.scenarioContractRefFormat",
2567
+ scenarioContractRefs.invalidTokens
2568
+ )
2569
+ );
2570
+ }
2571
+ }
2236
2572
  const { document, errors } = parseScenarioDocument(text, file);
2237
2573
  if (!document || errors.length > 0) {
2238
2574
  continue;
2239
2575
  }
2240
- const atoms = buildScenarioAtoms(document);
2576
+ if (document.scenarios.length !== 1) {
2577
+ issues.push(
2578
+ issue6(
2579
+ "QFAI-TRACE-030",
2580
+ `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})`,
2581
+ "error",
2582
+ file,
2583
+ "traceability.scenarioOnePerFile"
2584
+ )
2585
+ );
2586
+ }
2587
+ const atoms = buildScenarioAtoms(document, scenarioContractRefs.ids);
2241
2588
  const scIdsInFile = /* @__PURE__ */ new Set();
2242
2589
  for (const [index, scenario] of document.scenarios.entries()) {
2243
2590
  const atom = atoms[index];
@@ -2382,7 +2729,7 @@ async function validateTraceability(root, config) {
2382
2729
  if (orphanBrIds.length > 0) {
2383
2730
  issues.push(
2384
2731
  issue6(
2385
- "QFAI_TRACE_BR_ORPHAN",
2732
+ "QFAI-TRACE-009",
2386
2733
  `BR \u304C SC \u306B\u7D10\u3065\u3044\u3066\u3044\u307E\u305B\u3093: ${orphanBrIds.join(", ")}`,
2387
2734
  "error",
2388
2735
  specsRoot,
@@ -2452,17 +2799,19 @@ async function validateTraceability(root, config) {
2452
2799
  );
2453
2800
  }
2454
2801
  }
2455
- if (!config.validation.traceability.allowOrphanContracts) {
2802
+ const orphanPolicy = config.validation.traceability.orphanContractsPolicy;
2803
+ if (orphanPolicy !== "allow") {
2456
2804
  if (contractIds.size > 0) {
2457
2805
  const orphanContracts = Array.from(contractIds).filter(
2458
2806
  (id) => !specContractIds.has(id)
2459
2807
  );
2460
2808
  if (orphanContracts.length > 0) {
2809
+ const severity = orphanPolicy === "warning" ? "warning" : "error";
2461
2810
  issues.push(
2462
2811
  issue6(
2463
2812
  "QFAI-TRACE-022",
2464
2813
  `\u5951\u7D04\u304C Spec \u304B\u3089\u53C2\u7167\u3055\u308C\u3066\u3044\u307E\u305B\u3093: ${orphanContracts.join(", ")}`,
2465
- "error",
2814
+ severity,
2466
2815
  specsRoot,
2467
2816
  "traceability.contractCoverage",
2468
2817
  orphanContracts
@@ -2587,16 +2936,17 @@ function countIssues(issues) {
2587
2936
  // src/core/report.ts
2588
2937
  var ID_PREFIXES2 = ["SPEC", "BR", "SC", "UI", "API", "DB"];
2589
2938
  async function createReportData(root, validation, configResult) {
2590
- const resolved = configResult ?? await loadConfig(root);
2939
+ const resolvedRoot = path17.resolve(root);
2940
+ const resolved = configResult ?? await loadConfig(resolvedRoot);
2591
2941
  const config = resolved.config;
2592
2942
  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");
2943
+ const specsRoot = resolvePath(resolvedRoot, config, "specsDir");
2944
+ const contractsRoot = resolvePath(resolvedRoot, config, "contractsDir");
2945
+ const apiRoot = path17.join(contractsRoot, "api");
2946
+ const uiRoot = path17.join(contractsRoot, "ui");
2947
+ const dbRoot = path17.join(contractsRoot, "db");
2948
+ const srcRoot = resolvePath(resolvedRoot, config, "srcDir");
2949
+ const testsRoot = resolvePath(resolvedRoot, config, "testsDir");
2600
2950
  const specFiles = await collectSpecFiles(specsRoot);
2601
2951
  const scenarioFiles = await collectScenarioFiles(specsRoot);
2602
2952
  const {
@@ -2604,15 +2954,15 @@ async function createReportData(root, validation, configResult) {
2604
2954
  ui: uiFiles,
2605
2955
  db: dbFiles
2606
2956
  } = await collectContractFiles(uiRoot, apiRoot, dbRoot);
2607
- const contractIndex = await buildContractIndex(root, config);
2957
+ const contractIndex = await buildContractIndex(resolvedRoot, config);
2608
2958
  const contractIdList = Array.from(contractIndex.ids);
2609
2959
  const specContractRefs = await collectSpecContractRefs(
2610
2960
  specFiles,
2611
2961
  contractIdList
2612
2962
  );
2613
2963
  const referencedContracts = /* @__PURE__ */ new Set();
2614
- for (const ids of specContractRefs.specToContractIds.values()) {
2615
- ids.forEach((id) => referencedContracts.add(id));
2964
+ for (const entry of specContractRefs.specToContracts.values()) {
2965
+ entry.ids.forEach((id) => referencedContracts.add(id));
2616
2966
  }
2617
2967
  const referencedContractCount = contractIdList.filter(
2618
2968
  (id) => referencedContracts.has(id)
@@ -2621,8 +2971,8 @@ async function createReportData(root, validation, configResult) {
2621
2971
  (id) => !referencedContracts.has(id)
2622
2972
  ).length;
2623
2973
  const contractIdToSpecsRecord = mapToSortedRecord(specContractRefs.idToSpecs);
2624
- const specToContractIdsRecord = mapToSortedRecord(
2625
- specContractRefs.specToContractIds
2974
+ const specToContractsRecord = mapToSpecContractRecord(
2975
+ specContractRefs.specToContracts
2626
2976
  );
2627
2977
  const idsByPrefix = await collectIds([
2628
2978
  ...specFiles,
@@ -2640,24 +2990,28 @@ async function createReportData(root, validation, configResult) {
2640
2990
  srcRoot,
2641
2991
  testsRoot
2642
2992
  );
2643
- const scIds = await collectScIdsFromScenarioFiles(scenarioFiles);
2644
- const scRefsResult = await collectScTestReferences(
2645
- root,
2646
- config.validation.traceability.testFileGlobs,
2647
- config.validation.traceability.testFileExcludeGlobs
2993
+ const resolvedValidationRaw = validation ?? await validateProject(resolvedRoot, resolved);
2994
+ const normalizedValidation = normalizeValidationResult(
2995
+ resolvedRoot,
2996
+ resolvedValidationRaw
2648
2997
  );
2649
- const scCoverage = validation?.traceability?.sc ?? buildScCoverage(scIds, scRefsResult.refs);
2650
- const testFiles = validation?.traceability?.testFiles ?? scRefsResult.scan;
2998
+ const scCoverage = normalizedValidation.traceability.sc;
2999
+ const testFiles = normalizedValidation.traceability.testFiles;
2651
3000
  const scSources = await collectScIdSourcesFromScenarioFiles(scenarioFiles);
2652
- const scSourceRecord = mapToSortedRecord(scSources);
2653
- const resolvedValidation = validation ?? await validateProject(root, resolved);
3001
+ const scSourceRecord = mapToSortedRecord(
3002
+ normalizeScSources(resolvedRoot, scSources)
3003
+ );
2654
3004
  const version = await resolveToolVersion();
3005
+ const reportFormatVersion = 1;
3006
+ const displayRoot = toRelativePath(resolvedRoot, resolvedRoot);
3007
+ const displayConfigPath = toRelativePath(resolvedRoot, configPath);
2655
3008
  return {
2656
3009
  tool: "qfai",
2657
3010
  version,
3011
+ reportFormatVersion,
2658
3012
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2659
- root,
2660
- configPath,
3013
+ root: displayRoot,
3014
+ configPath: displayConfigPath,
2661
3015
  summary: {
2662
3016
  specs: specFiles.length,
2663
3017
  scenarios: scenarioFiles.length,
@@ -2666,7 +3020,7 @@ async function createReportData(root, validation, configResult) {
2666
3020
  ui: uiFiles.length,
2667
3021
  db: dbFiles.length
2668
3022
  },
2669
- counts: resolvedValidation.counts
3023
+ counts: normalizedValidation.counts
2670
3024
  },
2671
3025
  ids: {
2672
3026
  spec: idsByPrefix.SPEC,
@@ -2691,21 +3045,23 @@ async function createReportData(root, validation, configResult) {
2691
3045
  specs: {
2692
3046
  contractRefMissing: specContractRefs.missingRefSpecs.size,
2693
3047
  missingRefSpecs: toSortedArray2(specContractRefs.missingRefSpecs),
2694
- specToContractIds: specToContractIdsRecord
3048
+ specToContracts: specToContractsRecord
2695
3049
  }
2696
3050
  },
2697
- issues: resolvedValidation.issues
3051
+ issues: normalizedValidation.issues
2698
3052
  };
2699
3053
  }
2700
3054
  function formatReportMarkdown(data) {
2701
3055
  const lines = [];
2702
3056
  lines.push("# QFAI Report");
3057
+ lines.push("");
2703
3058
  lines.push(`- \u751F\u6210\u65E5\u6642: ${data.generatedAt}`);
2704
3059
  lines.push(`- \u30EB\u30FC\u30C8: ${data.root}`);
2705
3060
  lines.push(`- \u8A2D\u5B9A: ${data.configPath}`);
2706
3061
  lines.push(`- \u7248: ${data.version}`);
2707
3062
  lines.push("");
2708
3063
  lines.push("## \u6982\u8981");
3064
+ lines.push("");
2709
3065
  lines.push(`- specs: ${data.summary.specs}`);
2710
3066
  lines.push(`- scenarios: ${data.summary.scenarios}`);
2711
3067
  lines.push(
@@ -2716,6 +3072,7 @@ function formatReportMarkdown(data) {
2716
3072
  );
2717
3073
  lines.push("");
2718
3074
  lines.push("## ID\u96C6\u8A08");
3075
+ lines.push("");
2719
3076
  lines.push(formatIdLine("SPEC", data.ids.spec));
2720
3077
  lines.push(formatIdLine("BR", data.ids.br));
2721
3078
  lines.push(formatIdLine("SC", data.ids.sc));
@@ -2724,12 +3081,14 @@ function formatReportMarkdown(data) {
2724
3081
  lines.push(formatIdLine("DB", data.ids.db));
2725
3082
  lines.push("");
2726
3083
  lines.push("## \u30C8\u30EC\u30FC\u30B5\u30D3\u30EA\u30C6\u30A3");
3084
+ lines.push("");
2727
3085
  lines.push(`- \u4E0A\u6D41ID\u691C\u51FA\u6570: ${data.traceability.upstreamIdsFound}`);
2728
3086
  lines.push(
2729
3087
  `- \u30B3\u30FC\u30C9/\u30C6\u30B9\u30C8\u53C2\u7167: ${data.traceability.referencedInCodeOrTests ? "\u3042\u308A" : "\u306A\u3057"}`
2730
3088
  );
2731
3089
  lines.push("");
2732
3090
  lines.push("## \u5951\u7D04\u30AB\u30D0\u30EC\u30C3\u30B8");
3091
+ lines.push("");
2733
3092
  lines.push(`- total: ${data.traceability.contracts.total}`);
2734
3093
  lines.push(`- referenced: ${data.traceability.contracts.referenced}`);
2735
3094
  lines.push(`- orphan: ${data.traceability.contracts.orphan}`);
@@ -2738,6 +3097,7 @@ function formatReportMarkdown(data) {
2738
3097
  );
2739
3098
  lines.push("");
2740
3099
  lines.push("## \u5951\u7D04\u2192Spec");
3100
+ lines.push("");
2741
3101
  const contractToSpecs = data.traceability.contracts.idToSpecs;
2742
3102
  const contractIds = Object.keys(contractToSpecs).sort(
2743
3103
  (a, b) => a.localeCompare(b)
@@ -2756,24 +3116,25 @@ function formatReportMarkdown(data) {
2756
3116
  }
2757
3117
  lines.push("");
2758
3118
  lines.push("## Spec\u2192\u5951\u7D04");
2759
- const specToContracts = data.traceability.specs.specToContractIds;
3119
+ lines.push("");
3120
+ const specToContracts = data.traceability.specs.specToContracts;
2760
3121
  const specIds = Object.keys(specToContracts).sort(
2761
3122
  (a, b) => a.localeCompare(b)
2762
3123
  );
2763
3124
  if (specIds.length === 0) {
2764
3125
  lines.push("- (none)");
2765
3126
  } else {
2766
- for (const specId of specIds) {
2767
- const contractIds2 = specToContracts[specId] ?? [];
2768
- if (contractIds2.length === 0) {
2769
- lines.push(`- ${specId}: (none)`);
2770
- } else {
2771
- lines.push(`- ${specId}: ${contractIds2.join(", ")}`);
2772
- }
2773
- }
3127
+ const rows = specIds.map((specId) => {
3128
+ const entry = specToContracts[specId];
3129
+ const contracts = entry?.status === "missing" ? "(missing)" : entry && entry.ids.length > 0 ? entry.ids.join(", ") : "(none)";
3130
+ const status = entry?.status ?? "missing";
3131
+ return [specId, status, contracts];
3132
+ });
3133
+ lines.push(...formatMarkdownTable(["Spec", "Status", "Contracts"], rows));
2774
3134
  }
2775
3135
  lines.push("");
2776
3136
  lines.push("## Spec\u3067 contract-ref \u672A\u5BA3\u8A00");
3137
+ lines.push("");
2777
3138
  const missingRefSpecs = data.traceability.specs.missingRefSpecs;
2778
3139
  if (missingRefSpecs.length === 0) {
2779
3140
  lines.push("- (none)");
@@ -2784,6 +3145,7 @@ function formatReportMarkdown(data) {
2784
3145
  }
2785
3146
  lines.push("");
2786
3147
  lines.push("## SC\u30AB\u30D0\u30EC\u30C3\u30B8");
3148
+ lines.push("");
2787
3149
  lines.push(`- total: ${data.traceability.sc.total}`);
2788
3150
  lines.push(`- covered: ${data.traceability.sc.covered}`);
2789
3151
  lines.push(`- missing: ${data.traceability.sc.missing}`);
@@ -2813,6 +3175,7 @@ function formatReportMarkdown(data) {
2813
3175
  }
2814
3176
  lines.push("");
2815
3177
  lines.push("## SC\u2192\u53C2\u7167\u30C6\u30B9\u30C8");
3178
+ lines.push("");
2816
3179
  const scRefs = data.traceability.sc.refs;
2817
3180
  const scIds = Object.keys(scRefs).sort((a, b) => a.localeCompare(b));
2818
3181
  if (scIds.length === 0) {
@@ -2829,6 +3192,7 @@ function formatReportMarkdown(data) {
2829
3192
  }
2830
3193
  lines.push("");
2831
3194
  lines.push("## Spec:SC=1:1 \u9055\u53CD");
3195
+ lines.push("");
2832
3196
  const specScIssues = data.issues.filter(
2833
3197
  (item) => item.code === "QFAI-TRACE-012"
2834
3198
  );
@@ -2843,6 +3207,7 @@ function formatReportMarkdown(data) {
2843
3207
  }
2844
3208
  lines.push("");
2845
3209
  lines.push("## Hotspots");
3210
+ lines.push("");
2846
3211
  const hotspots = buildHotspots(data.issues);
2847
3212
  if (hotspots.length === 0) {
2848
3213
  lines.push("- (none)");
@@ -2855,6 +3220,7 @@ function formatReportMarkdown(data) {
2855
3220
  }
2856
3221
  lines.push("");
2857
3222
  lines.push("## \u30C8\u30EC\u30FC\u30B5\u30D3\u30EA\u30C6\u30A3\uFF08\u691C\u8A3C\uFF09");
3223
+ lines.push("");
2858
3224
  const traceIssues = data.issues.filter(
2859
3225
  (item) => item.rule?.startsWith("traceability.") || item.code.startsWith("QFAI_TRACE") || item.code.startsWith("QFAI-TRACE-")
2860
3226
  );
@@ -2870,6 +3236,7 @@ function formatReportMarkdown(data) {
2870
3236
  }
2871
3237
  lines.push("");
2872
3238
  lines.push("## \u691C\u8A3C\u7D50\u679C");
3239
+ lines.push("");
2873
3240
  if (data.issues.length === 0) {
2874
3241
  lines.push("- (none)");
2875
3242
  } else {
@@ -2887,7 +3254,7 @@ function formatReportJson(data) {
2887
3254
  return JSON.stringify(data, null, 2);
2888
3255
  }
2889
3256
  async function collectSpecContractRefs(specFiles, contractIdList) {
2890
- const specToContractIds = /* @__PURE__ */ new Map();
3257
+ const specToContracts = /* @__PURE__ */ new Map();
2891
3258
  const idToSpecs = /* @__PURE__ */ new Map();
2892
3259
  const missingRefSpecs = /* @__PURE__ */ new Set();
2893
3260
  for (const contractId of contractIdList) {
@@ -2896,24 +3263,31 @@ async function collectSpecContractRefs(specFiles, contractIdList) {
2896
3263
  for (const file of specFiles) {
2897
3264
  const text = await readFile11(file, "utf-8");
2898
3265
  const parsed = parseSpec(text, file);
2899
- const specKey = parsed.specId ?? file;
3266
+ const specKey = parsed.specId;
3267
+ if (!specKey) {
3268
+ continue;
3269
+ }
2900
3270
  const refs = parsed.contractRefs;
2901
3271
  if (refs.lines.length === 0) {
2902
3272
  missingRefSpecs.add(specKey);
3273
+ specToContracts.set(specKey, { status: "missing", ids: /* @__PURE__ */ new Set() });
2903
3274
  continue;
2904
3275
  }
2905
- const currentContracts = specToContractIds.get(specKey) ?? /* @__PURE__ */ new Set();
3276
+ const current = specToContracts.get(specKey) ?? {
3277
+ status: "declared",
3278
+ ids: /* @__PURE__ */ new Set()
3279
+ };
2906
3280
  for (const id of refs.ids) {
2907
- currentContracts.add(id);
3281
+ current.ids.add(id);
2908
3282
  const specs = idToSpecs.get(id);
2909
3283
  if (specs) {
2910
3284
  specs.add(specKey);
2911
3285
  }
2912
3286
  }
2913
- specToContractIds.set(specKey, currentContracts);
3287
+ specToContracts.set(specKey, current);
2914
3288
  }
2915
3289
  return {
2916
- specToContractIds,
3290
+ specToContracts,
2917
3291
  idToSpecs,
2918
3292
  missingRefSpecs
2919
3293
  };
@@ -2990,6 +3364,20 @@ function formatList(values) {
2990
3364
  }
2991
3365
  return values.join(", ");
2992
3366
  }
3367
+ function formatMarkdownTable(headers, rows) {
3368
+ const widths = headers.map((header, index) => {
3369
+ const candidates = rows.map((row) => row[index] ?? "");
3370
+ return Math.max(header.length, ...candidates.map((item) => item.length));
3371
+ });
3372
+ const formatRow = (cells) => {
3373
+ const padded = cells.map(
3374
+ (cell, index) => (cell ?? "").padEnd(widths[index] ?? 0)
3375
+ );
3376
+ return `| ${padded.join(" | ")} |`;
3377
+ };
3378
+ const separator = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`;
3379
+ return [formatRow(headers), separator, ...rows.map(formatRow)];
3380
+ }
2993
3381
  function toSortedArray2(values) {
2994
3382
  return Array.from(values).sort((a, b) => a.localeCompare(b));
2995
3383
  }
@@ -3000,6 +3388,27 @@ function mapToSortedRecord(values) {
3000
3388
  }
3001
3389
  return record2;
3002
3390
  }
3391
+ function mapToSpecContractRecord(values) {
3392
+ const record2 = {};
3393
+ for (const [key, entry] of values.entries()) {
3394
+ record2[key] = {
3395
+ status: entry.status,
3396
+ ids: toSortedArray2(entry.ids)
3397
+ };
3398
+ }
3399
+ return record2;
3400
+ }
3401
+ function normalizeScSources(root, sources) {
3402
+ const normalized = /* @__PURE__ */ new Map();
3403
+ for (const [id, files] of sources.entries()) {
3404
+ const mapped = /* @__PURE__ */ new Set();
3405
+ for (const file of files) {
3406
+ mapped.add(toRelativePath(root, file));
3407
+ }
3408
+ normalized.set(id, mapped);
3409
+ }
3410
+ return normalized;
3411
+ }
3003
3412
  function buildHotspots(issues) {
3004
3413
  const map = /* @__PURE__ */ new Map();
3005
3414
  for (const issue7 of issues) {
@@ -3024,39 +3433,54 @@ function buildHotspots(issues) {
3024
3433
 
3025
3434
  // src/cli/commands/report.ts
3026
3435
  async function runReport(options) {
3027
- const root = path15.resolve(options.root);
3436
+ const root = path18.resolve(options.root);
3028
3437
  const configResult = await loadConfig(root);
3029
- const input = configResult.config.output.validateJsonPath;
3030
- const inputPath = path15.isAbsolute(input) ? input : path15.resolve(root, input);
3031
3438
  let validation;
3032
- try {
3033
- validation = await readValidationResult(inputPath);
3034
- } catch (err) {
3035
- if (isMissingFileError5(err)) {
3036
- error(
3037
- [
3038
- `qfai report: \u5165\u529B\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${inputPath}`,
3039
- "",
3040
- "\u307E\u305A qfai validate \u3092\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u4F8B:",
3041
- " qfai validate",
3042
- "\uFF08\u30C7\u30D5\u30A9\u30EB\u30C8\u306E\u51FA\u529B\u5148: .qfai/out/validate.json\uFF09",
3043
- "",
3044
- "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"
3045
- ].join("\n")
3046
- );
3047
- process.exitCode = 2;
3048
- return;
3439
+ if (options.runValidate) {
3440
+ if (options.inputPath) {
3441
+ warn("report: --run-validate \u304C\u6307\u5B9A\u3055\u308C\u305F\u305F\u3081 --in \u306F\u7121\u8996\u3057\u307E\u3059\u3002");
3442
+ }
3443
+ const result = await validateProject(root, configResult);
3444
+ const normalized = normalizeValidationResult(root, result);
3445
+ await writeValidationResult(
3446
+ root,
3447
+ configResult.config.output.validateJsonPath,
3448
+ normalized
3449
+ );
3450
+ validation = normalized;
3451
+ } else {
3452
+ const input = options.inputPath ?? configResult.config.output.validateJsonPath;
3453
+ const inputPath = path18.isAbsolute(input) ? input : path18.resolve(root, input);
3454
+ try {
3455
+ validation = await readValidationResult(inputPath);
3456
+ } catch (err) {
3457
+ if (isMissingFileError5(err)) {
3458
+ error(
3459
+ [
3460
+ `qfai report: \u5165\u529B\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${inputPath}`,
3461
+ "",
3462
+ "\u307E\u305A qfai validate \u3092\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u4F8B:",
3463
+ " qfai validate",
3464
+ "\uFF08\u30C7\u30D5\u30A9\u30EB\u30C8\u306E\u51FA\u529B\u5148: .qfai/out/validate.json\uFF09",
3465
+ "",
3466
+ "\u307E\u305F\u306F report \u306B --run-validate \u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
3467
+ "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"
3468
+ ].join("\n")
3469
+ );
3470
+ process.exitCode = 2;
3471
+ return;
3472
+ }
3473
+ throw err;
3049
3474
  }
3050
- throw err;
3051
3475
  }
3052
3476
  const data = await createReportData(root, validation, configResult);
3053
3477
  const output = options.format === "json" ? formatReportJson(data) : formatReportMarkdown(data);
3054
3478
  const outRoot = resolvePath(root, configResult.config, "outDir");
3055
- const defaultOut = options.format === "json" ? path15.join(outRoot, "report.json") : path15.join(outRoot, "report.md");
3479
+ const defaultOut = options.format === "json" ? path18.join(outRoot, "report.json") : path18.join(outRoot, "report.md");
3056
3480
  const out = options.outPath ?? defaultOut;
3057
- const outPath = path15.isAbsolute(out) ? out : path15.resolve(root, out);
3058
- await mkdir2(path15.dirname(outPath), { recursive: true });
3059
- await writeFile(outPath, `${output}
3481
+ const outPath = path18.isAbsolute(out) ? out : path18.resolve(root, out);
3482
+ await mkdir3(path18.dirname(outPath), { recursive: true });
3483
+ await writeFile2(outPath, `${output}
3060
3484
  `, "utf-8");
3061
3485
  info(
3062
3486
  `report: info=${validation.counts.info} warning=${validation.counts.warning} error=${validation.counts.error}`
@@ -3119,10 +3543,16 @@ function isMissingFileError5(error2) {
3119
3543
  const record2 = error2;
3120
3544
  return record2.code === "ENOENT";
3121
3545
  }
3546
+ async function writeValidationResult(root, outputPath, result) {
3547
+ const abs = path18.isAbsolute(outputPath) ? outputPath : path18.resolve(root, outputPath);
3548
+ await mkdir3(path18.dirname(abs), { recursive: true });
3549
+ await writeFile2(abs, `${JSON.stringify(result, null, 2)}
3550
+ `, "utf-8");
3551
+ }
3122
3552
 
3123
3553
  // src/cli/commands/validate.ts
3124
- import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
3125
- import path16 from "path";
3554
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
3555
+ import path19 from "path";
3126
3556
 
3127
3557
  // src/cli/lib/failOn.ts
3128
3558
  function shouldFail(result, failOn) {
@@ -3137,19 +3567,24 @@ function shouldFail(result, failOn) {
3137
3567
 
3138
3568
  // src/cli/commands/validate.ts
3139
3569
  async function runValidate(options) {
3140
- const root = path16.resolve(options.root);
3570
+ const root = path19.resolve(options.root);
3141
3571
  const configResult = await loadConfig(root);
3142
3572
  const result = await validateProject(root, configResult);
3573
+ const normalized = normalizeValidationResult(root, result);
3143
3574
  const format = options.format ?? "text";
3144
3575
  if (format === "text") {
3145
- emitText(result);
3576
+ emitText(normalized);
3146
3577
  }
3147
3578
  if (format === "github") {
3148
- result.issues.forEach(emitGitHub);
3579
+ const jsonPath = resolveJsonPath(
3580
+ root,
3581
+ configResult.config.output.validateJsonPath
3582
+ );
3583
+ emitGitHubOutput(normalized, root, jsonPath);
3149
3584
  }
3150
- await emitJson(result, root, configResult.config.output.validateJsonPath);
3585
+ await emitJson(normalized, root, configResult.config.output.validateJsonPath);
3151
3586
  const failOn = resolveFailOn(options, configResult.config.validation.failOn);
3152
- return shouldFail(result, failOn) ? 1 : 0;
3587
+ return shouldFail(normalized, failOn) ? 1 : 0;
3153
3588
  }
3154
3589
  function resolveFailOn(options, fallback) {
3155
3590
  if (options.failOn) {
@@ -3174,6 +3609,22 @@ function emitText(result) {
3174
3609
  `
3175
3610
  );
3176
3611
  }
3612
+ function emitGitHubOutput(result, root, jsonPath) {
3613
+ const deduped = dedupeIssues(result.issues);
3614
+ const omitted = Math.max(deduped.length - GITHUB_ANNOTATION_LIMIT, 0);
3615
+ const dropped = Math.max(result.issues.length - deduped.length, 0);
3616
+ emitGitHubSummary(result, {
3617
+ total: deduped.length,
3618
+ omitted,
3619
+ dropped,
3620
+ jsonPath,
3621
+ root
3622
+ });
3623
+ const issues = deduped.slice(0, GITHUB_ANNOTATION_LIMIT);
3624
+ for (const issue7 of issues) {
3625
+ emitGitHub(issue7);
3626
+ }
3627
+ }
3177
3628
  function emitGitHub(issue7) {
3178
3629
  const level = issue7.severity === "error" ? "error" : issue7.severity === "warning" ? "warning" : "notice";
3179
3630
  const file = issue7.file ? `file=${issue7.file}` : "";
@@ -3185,22 +3636,75 @@ function emitGitHub(issue7) {
3185
3636
  `
3186
3637
  );
3187
3638
  }
3639
+ function emitGitHubSummary(result, options) {
3640
+ const summary = [
3641
+ "qfai validate summary:",
3642
+ `error=${result.counts.error}`,
3643
+ `warning=${result.counts.warning}`,
3644
+ `info=${result.counts.info}`,
3645
+ `annotations=${Math.min(options.total, GITHUB_ANNOTATION_LIMIT)}/${options.total}`
3646
+ ].join(" ");
3647
+ process.stdout.write(`${summary}
3648
+ `);
3649
+ if (options.dropped > 0 || options.omitted > 0) {
3650
+ const details = [
3651
+ "qfai validate note:",
3652
+ options.dropped > 0 ? `\u91CD\u8907\u9664\u5916=${options.dropped}` : null,
3653
+ options.omitted > 0 ? `\u4E0A\u9650\u7701\u7565=${options.omitted}` : null
3654
+ ].filter(Boolean).join(" ");
3655
+ process.stdout.write(`${details}
3656
+ `);
3657
+ }
3658
+ const relative = toRelativePath(options.root, options.jsonPath);
3659
+ process.stdout.write(
3660
+ `qfai validate note: \u8A73\u7D30\u306F ${relative} \u307E\u305F\u306F --format text \u3092\u53C2\u7167\u3057\u3066\u304F\u3060\u3055\u3044\u3002
3661
+ `
3662
+ );
3663
+ }
3664
+ function dedupeIssues(issues) {
3665
+ const seen = /* @__PURE__ */ new Set();
3666
+ const deduped = [];
3667
+ for (const issue7 of issues) {
3668
+ const key = issueKey(issue7);
3669
+ if (seen.has(key)) {
3670
+ continue;
3671
+ }
3672
+ seen.add(key);
3673
+ deduped.push(issue7);
3674
+ }
3675
+ return deduped;
3676
+ }
3677
+ function issueKey(issue7) {
3678
+ const file = issue7.file ?? "";
3679
+ const line = issue7.loc?.line ?? "";
3680
+ const column = issue7.loc?.column ?? "";
3681
+ return [issue7.code, issue7.severity, issue7.message, file, line, column].join(
3682
+ "|"
3683
+ );
3684
+ }
3188
3685
  async function emitJson(result, root, jsonPath) {
3189
- const abs = path16.isAbsolute(jsonPath) ? jsonPath : path16.resolve(root, jsonPath);
3190
- await mkdir3(path16.dirname(abs), { recursive: true });
3191
- await writeFile2(abs, `${JSON.stringify(result, null, 2)}
3686
+ const abs = resolveJsonPath(root, jsonPath);
3687
+ await mkdir4(path19.dirname(abs), { recursive: true });
3688
+ await writeFile3(abs, `${JSON.stringify(result, null, 2)}
3192
3689
  `, "utf-8");
3193
3690
  }
3691
+ function resolveJsonPath(root, jsonPath) {
3692
+ return path19.isAbsolute(jsonPath) ? jsonPath : path19.resolve(root, jsonPath);
3693
+ }
3694
+ var GITHUB_ANNOTATION_LIMIT = 100;
3194
3695
 
3195
3696
  // src/cli/lib/args.ts
3196
3697
  function parseArgs(argv, cwd) {
3197
3698
  const options = {
3198
3699
  root: cwd,
3700
+ rootExplicit: false,
3199
3701
  dir: cwd,
3200
3702
  force: false,
3201
3703
  yes: false,
3202
3704
  dryRun: false,
3203
3705
  reportFormat: "md",
3706
+ reportRunValidate: false,
3707
+ doctorFormat: "text",
3204
3708
  validateFormat: "text",
3205
3709
  strict: false,
3206
3710
  help: false
@@ -3216,6 +3720,7 @@ function parseArgs(argv, cwd) {
3216
3720
  switch (arg) {
3217
3721
  case "--root":
3218
3722
  options.root = args[i + 1] ?? options.root;
3723
+ options.rootExplicit = true;
3219
3724
  i += 1;
3220
3725
  break;
3221
3726
  case "--dir":
@@ -3252,11 +3757,27 @@ function parseArgs(argv, cwd) {
3252
3757
  {
3253
3758
  const next = args[i + 1];
3254
3759
  if (next) {
3255
- options.reportOut = next;
3760
+ if (command === "doctor") {
3761
+ options.doctorOut = next;
3762
+ } else {
3763
+ options.reportOut = next;
3764
+ }
3765
+ }
3766
+ }
3767
+ i += 1;
3768
+ break;
3769
+ case "--in":
3770
+ {
3771
+ const next = args[i + 1];
3772
+ if (next) {
3773
+ options.reportIn = next;
3256
3774
  }
3257
3775
  }
3258
3776
  i += 1;
3259
3777
  break;
3778
+ case "--run-validate":
3779
+ options.reportRunValidate = true;
3780
+ break;
3260
3781
  case "--help":
3261
3782
  case "-h":
3262
3783
  options.help = true;
@@ -3283,6 +3804,12 @@ function applyFormatOption(command, value, options) {
3283
3804
  }
3284
3805
  return;
3285
3806
  }
3807
+ if (command === "doctor") {
3808
+ if (value === "text" || value === "json") {
3809
+ options.doctorFormat = value;
3810
+ }
3811
+ return;
3812
+ }
3286
3813
  if (value === "md" || value === "json") {
3287
3814
  options.reportFormat = value;
3288
3815
  }
@@ -3308,18 +3835,34 @@ async function run(argv, cwd) {
3308
3835
  });
3309
3836
  return;
3310
3837
  case "validate":
3311
- process.exitCode = await runValidate({
3312
- root: options.root,
3313
- strict: options.strict,
3314
- format: options.validateFormat,
3315
- ...options.failOn !== void 0 ? { failOn: options.failOn } : {}
3316
- });
3838
+ {
3839
+ const resolvedRoot = await resolveRoot(options);
3840
+ process.exitCode = await runValidate({
3841
+ root: resolvedRoot,
3842
+ strict: options.strict,
3843
+ format: options.validateFormat,
3844
+ ...options.failOn !== void 0 ? { failOn: options.failOn } : {}
3845
+ });
3846
+ }
3317
3847
  return;
3318
3848
  case "report":
3319
- await runReport({
3849
+ {
3850
+ const resolvedRoot = await resolveRoot(options);
3851
+ await runReport({
3852
+ root: resolvedRoot,
3853
+ format: options.reportFormat,
3854
+ ...options.reportOut !== void 0 ? { outPath: options.reportOut } : {},
3855
+ ...options.reportIn !== void 0 ? { inputPath: options.reportIn } : {},
3856
+ ...options.reportRunValidate ? { runValidate: true } : {}
3857
+ });
3858
+ }
3859
+ return;
3860
+ case "doctor":
3861
+ await runDoctor({
3320
3862
  root: options.root,
3321
- format: options.reportFormat,
3322
- ...options.reportOut !== void 0 ? { outPath: options.reportOut } : {}
3863
+ rootExplicit: options.rootExplicit,
3864
+ format: options.doctorFormat,
3865
+ ...options.doctorOut !== void 0 ? { outPath: options.doctorOut } : {}
3323
3866
  });
3324
3867
  return;
3325
3868
  default:
@@ -3335,6 +3878,7 @@ Commands:
3335
3878
  init \u30C6\u30F3\u30D7\u30EC\u3092\u751F\u6210
3336
3879
  validate \u4ED5\u69D8/\u5951\u7D04/\u53C2\u7167\u306E\u691C\u67FB
3337
3880
  report \u691C\u8A3C\u7D50\u679C\u3068\u96C6\u8A08\u3092\u51FA\u529B
3881
+ doctor \u8A2D\u5B9A/\u30D1\u30B9/\u51FA\u529B\u524D\u63D0\u306E\u8A3A\u65AD
3338
3882
 
3339
3883
  Options:
3340
3884
  --root <path> \u5BFE\u8C61\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA
@@ -3344,12 +3888,27 @@ Options:
3344
3888
  --dry-run \u5909\u66F4\u3092\u884C\u308F\u305A\u8868\u793A\u306E\u307F
3345
3889
  --format <text|github> validate \u306E\u51FA\u529B\u5F62\u5F0F
3346
3890
  --format <md|json> report \u306E\u51FA\u529B\u5F62\u5F0F
3891
+ --format <text|json> doctor \u306E\u51FA\u529B\u5F62\u5F0F
3347
3892
  --strict validate: warning \u4EE5\u4E0A\u3067 exit 1
3348
3893
  --fail-on <error|warning|never> validate: \u5931\u6557\u6761\u4EF6
3349
- --out <path> report: \u51FA\u529B\u5148
3894
+ --out <path> report/doctor: \u51FA\u529B\u5148
3895
+ --in <path> report: validate.json \u306E\u5165\u529B\u5148\uFF08config\u3088\u308A\u512A\u5148\uFF09
3896
+ --run-validate report: validate \u3092\u5B9F\u884C\u3057\u3066\u304B\u3089 report \u3092\u751F\u6210
3350
3897
  -h, --help \u30D8\u30EB\u30D7\u8868\u793A
3351
3898
  `;
3352
3899
  }
3900
+ async function resolveRoot(options) {
3901
+ if (options.rootExplicit) {
3902
+ return options.root;
3903
+ }
3904
+ const search = await findConfigRoot(options.root);
3905
+ if (!search.found) {
3906
+ warn(
3907
+ `qfai: qfai.config.yaml \u304C\u898B\u3064\u304B\u3089\u306A\u3044\u305F\u3081 defaultConfig \u3092\u4F7F\u7528\u3057\u307E\u3059 (root=${search.root})`
3908
+ );
3909
+ }
3910
+ return search.root;
3911
+ }
3353
3912
 
3354
3913
  // src/cli/index.ts
3355
3914
  run(process.argv.slice(2), process.cwd()).catch((err) => {