phasegate 0.39.0 → 0.44.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.
@@ -66,6 +66,21 @@ Commands exposed as npm scripts (`npm run <command>`).
66
66
  | `render-errors` | `--format human\|agent\|ci` | Render errors |
67
67
  | `validate-fix` | `--code <code>` | Validate fix code example |
68
68
 
69
+ ### `list-errors` と `render-errors` の使い分け
70
+
71
+ ISSUE-005 P3-10 で明確化された境界:
72
+
73
+ - **`list-errors`** — **定義駆動**。`HarnessError` Value Object の**静的な定義**を出力する。
74
+ コードを実行しないため、常に安定した結果を返す。`--format json` と組み合わせて**異なる
75
+ バージョン間の定義差分を比較**する用途に向く。
76
+ - **`render-errors`** — **ランタイム駆動**。実行時に蓄積されたエラーの**蓄積履歴**を整形する。
77
+ まだエラーが記録されていない環境では空を返すため、テストデータや実行痕跡を前提とする。
78
+ CI ログ向けの詳細フォーマット (`--format ci`) やエージェント送信向けの構造化 (`--format agent`)
79
+ に向く。
80
+
81
+ **差分比較を行いたい場合は `list-errors --format json`** を使い、`render-errors` は runtime 観測用と
82
+ 位置付けること。
83
+
69
84
  ---
70
85
 
71
86
  ## Skill Quality
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.39.0",
3
+ "version": "0.44.0",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "Apache-2.0",
@@ -9,7 +9,7 @@ import type { TemplateGenerator } from '../../domain/services/template-generator
9
9
  import type { GenerateCiTemplateInput } from '../dto/generate-ci-template-input.js';
10
10
  import type { GenerateCiTemplateOutput } from '../dto/generate-ci-template-output.js';
11
11
  import { CiTemplate } from '../../domain/aggregates/ci-template.js';
12
- import { isTemplateType } from '../../domain/types/template-type.js';
12
+ import { isTemplateType, TEMPLATE_TYPES } from '../../domain/types/template-type.js';
13
13
 
14
14
  export class GenerateCiTemplateUseCase {
15
15
  constructor(private readonly templateGenerator: TemplateGenerator) {}
@@ -18,6 +18,10 @@ export class GenerateCiTemplateUseCase {
18
18
  const { presetId, templateType } = input;
19
19
 
20
20
  if (!isTemplateType(templateType)) {
21
+ const validValues = TEMPLATE_TYPES.map((t) => `'${t}'`).join(', ');
22
+ const hint = /github|gitlab|circle|jenkins|travis|actions/i.test(String(templateType))
23
+ ? ` (Hint: --type specifies the template purpose, not the CI platform. Use one of: ${validValues})`
24
+ : ` (valid: ${validValues})`;
21
25
  return {
22
26
  templateType: templateType as any,
23
27
  presetRef: presetId,
@@ -26,7 +30,7 @@ export class GenerateCiTemplateUseCase {
26
30
  failOnWarning: false,
27
31
  validationErrors: [{
28
32
  code: 'CI_TEMPLATE_INVALID_TYPE',
29
- message: `INV-1: Invalid templateType: ${templateType}`,
33
+ message: `INV-1: Invalid templateType: ${templateType}${hint}`,
30
34
  }],
31
35
  };
32
36
  }
@@ -45,8 +45,17 @@ export class ValidatorSystemExecutionAdapter implements ValidatorExecutionPort {
45
45
  },
46
46
 
47
47
  async runDriftDetection(): Promise<DriftItem[]> {
48
- // validator-system does not implement drift detection
49
- return [];
48
+ // ISSUE-005 P1-5: L4-001 の DriftDetectionService を直接呼び出し、
49
+ // phasegate:detect-drift と validate --layer L4 の結果を一致させる
50
+ const { createValidatorSystemModule } = await import('../../../validator-system/composition-root.js');
51
+ const mod = createValidatorSystemModule();
52
+ const reports = await mod.driftDetectionService.detect();
53
+ return reports.map((r) => ({
54
+ direction: r.direction,
55
+ unit: r.unitName,
56
+ element: r.element,
57
+ recommendation: r.recommendation,
58
+ }));
50
59
  },
51
60
  };
52
61
  }
@@ -1,37 +1,37 @@
1
- // @layer infrastructure
2
1
  /**
3
- * Harness Engineering - Pre-commit Integration
2
+ * @unit harness-api
3
+ * @layer presentation
4
4
  *
5
- * Reads harness config, determines which validators to run,
6
- * and executes enabled checks against staged files.
5
+ * Pre-commit CLI entry.
6
+ * Runs L2 validators (phase-gate / metadata / test-quality) against staged
7
+ * TypeScript files. Invoked from `.husky/pre-commit` or `npx phasegate pre-commit`.
7
8
  *
8
- * Called from .husky/pre-commit.
9
- * Exit code 0 = pass, non-zero = block commit.
9
+ * Exit codes:
10
+ * 0 = pass (or nothing to check)
11
+ * 1 = validation failure (commit blocked)
12
+ * 2 = runtime error
10
13
  */
11
14
 
12
- import { execSync } from "node:child_process";
13
- import { loadConfig, isHarnessEnabled } from "../core/config-loader.js";
14
- import { isExcludedPath } from "../core/metadata-parser.js";
15
- import type { HarnessError } from "../core/error-reporter.js";
16
- import { createError, formatForHuman } from "../core/error-reporter.js";
15
+ import { execSync } from 'node:child_process';
16
+ import { createValidatorSystemModule } from '../validator-system/composition-root.js';
17
+ import { HumanValidationResultFormatter } from '../validator-system/presentation/formatters/human-validation-result-formatter.js';
18
+ import type { AggregatedValidationReport } from '../validator-system/application/dto/aggregated-validation-report.js';
19
+ import type { ValidationResultContract } from '../validator-system/application/dto/validation-result-contract.js';
17
20
 
18
- // ─── ANSI Helpers ───
19
-
20
- const GREEN = "\x1b[32m";
21
- const BOLD = "\x1b[1m";
22
- const DIM = "\x1b[2m";
23
- const RED = "\x1b[31m";
24
- const RESET = "\x1b[0m";
25
-
26
- // ─── Staged File Detection ───
21
+ const GREEN = '\x1b[32m';
22
+ const RED = '\x1b[31m';
23
+ const BOLD = '\x1b[1m';
24
+ const DIM = '\x1b[2m';
25
+ const RESET = '\x1b[0m';
27
26
 
28
27
  function getStagedFiles(): string[] {
29
28
  try {
30
- const output = execSync("git diff --cached --name-only --diff-filter=ACM", {
31
- encoding: "utf-8",
29
+ const output = execSync('git diff --cached --name-only --diff-filter=ACM', {
30
+ encoding: 'utf-8',
31
+ stdio: ['ignore', 'pipe', 'ignore'],
32
32
  });
33
33
  return output
34
- .split("\n")
34
+ .split('\n')
35
35
  .map((f) => f.trim())
36
36
  .filter((f) => f.length > 0);
37
37
  } catch {
@@ -39,126 +39,60 @@ function getStagedFiles(): string[] {
39
39
  }
40
40
  }
41
41
 
42
- // ─── Dynamic Validator Loading ───
43
-
44
- type ValidatorFn = (files: string[], config: import("../core/config-schema.js").HarnessConfig) => Array<{
45
- rule: string;
46
- severity: "error" | "warning";
47
- file: string;
48
- message: string;
49
- suggestion?: string;
50
- }>;
51
-
52
- interface ValidatorEntry {
53
- name: string;
54
- harnessKey: keyof import("../core/config-schema.js").HarnessesConfig;
55
- category: import("../core/error-reporter.js").HarnessError["category"];
56
- importPath: string;
57
- exportName: string;
58
- }
59
-
60
- const VALIDATOR_REGISTRY: ValidatorEntry[] = [
61
- { name: "phase-gate", harnessKey: "phaseGate", category: "phase_gate", importPath: "../validators/phase-gate.js", exportName: "runPhaseGateCheck" },
62
- { name: "architecture", harnessKey: "architecture", category: "architecture", importPath: "../validators/architecture.js", exportName: "runArchitectureCheck" },
63
- { name: "dependency", harnessKey: "dependency", category: "dependency", importPath: "../validators/dependency.js", exportName: "runDependencyCheck" },
64
- { name: "test-quality", harnessKey: "testQuality", category: "quality", importPath: "../validators/test-quality.js", exportName: "runTestQualityCheck" },
65
- { name: "security", harnessKey: "security", category: "security", importPath: "../validators/security.js", exportName: "runSecurityCheck" },
66
- { name: "performance", harnessKey: "performance", category: "performance", importPath: "../validators/performance.js", exportName: "runPerformanceCheck" },
67
- { name: "consistency", harnessKey: "consistency", category: "consistency", importPath: "../validators/consistency.js", exportName: "runConsistencyCheck" },
68
- { name: "metadata", harnessKey: "metadata", category: "quality", importPath: "../validators/metadata.js", exportName: "runMetadataCheck" },
69
- ];
70
-
71
- async function loadValidator(entry: ValidatorEntry): Promise<ValidatorFn | null> {
72
- try {
73
- const mod = await import(entry.importPath);
74
- return mod[entry.exportName] ?? null;
75
- } catch {
76
- // Validator not yet implemented, skip gracefully
77
- return null;
78
- }
79
- }
80
-
81
- // ─── Convert Validator Errors to HarnessErrors ───
82
-
83
- function toHarnessErrors(
84
- validatorErrors: Array<{ rule: string; severity: "error" | "warning"; file: string; message: string; suggestion?: string }>,
85
- entry: ValidatorEntry,
86
- ): HarnessError[] {
87
- return validatorErrors.map((err) =>
88
- createError({
89
- code: `HARNESS-${err.rule.toUpperCase().replace(/[^A-Z0-9]/g, "-")}`,
90
- severity: err.severity,
91
- category: entry.category,
92
- location: { file: err.file },
93
- message: {
94
- short: err.message,
95
- detailed: err.message,
96
- agentInstruction: err.suggestion ?? "",
97
- },
98
- metadata: { timestamp: new Date().toISOString(), validator: entry.name, layer: "L2" },
99
- }),
100
- );
42
+ function buildReport(results: readonly ValidationResultContract[]): AggregatedValidationReport {
43
+ const passed = results.filter((r) => r.passed && !r.skipped).length;
44
+ const failed = results.filter((r) => !r.passed && !r.skipped).length;
45
+ const skipped = results.filter((r) => r.skipped).length;
46
+ const allErrors = results.flatMap((r) => r.errors);
47
+ const errorCount = allErrors.filter((e) => e.severity === 'error').length;
48
+ const warnCount = allErrors.filter((e) => e.severity === 'warning').length;
49
+ return {
50
+ overallPassed: failed === 0,
51
+ totalValidators: results.length,
52
+ passedValidators: passed,
53
+ failedValidators: failed,
54
+ skippedValidators: skipped,
55
+ allErrors,
56
+ summary: {
57
+ totalErrors: errorCount,
58
+ totalWarnings: warnCount,
59
+ errorsByLayer: { L2: errorCount, L3: 0, L4: 0 },
60
+ },
61
+ results,
62
+ };
101
63
  }
102
64
 
103
- // ─── Main ───
104
-
105
65
  async function main(): Promise<void> {
106
- const config = loadConfig();
107
-
108
- // Check if L2 (pre-commit) layer is enabled
109
- if (!config.layers.L2_precommit.enabled) {
110
- console.log(`${DIM}[harness] L2 Pre-commit layer is disabled. Skipping.${RESET}`);
111
- return;
112
- }
113
-
114
- // Get staged .ts files, filtering excluded paths
115
- const allStaged = getStagedFiles();
116
- const tsFiles = allStaged
117
- .filter((f) => f.endsWith(".ts"))
118
- .filter((f) => !isExcludedPath(f, config));
66
+ const stagedFiles = getStagedFiles();
67
+ const tsFiles = stagedFiles.filter((f) => f.endsWith('.ts'));
119
68
 
120
69
  if (tsFiles.length === 0) {
121
- console.log(`${DIM}[harness] No relevant TypeScript files staged. Skipping.${RESET}`);
122
- return;
70
+ process.stdout.write(`${DIM}[phasegate] No staged TypeScript files. Skipping.${RESET}\n`);
71
+ process.exit(0);
123
72
  }
124
73
 
125
- console.log(`${BOLD}[harness]${RESET} Pre-commit check (${tsFiles.length} file(s))`);
126
-
127
- const allErrors: HarnessError[] = [];
74
+ process.stdout.write(`${BOLD}[phasegate]${RESET} Pre-commit check (${tsFiles.length} file(s))\n`);
128
75
 
129
- // Load and run enabled validators in order
130
- for (const entry of VALIDATOR_REGISTRY) {
131
- if (!isHarnessEnabled(config, entry.harnessKey, "L2_precommit")) {
132
- continue;
133
- }
76
+ const mod = createValidatorSystemModule();
77
+ const results = await mod.runL2ValidatorsUseCase.execute({
78
+ targetPaths: tsFiles,
79
+ unitName: '',
80
+ currentPhase: '',
81
+ });
134
82
 
135
- const runValidator = await loadValidator(entry);
136
- if (!runValidator) {
137
- continue;
138
- }
83
+ const report = buildReport(results);
84
+ process.stdout.write(`${new HumanValidationResultFormatter().format(report)}\n`);
139
85
 
140
- console.log(` ${DIM}Running ${entry.name}...${RESET}`);
141
- const errors = runValidator(tsFiles, config);
142
- allErrors.push(...toHarnessErrors(errors, entry));
143
- }
144
-
145
- // Report results
146
- const errorCount = allErrors.filter((e) => e.severity === "error").length;
147
-
148
- if (allErrors.length > 0) {
149
- console.log();
150
- console.log(formatForHuman(allErrors, config));
151
- }
152
-
153
- if (errorCount > 0) {
154
- console.log(`\n${RED}${BOLD}[harness] Commit blocked.${RESET}`);
86
+ if (!report.overallPassed) {
87
+ process.stdout.write(`\n${RED}${BOLD}[phasegate] Commit blocked.${RESET}\n`);
155
88
  process.exit(1);
156
89
  }
157
-
158
- console.log(`${GREEN}[harness]${RESET} All checks passed.`);
90
+ process.stdout.write(`\n${GREEN}[phasegate]${RESET} All checks passed.\n`);
91
+ process.exit(0);
159
92
  }
160
93
 
161
94
  main().catch((err) => {
162
- console.error(`${RED}[harness] Unexpected error:${RESET}`, err);
163
- process.exit(1);
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ process.stderr.write(`${RED}[phasegate] Unexpected error:${RESET} ${msg}\n`);
97
+ process.exit(2);
164
98
  });
@@ -85,7 +85,7 @@ Commands:
85
85
  phasegate:complete-check Complete L2-L4 check (--json)
86
86
  phasegate:impact-analysis Impact analysis for story (<storyId>, --json)
87
87
 
88
- ci:generate-template Generate CI template (--preset <id>, --type <type>, --render, --json)
88
+ ci:generate-template Generate CI template (--preset <id>, --type <aidlc-gate|consistency-check|pre-commit>, --render, --json)
89
89
  ci:migrate-agents-md Migrate AGENTS.md (--dry-run, --validate-only, --json)
90
90
  ci:check-repetition Check error repetition (--code <errorCode>, --reset, --json)
91
91
 
@@ -698,8 +698,29 @@ async function main(): Promise<void> {
698
698
  }
699
699
 
700
700
  case 'phasegate:check-phase': {
701
+ // ISSUE-005 P2-6: --help / --json を positional として食わないようにする
702
+ if (hasFlag(args, '--help')) {
703
+ process.stdout.write([
704
+ 'Usage: phasegate phasegate:check-phase [options]',
705
+ '',
706
+ 'Check phase gate for a specific unit.',
707
+ '',
708
+ 'Options:',
709
+ ' --unit <unitId> Target unit ID (e.g., harness-api). If omitted,',
710
+ ' the first positional argument is used.',
711
+ ' --json Output result as JSON.',
712
+ ' --help Show this help.',
713
+ '',
714
+ 'Examples:',
715
+ ' phasegate phasegate:check-phase --unit harness-api',
716
+ ' phasegate phasegate:check-phase harness-api --json',
717
+ '',
718
+ ].join('\n'));
719
+ return;
720
+ }
701
721
  const mod = createHarnessApiModule();
702
- const unit = parseFlag(args, '--unit') ?? args[1] ?? '';
722
+ const positional = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
723
+ const unit = parseFlag(args, '--unit') ?? positional ?? '';
703
724
  const flags: Record<string, boolean | string> = {};
704
725
  if (json) flags.json = true;
705
726
  await mod.handlers.checkPhase.handle({ unit }, flags);
@@ -760,6 +781,25 @@ async function main(): Promise<void> {
760
781
 
761
782
  // ── ci-governance ──
762
783
  case 'ci:generate-template': {
784
+ if (hasFlag(args, '--help')) {
785
+ console.log(`Usage: phasegate ci:generate-template [options]
786
+
787
+ Generates a CI template configuration.
788
+
789
+ Options:
790
+ --preset <id> Preset name (e.g. standard, strict). Required.
791
+ --type <type> Template purpose (NOT CI platform name). One of:
792
+ aidlc-gate — AIDLC phase gate checks
793
+ consistency-check — Doc/code consistency checks
794
+ pre-commit — Pre-commit hook template
795
+ --render Render the template to stdout
796
+ --json Output in JSON format
797
+
798
+ Examples:
799
+ phasegate ci:generate-template --preset standard --type aidlc-gate
800
+ phasegate ci:generate-template --preset strict --type pre-commit --render`);
801
+ process.exit(0);
802
+ }
763
803
  const mod = buildCiGovernance(rootDir);
764
804
  const presetId = parseFlag(args, '--preset') ?? 'default';
765
805
  const templateType = parseFlag(args, '--type') ?? 'aidlc-gate';
@@ -17,13 +17,19 @@ export class GitLogDocumentAgeAdapter implements DocumentAgePort {
17
17
  constructor(
18
18
  private readonly projectRoot: string,
19
19
  private readonly nowProvider: () => Date = () => new Date(),
20
- private readonly gitLogExecutor: (command: string, options: { cwd: string }) => Buffer = execSync,
20
+ private readonly gitLogExecutor: (
21
+ command: string,
22
+ options: { cwd: string; stdio?: readonly ['pipe', 'pipe', 'pipe'] },
23
+ ) => Buffer = execSync,
21
24
  ) {}
22
25
 
23
26
  async getAge(documentPath: string): Promise<DocumentAge> {
24
27
  try {
25
28
  const output = this.gitLogExecutor(`git log --format=%ai -1 -- "${documentPath}"`, {
26
29
  cwd: this.projectRoot,
30
+ // ISSUE-005 P1-3: fresh repo では "fatal: your current branch ... does not have
31
+ // any commits yet" が 34回 stderr に漏れる。pipe に束ねて静音化する。
32
+ stdio: ['pipe', 'pipe', 'pipe'] as const,
27
33
  })
28
34
  .toString()
29
35
  .trim();
@@ -42,9 +42,17 @@ export class GitDiffChangedFilesAdapter {
42
42
  getChangedFiles(): readonly ChangedFile[] {
43
43
  let output: string;
44
44
 
45
+ // ISSUE-005 P1-3: fresh repo (HEAD 未作成) では `git diff ... HEAD` が fatal
46
+ // になるため、HEAD の存在を事前確認して命令を切り替える
47
+ const hasHead = this.hasHead();
48
+ const command = hasHead
49
+ ? 'git diff --name-status --cached HEAD'
50
+ : 'git diff --name-status --cached';
51
+
45
52
  try {
46
- output = childProcess.execSync('git diff --name-status --cached HEAD', {
53
+ output = childProcess.execSync(command, {
47
54
  encoding: 'utf8',
55
+ stdio: ['pipe', 'pipe', 'pipe'],
48
56
  }) as string;
49
57
  } catch (err) {
50
58
  const message = err instanceof Error ? err.message : String(err);
@@ -89,4 +97,15 @@ export class GitDiffChangedFilesAdapter {
89
97
 
90
98
  return Object.freeze(files);
91
99
  }
100
+
101
+ private hasHead(): boolean {
102
+ try {
103
+ childProcess.execSync('git rev-parse --verify HEAD', {
104
+ stdio: ['pipe', 'pipe', 'pipe'],
105
+ });
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
92
111
  }
@@ -40,7 +40,10 @@ export function buildRegressionSuite(baseDir: string): RegressionSuiteCompositio
40
40
  const suiteRegistryPort = new StaticSuiteRegistryAdapter();
41
41
  const testRunnerPort = new VitestTestRunnerAdapter();
42
42
  const configQueryPort = new HarnessConfigQueryAdapter();
43
- const ciGateResultWriterPort = new JsonCiGateResultWriterAdapter(baseDir);
43
+ // ISSUE-005 P2-7: リポジトリ直下ではなく reports/regression/ 配下に出力する
44
+ const ciGateResultWriterPort = new JsonCiGateResultWriterAdapter(
45
+ path.join(baseDir, 'reports', 'regression'),
46
+ );
44
47
  const importAnalyzerPort = new BiomeAstImportAnalyzerAdapter();
45
48
  const v0SpecReaderPort = new FileSystemV0SpecReaderAdapter(baseDir);
46
49
  const migrationMappingRepositoryPort = new MarkdownMigrationMappingRepositoryAdapter(
@@ -13,4 +13,9 @@ export interface RunFullValidationInput {
13
13
  readonly failOnWarning?: boolean;
14
14
  readonly coverageReportPath?: string;
15
15
  readonly requirementMatrixPath?: string;
16
+ /**
17
+ * ISSUE-005 P1-4: 実行レイヤー絞り込み。未指定は全レイヤー実行。
18
+ * 指定された場合、`includeL4` より優先される。
19
+ */
20
+ readonly targetLayers?: readonly ('L2' | 'L3' | 'L4')[];
16
21
  }
@@ -32,22 +32,50 @@ export class RunFullValidationUseCase {
32
32
  }
33
33
 
34
34
  async execute(input: RunFullValidationInput): Promise<AggregatedValidationReport> {
35
+ // ISSUE-005 P1-4: targetLayers で絞り込み。未指定時は従来の includeL4 挙動を維持。
35
36
  const includeL4 = input.includeL4 !== false;
37
+ const defaultLayers: readonly ('L2' | 'L3' | 'L4')[] = includeL4
38
+ ? ['L2', 'L3', 'L4']
39
+ : ['L2', 'L3'];
40
+ const effectiveLayers = input.targetLayers ?? defaultLayers;
41
+ const runL2 = effectiveLayers.includes('L2');
42
+ const runL3 = effectiveLayers.includes('L3');
43
+ const runL4 = effectiveLayers.includes('L4');
36
44
 
37
- const l2Results = await this.l2UseCase.execute({
38
- targetPaths: input.targetPaths,
39
- unitName: input.unitName,
40
- currentPhase: input.currentPhase,
41
- });
45
+ type Result = {
46
+ validatorId: string;
47
+ passed: boolean;
48
+ errors: readonly {
49
+ code: string;
50
+ severity: string;
51
+ message: string;
52
+ suggestion: string;
53
+ [key: string]: unknown;
54
+ }[];
55
+ durationMs: number;
56
+ skipped?: boolean;
57
+ };
42
58
 
43
- const l3Results = await this.l3UseCase.execute({
44
- targetPaths: input.targetPaths,
45
- coverageReportPath: input.coverageReportPath,
46
- requirementMatrixPath: input.requirementMatrixPath,
47
- });
59
+ let l2Results: readonly Result[] = [];
60
+ if (runL2) {
61
+ l2Results = await this.l2UseCase.execute({
62
+ targetPaths: input.targetPaths,
63
+ unitName: input.unitName,
64
+ currentPhase: input.currentPhase,
65
+ });
66
+ }
67
+
68
+ let l3Results: readonly Result[] = [];
69
+ if (runL3) {
70
+ l3Results = await this.l3UseCase.execute({
71
+ targetPaths: input.targetPaths,
72
+ coverageReportPath: input.coverageReportPath,
73
+ requirementMatrixPath: input.requirementMatrixPath,
74
+ });
75
+ }
48
76
 
49
- let l4Results: readonly { validatorId: string; passed: boolean; errors: readonly { code: string; severity: string; message: string; suggestion: string; [key: string]: unknown }[]; durationMs: number; skipped?: boolean }[] = [];
50
- if (includeL4) {
77
+ let l4Results: readonly Result[] = [];
78
+ if (runL4) {
51
79
  l4Results = await this.l4UseCase.execute({
52
80
  targetUnits: input.targetUnits,
53
81
  });
@@ -100,6 +100,7 @@ export interface ValidatorSystemModule {
100
100
  runQuickModeUseCase: RunQuickModeUseCase;
101
101
  aggregateValidationResultsUseCase: AggregateValidationResultsUseCase;
102
102
  runFullValidationUseCase: RunFullValidationUseCase;
103
+ driftDetectionService: DriftDetectionService;
103
104
  handlers: {
104
105
  runValidators: RunValidatorsHandler;
105
106
  runQuickMode: RunQuickModeHandler;
@@ -229,6 +230,7 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
229
230
  runQuickModeUseCase,
230
231
  aggregateValidationResultsUseCase,
231
232
  runFullValidationUseCase,
233
+ driftDetectionService,
232
234
  handlers,
233
235
  };
234
236
  }
@@ -9,10 +9,20 @@ import { DriftReport } from '../../value-objects/drift-report.js';
9
9
 
10
10
  export interface DriftDetectionDesignDocumentPort {
11
11
  getElements(targetUnits?: readonly string[]): Promise<string[]>;
12
+ /**
13
+ * ISSUE-005 P3-9: element → unit 名のマップ。
14
+ * 実装されていれば DriftReport.unitName の解決に使われる (fallback: 'unknown')。
15
+ */
16
+ getElementUnitMap?(targetUnits?: readonly string[]): Promise<Record<string, string>>;
12
17
  }
13
18
 
14
19
  export interface DriftDetectionSourceCodeAnalyzerPort {
15
20
  getElements(targetUnits?: readonly string[]): Promise<string[]>;
21
+ /**
22
+ * ISSUE-005 P3-9: element → unit 名のマップ。
23
+ * 実装されていれば DriftReport.unitName の解決に使われる (fallback: 'unknown')。
24
+ */
25
+ getElementUnitMap?(targetUnits?: readonly string[]): Promise<Record<string, string>>;
16
26
  }
17
27
 
18
28
  export interface DriftDetectionServiceDeps {
@@ -33,6 +43,23 @@ export class DriftDetectionService {
33
43
  const designElements = await this.designDocumentPort.getElements(targetUnits);
34
44
  const codeElements = await this.sourceCodeAnalyzerPort.getElements(targetUnits);
35
45
 
46
+ // ISSUE-005 P3-9: element → unit のマップを取得し、DriftReport.unitName の解決に使う
47
+ const designUnitMap = this.designDocumentPort.getElementUnitMap
48
+ ? await this.designDocumentPort.getElementUnitMap(targetUnits)
49
+ : {};
50
+ const codeUnitMap = this.sourceCodeAnalyzerPort.getElementUnitMap
51
+ ? await this.sourceCodeAnalyzerPort.getElementUnitMap(targetUnits)
52
+ : {};
53
+
54
+ const resolveUnit = (element: string): string => {
55
+ return (
56
+ designUnitMap[element] ??
57
+ codeUnitMap[element] ??
58
+ targetUnits?.[0] ??
59
+ 'unknown'
60
+ );
61
+ };
62
+
36
63
  const designSet = new Set(designElements);
37
64
  const codeSet = new Set(codeElements);
38
65
 
@@ -44,7 +71,7 @@ export class DriftDetectionService {
44
71
  reports.push(
45
72
  DriftReport.create({
46
73
  direction: 'design→code',
47
- unitName: (targetUnits?.[0]) ?? 'unknown',
74
+ unitName: resolveUnit(element),
48
75
  element,
49
76
  description: `設計に存在するがコードに存在しない: ${element}`,
50
77
  recommendation: `${element} をコードに実装してください`,
@@ -59,7 +86,7 @@ export class DriftDetectionService {
59
86
  reports.push(
60
87
  DriftReport.create({
61
88
  direction: 'code→design',
62
- unitName: (targetUnits?.[0]) ?? 'unknown',
89
+ unitName: resolveUnit(element),
63
90
  element,
64
91
  description: `コードに存在するが設計に存在しない: ${element}`,
65
92
  recommendation: `${element} を設計文書に追記するか、コードから削除してください`,
@@ -44,6 +44,23 @@ export class BiomeAstSourceCodeAnalyzerAdapter implements SourceCodeAnalyzerPort
44
44
  const results = await this.analyzeExports(targetUnits);
45
45
  return results.flatMap((result) => result.exports.map((entry) => entry.name));
46
46
  }
47
+
48
+ /**
49
+ * ISSUE-005 P3-9: element 名から unit 名を引けるマップを返す。
50
+ * 同名 export が複数 unit に存在する場合は最初に見つかった unit を採用する。
51
+ */
52
+ async getElementUnitMap(targetUnits?: readonly string[]): Promise<Record<string, string>> {
53
+ const results = await this.analyzeExports(targetUnits);
54
+ const map: Record<string, string> = {};
55
+ for (const result of results) {
56
+ for (const entry of result.exports) {
57
+ if (!(entry.name in map)) {
58
+ map[entry.name] = result.unitName;
59
+ }
60
+ }
61
+ }
62
+ return map;
63
+ }
47
64
  }
48
65
 
49
66
  type ExportType = SourceAnalysisResult['exports'][number]['type'];
@@ -8,9 +8,55 @@ import type { DesignDocumentPort, StructuredDesignDoc } from '../../domain/ports
8
8
  import { readFile, readdir } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
10
 
11
- const SECTION_PATTERN = /^#{2,3}\s+(.+)/gm;
12
11
  const ADR_PATTERN = /ADR-\d{3}/g;
13
12
 
13
+ // ISSUE-005 P3-8: メタ見出し / 議論用セクションを drift 対象から除外するマーカー。
14
+ // 見出し行の直後 (同一行末 or 次の非空行) に置かれたコメントを拾う。
15
+ const SKIP_MARKER = /<!--\s*@drift-check\s*:\s*skip\s*-->/i;
16
+
17
+ // 見出し文字列のうち、デフォルトで drift 対象から外す既知のメタパターン。
18
+ // Unit 設計ドキュメントで頻出する「議論用」「自己評価」系セクション。
19
+ const DEFAULT_META_HEADING_PATTERNS = [
20
+ /engineering[- ]perspective/i,
21
+ /自己評価/,
22
+ /レビュー(観点|コメント)/,
23
+ /議論/,
24
+ /TODO|未決事項|Open\s+Questions?/i,
25
+ /変更履歴|Change\s*Log/i,
26
+ /参考文献|References?/i,
27
+ ];
28
+
29
+ function isMetaHeading(name: string): boolean {
30
+ return DEFAULT_META_HEADING_PATTERNS.some((p) => p.test(name));
31
+ }
32
+
33
+ function extractConceptNames(markdown: string): string[] {
34
+ const lines = markdown.split(/\r?\n/);
35
+ const headings: string[] = [];
36
+ const headingRegex = /^(#{2,3})\s+(.+?)\s*$/;
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const m = headingRegex.exec(lines[i]);
39
+ if (!m) continue;
40
+ const name = m[2].trim();
41
+ // 見出し行自体にスキップマーカーが付いている
42
+ if (SKIP_MARKER.test(lines[i])) continue;
43
+ // 次の非空行にスキップマーカーが付いている
44
+ // ただし、次行が新しい見出しの場合はそれ自身の注釈なので無視する
45
+ let j = i + 1;
46
+ while (j < lines.length && lines[j].trim() === '') j++;
47
+ if (
48
+ j < lines.length &&
49
+ !headingRegex.test(lines[j]) &&
50
+ SKIP_MARKER.test(lines[j])
51
+ ) continue;
52
+ // 既知のメタ見出しは暗黙的にスキップ
53
+ if (isMetaHeading(name)) continue;
54
+ // 名前から末尾のスキップマーカーを落とす (念のため)
55
+ headings.push(name.replace(SKIP_MARKER, '').trim());
56
+ }
57
+ return headings;
58
+ }
59
+
14
60
  export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
15
61
  private readonly docsRoot: string;
16
62
  private readonly cache = new Map<string, StructuredDesignDoc>();
@@ -38,10 +84,7 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
38
84
  const doc: StructuredDesignDoc = {
39
85
  unitName,
40
86
  docPath,
41
- concepts: Array.from(markdown.matchAll(SECTION_PATTERN), (match) => ({
42
- name: match[1].trim(),
43
- type: 'class',
44
- })),
87
+ concepts: extractConceptNames(markdown).map((name) => ({ name, type: 'class' })),
45
88
  layerDependencies: [],
46
89
  adrRefs: Array.from(new Set(markdown.match(ADR_PATTERN) ?? [])),
47
90
  };
@@ -64,6 +107,23 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
64
107
  return docs.flatMap((doc) => doc.concepts.map((concept) => concept.name));
65
108
  }
66
109
 
110
+ /**
111
+ * ISSUE-005 P3-9: element 名から unit 名を引けるマップを返す。
112
+ * 同名 element が複数 unit に存在する場合は最初に見つかった unit を採用する。
113
+ */
114
+ async getElementUnitMap(targetUnits?: readonly string[]): Promise<Record<string, string>> {
115
+ const docs = await this.loadDesignDocuments(targetUnits);
116
+ const map: Record<string, string> = {};
117
+ for (const doc of docs) {
118
+ for (const concept of doc.concepts) {
119
+ if (!(concept.name in map)) {
120
+ map[concept.name] = doc.unitName;
121
+ }
122
+ }
123
+ }
124
+ return map;
125
+ }
126
+
67
127
  private async listUnitNames(): Promise<string[]> {
68
128
  try {
69
129
  const entries = await readdir(this.docsRoot, { withFileTypes: true });
@@ -110,12 +110,21 @@ export class RunValidatorsHandler {
110
110
  return { output, exitCode: l1Report.overallPassed ? 0 : 1 };
111
111
  }
112
112
 
113
+ // ISSUE-005 P1-4: args.layer を targetLayers にマップ
114
+ let targetLayers: readonly ('L2' | 'L3' | 'L4')[] | undefined;
115
+ if (args.layer === 'L2') targetLayers = ['L2'];
116
+ else if (args.layer === 'L3') targetLayers = ['L3'];
117
+ else if (args.layer === 'L4') targetLayers = ['L4'];
118
+ else if (args.layer === 'all') targetLayers = ['L2', 'L3', 'L4'];
119
+ // undefined → フィルタなし(従来挙動)
120
+
113
121
  const report = await this.useCase.execute({
114
122
  targetPaths: args.targetPaths ?? [],
115
123
  unitName: args.unit ?? '',
116
124
  currentPhase: args.phase ?? '',
117
125
  includeL4: !args.noL4,
118
126
  failOnWarning: args.failOnWarning,
127
+ targetLayers,
119
128
  });
120
129
 
121
130
  const format = args.format ?? 'human';