vibepro 0.1.0-alpha.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.
Files changed (89) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +9 -0
  3. package/README.ja.md +448 -0
  4. package/README.md +520 -0
  5. package/agent-instructions/codex/AGENTS.vibepro.md +45 -0
  6. package/bin/vibepro.js +9 -0
  7. package/docs/assets/vibepro-header.png +0 -0
  8. package/package.json +51 -0
  9. package/skills/vibepro-diagnosis-packages/SKILL.md +133 -0
  10. package/skills/vibepro-human-review/SKILL.md +73 -0
  11. package/skills/vibepro-story-refactor/SKILL.md +89 -0
  12. package/skills/vibepro-workflow/SKILL.md +139 -0
  13. package/src/agent-harness-map.js +230 -0
  14. package/src/agent-harness-scanner.js +337 -0
  15. package/src/agent-review.js +2180 -0
  16. package/src/api-boundary-scanner.js +452 -0
  17. package/src/architecture-profiler.js +423 -0
  18. package/src/authorization-scoring.js +149 -0
  19. package/src/brainbase-importer.js +534 -0
  20. package/src/change-risk-classifier.js +195 -0
  21. package/src/check-packs.js +605 -0
  22. package/src/checkpoint-manager.js +233 -0
  23. package/src/cli.js +2213 -0
  24. package/src/code-quality-scanner.js +310 -0
  25. package/src/codex-manager.js +143 -0
  26. package/src/component-style-scanner.js +336 -0
  27. package/src/coverage-report.js +99 -0
  28. package/src/database-access-scanner.js +163 -0
  29. package/src/decision-records.js +315 -0
  30. package/src/design-modernize.js +1435 -0
  31. package/src/design-system.js +1732 -0
  32. package/src/diagnostic-engine.js +1945 -0
  33. package/src/diagram-requirement-resolver.js +194 -0
  34. package/src/doctor.js +677 -0
  35. package/src/environment-graph.js +424 -0
  36. package/src/execution-state.js +849 -0
  37. package/src/explore-evidence.js +425 -0
  38. package/src/flow-design-scanner.js +896 -0
  39. package/src/flow-verifier.js +887 -0
  40. package/src/gesture-interaction-scanner.js +330 -0
  41. package/src/graph-context.js +263 -0
  42. package/src/graphify-adapter.js +189 -0
  43. package/src/html-report.js +1035 -0
  44. package/src/journey-map.js +1299 -0
  45. package/src/language.js +48 -0
  46. package/src/lazy-pattern-detector.js +182 -0
  47. package/src/local-dev-scanner.js +135 -0
  48. package/src/managed-worktree-gate.js +187 -0
  49. package/src/managed-worktree.js +766 -0
  50. package/src/merge-manager.js +501 -0
  51. package/src/network-contract-scanner.js +442 -0
  52. package/src/nocodb-story-sync.js +386 -0
  53. package/src/oss-readiness-scanner.js +417 -0
  54. package/src/performance-evidence.js +756 -0
  55. package/src/performance-measurer.js +591 -0
  56. package/src/pr-manager.js +8220 -0
  57. package/src/presets.js +682 -0
  58. package/src/public-discovery-scanner.js +519 -0
  59. package/src/refactoring-delta-reporter.js +367 -0
  60. package/src/refactoring-opportunity-generator.js +797 -0
  61. package/src/regression-risk-scanner.js +146 -0
  62. package/src/repo-status.js +266 -0
  63. package/src/report-fingerprint.js +188 -0
  64. package/src/report-pr-body-prompt-template.md +108 -0
  65. package/src/report-pr-body-schema.json +95 -0
  66. package/src/report-store.js +135 -0
  67. package/src/report-validator.js +192 -0
  68. package/src/requirement-consistency.js +1066 -0
  69. package/src/runtime-info.js +134 -0
  70. package/src/self-dogfood-scanner.js +476 -0
  71. package/src/session-learning.js +164 -0
  72. package/src/skills-manager.js +157 -0
  73. package/src/spec-drift.js +378 -0
  74. package/src/spec-fingerprint.js +445 -0
  75. package/src/spec-prompt-template.md +155 -0
  76. package/src/spec-schema.json +219 -0
  77. package/src/spec-store.js +258 -0
  78. package/src/spec-validator.js +459 -0
  79. package/src/static-site-scanner.js +316 -0
  80. package/src/story-candidate-generator.js +85 -0
  81. package/src/story-catalog-generator.js +2813 -0
  82. package/src/story-html.js +156 -0
  83. package/src/story-manager.js +2144 -0
  84. package/src/story-task-generator.js +522 -0
  85. package/src/task-manager.js +1029 -0
  86. package/src/terminal-link-scanner.js +238 -0
  87. package/src/usage-report.js +417 -0
  88. package/src/verification-evidence.js +284 -0
  89. package/src/workspace.js +126 -0
@@ -0,0 +1,164 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { getWorkspaceDir, initWorkspace, toWorkspaceRelative } from './workspace.js';
5
+
6
+ const LEARNING_STATUSES = new Set(['candidate', 'reviewed', 'accepted', 'rejected']);
7
+
8
+ export async function recordSessionLearning(repoRoot, options = {}) {
9
+ const root = path.resolve(repoRoot);
10
+ await initWorkspace(root);
11
+ const store = await readLearningStore(root);
12
+ const learning = {
13
+ id: options.id ?? buildLearningId(store.learnings.length + 1),
14
+ kind: options.kind ?? 'repeated_mistake',
15
+ summary: requireText(options.summary, 'harness learn requires --summary <text>'),
16
+ source: options.source ?? 'manual',
17
+ evidence: options.evidence ?? null,
18
+ pattern: options.pattern ?? null,
19
+ status: normalizeLearningStatus(options.status ?? 'candidate'),
20
+ skill_candidate: options.skillCandidate ?? null,
21
+ target_surfaces: parseList(options.targets ?? []),
22
+ created_at: new Date().toISOString()
23
+ };
24
+ const next = {
25
+ ...store,
26
+ updated_at: learning.created_at,
27
+ learnings: [
28
+ learning,
29
+ ...store.learnings.filter((item) => item.id !== learning.id)
30
+ ]
31
+ };
32
+ await writeLearningStore(root, next);
33
+ return {
34
+ learning,
35
+ store: summarizeLearningStore(next),
36
+ artifacts: {
37
+ json: toWorkspaceRelative(root, getLearningStorePath(root))
38
+ }
39
+ };
40
+ }
41
+
42
+ export async function reviewSessionLearnings(repoRoot) {
43
+ const root = path.resolve(repoRoot);
44
+ await initWorkspace(root);
45
+ const store = await readLearningStore(root);
46
+ const markdown = renderSessionLearningsReview(store);
47
+ const reviewPath = path.join(getHarnessDir(root), 'session-learnings-review.md');
48
+ await mkdir(path.dirname(reviewPath), { recursive: true });
49
+ await writeFile(reviewPath, markdown);
50
+ return {
51
+ store: summarizeLearningStore(store),
52
+ learnings: store.learnings,
53
+ artifacts: {
54
+ json: toWorkspaceRelative(root, getLearningStorePath(root)),
55
+ review_markdown: toWorkspaceRelative(root, reviewPath)
56
+ },
57
+ markdown
58
+ };
59
+ }
60
+
61
+ export function renderSessionLearningRecordSummary(result) {
62
+ return `# Session Learning Recorded
63
+
64
+ - id: ${result.learning.id}
65
+ - kind: ${result.learning.kind}
66
+ - status: ${result.learning.status}
67
+ - store: ${result.artifacts.json}
68
+ `;
69
+ }
70
+
71
+ export function renderSessionLearningsReviewSummary(result) {
72
+ return result.markdown;
73
+ }
74
+
75
+ function renderSessionLearningsReview(store) {
76
+ const candidates = store.learnings.filter((item) => item.status === 'candidate');
77
+ const lines = [
78
+ '# Session Learnings Review',
79
+ '',
80
+ `Total: ${store.learnings.length}`,
81
+ `Candidates: ${candidates.length}`,
82
+ '',
83
+ 'This report proposes updates for Skills / AGENTS.md / CLAUDE.md. It does not modify those files automatically.',
84
+ '',
85
+ '## Candidates',
86
+ ''
87
+ ];
88
+ if (candidates.length === 0) {
89
+ lines.push('- none');
90
+ } else {
91
+ for (const item of candidates) {
92
+ lines.push(`### ${item.id}: ${item.summary}`);
93
+ lines.push('');
94
+ lines.push(`- kind: ${item.kind}`);
95
+ lines.push(`- source: ${item.source}`);
96
+ if (item.evidence) lines.push(`- evidence: ${item.evidence}`);
97
+ if (item.pattern) lines.push(`- pattern: ${item.pattern}`);
98
+ lines.push(`- target surfaces: ${item.target_surfaces.join(', ') || '-'}`);
99
+ lines.push(`- skill candidate: ${item.skill_candidate ?? '-'}`);
100
+ lines.push('');
101
+ }
102
+ }
103
+ return `${lines.join('\n')}\n`;
104
+ }
105
+
106
+ async function readLearningStore(root) {
107
+ try {
108
+ return JSON.parse(await readFile(getLearningStorePath(root), 'utf8'));
109
+ } catch (error) {
110
+ if (error.code !== 'ENOENT') throw error;
111
+ return {
112
+ schema_version: '0.1.0',
113
+ updated_at: null,
114
+ learnings: []
115
+ };
116
+ }
117
+ }
118
+
119
+ async function writeLearningStore(root, store) {
120
+ const storePath = getLearningStorePath(root);
121
+ await mkdir(path.dirname(storePath), { recursive: true });
122
+ const tempPath = `${storePath}.${process.pid}.tmp`;
123
+ await writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`);
124
+ await rename(tempPath, storePath);
125
+ }
126
+
127
+ function summarizeLearningStore(store) {
128
+ return {
129
+ schema_version: store.schema_version,
130
+ updated_at: store.updated_at,
131
+ total: store.learnings.length,
132
+ candidate: store.learnings.filter((item) => item.status === 'candidate').length,
133
+ accepted: store.learnings.filter((item) => item.status === 'accepted').length,
134
+ rejected: store.learnings.filter((item) => item.status === 'rejected').length
135
+ };
136
+ }
137
+
138
+ function getHarnessDir(root) {
139
+ return path.join(getWorkspaceDir(root), 'harness');
140
+ }
141
+
142
+ function getLearningStorePath(root) {
143
+ return path.join(getHarnessDir(root), 'session-learnings.json');
144
+ }
145
+
146
+ function buildLearningId(index) {
147
+ return `learning-${String(index).padStart(3, '0')}`;
148
+ }
149
+
150
+ function normalizeLearningStatus(status) {
151
+ if (!LEARNING_STATUSES.has(status)) {
152
+ throw new Error(`learning status must be one of: ${[...LEARNING_STATUSES].join(', ')}`);
153
+ }
154
+ return status;
155
+ }
156
+
157
+ function requireText(value, message) {
158
+ if (!value) throw new Error(message);
159
+ return value;
160
+ }
161
+
162
+ function parseList(values) {
163
+ return values.flatMap((value) => String(value).split(',')).map((value) => value.trim()).filter(Boolean);
164
+ }
@@ -0,0 +1,157 @@
1
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills');
6
+ const TARGET_SKILLS_DIR = path.join('.claude', 'skills');
7
+
8
+ export async function listBundledSkills() {
9
+ const entries = await readdir(SKILLS_DIR, { withFileTypes: true });
10
+ const skills = [];
11
+ for (const entry of entries) {
12
+ if (!entry.isDirectory()) continue;
13
+ const skillPath = path.join(SKILLS_DIR, entry.name, 'SKILL.md');
14
+ const content = await readFile(skillPath, 'utf8');
15
+ const metadata = parseSkillFrontmatter(content);
16
+ skills.push({
17
+ name: metadata.name ?? entry.name,
18
+ description: metadata.description ?? '',
19
+ source_path: skillPath,
20
+ relative_path: path.join('skills', entry.name, 'SKILL.md')
21
+ });
22
+ }
23
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
24
+ }
25
+
26
+ export async function installBundledSkills(repoRoot, options = {}) {
27
+ const root = path.resolve(repoRoot);
28
+ const skills = await listBundledSkills();
29
+ const results = [];
30
+ for (const skill of skills) {
31
+ const source = skill.source_path;
32
+ const target = path.join(root, TARGET_SKILLS_DIR, skill.name, 'SKILL.md');
33
+ const sourceContent = await readFile(source, 'utf8');
34
+ const targetContent = await readOptional(target);
35
+ const exists = targetContent !== null;
36
+ const same = exists && targetContent === sourceContent;
37
+ let status = 'up_to_date';
38
+ if (!same) {
39
+ if (!exists) status = options.dryRun ? 'would_install' : 'installed';
40
+ else if (options.force) status = options.dryRun ? 'would_overwrite' : 'overwritten';
41
+ else status = 'skipped';
42
+ }
43
+ if (!options.dryRun && ['installed', 'overwritten'].includes(status)) {
44
+ await mkdir(path.dirname(target), { recursive: true });
45
+ await writeFile(target, sourceContent);
46
+ }
47
+ results.push({
48
+ name: skill.name,
49
+ description: skill.description,
50
+ status,
51
+ target_path: toDisplayPath(root, target),
52
+ source_path: skill.relative_path
53
+ });
54
+ }
55
+ return {
56
+ mode: 'install',
57
+ dry_run: Boolean(options.dryRun),
58
+ force: Boolean(options.force),
59
+ target_root: root,
60
+ target_dir: path.join(root, TARGET_SKILLS_DIR),
61
+ skills: results,
62
+ summary: summarizeSkillResults(results)
63
+ };
64
+ }
65
+
66
+ export async function verifyBundledSkills(repoRoot) {
67
+ const root = path.resolve(repoRoot);
68
+ const skills = await listBundledSkills();
69
+ const results = [];
70
+ for (const skill of skills) {
71
+ const sourceContent = await readFile(skill.source_path, 'utf8');
72
+ const target = path.join(root, TARGET_SKILLS_DIR, skill.name, 'SKILL.md');
73
+ const targetContent = await readOptional(target);
74
+ let status = 'ok';
75
+ if (targetContent === null) status = 'missing';
76
+ else if (targetContent !== sourceContent) status = 'outdated';
77
+ results.push({
78
+ name: skill.name,
79
+ description: skill.description,
80
+ status,
81
+ target_path: toDisplayPath(root, target),
82
+ source_path: skill.relative_path
83
+ });
84
+ }
85
+ const summary = summarizeSkillResults(results);
86
+ return {
87
+ mode: 'verify',
88
+ target_root: root,
89
+ target_dir: path.join(root, TARGET_SKILLS_DIR),
90
+ overall_status: results.every((item) => item.status === 'ok') ? 'ok' : 'needs_install',
91
+ skills: results,
92
+ summary
93
+ };
94
+ }
95
+
96
+ export function renderSkillsList(skills) {
97
+ return [
98
+ '# VibePro Skills',
99
+ '',
100
+ ...skills.map((skill) => `- ${skill.name}: ${skill.description}`)
101
+ ].join('\n') + '\n';
102
+ }
103
+
104
+ export function renderSkillsInstall(result) {
105
+ return renderSkillResult('VibePro Skills Install', result);
106
+ }
107
+
108
+ export function renderSkillsVerify(result) {
109
+ return renderSkillResult('VibePro Skills Verify', result);
110
+ }
111
+
112
+ function renderSkillResult(title, result) {
113
+ return `${title}
114
+
115
+ Target: ${result.target_dir}
116
+
117
+ ${result.skills.map((skill) => `- ${skill.name}: ${skill.status} (${skill.target_path})`).join('\n')}
118
+
119
+ Summary: ${Object.entries(result.summary).map(([key, value]) => `${key}=${value}`).join(', ')}
120
+ `;
121
+ }
122
+
123
+ function parseSkillFrontmatter(content) {
124
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
125
+ if (!match) return {};
126
+ const metadata = {};
127
+ for (const line of match[1].split('\n')) {
128
+ const index = line.indexOf(':');
129
+ if (index === -1) continue;
130
+ const key = line.slice(0, index).trim();
131
+ const value = line.slice(index + 1).trim().replace(/^["']|["']$/g, '');
132
+ metadata[key] = value;
133
+ }
134
+ return metadata;
135
+ }
136
+
137
+ async function readOptional(filePath) {
138
+ try {
139
+ await stat(filePath);
140
+ return await readFile(filePath, 'utf8');
141
+ } catch (error) {
142
+ if (error.code === 'ENOENT') return null;
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ function summarizeSkillResults(results) {
148
+ return results.reduce((summary, item) => {
149
+ summary[item.status] = (summary[item.status] ?? 0) + 1;
150
+ return summary;
151
+ }, {});
152
+ }
153
+
154
+ function toDisplayPath(root, filePath) {
155
+ const relative = path.relative(root, filePath);
156
+ return relative.startsWith('..') ? filePath : relative;
157
+ }
@@ -0,0 +1,378 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ import { buildSpecFingerprint } from './spec-fingerprint.js';
7
+ import { matchGlob } from './spec-validator.js';
8
+ import { readInferredSpec, readSuppressions, writeSuppressions } from './spec-store.js';
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ const SUPPRESS_DEMOTE_THRESHOLD = 3;
12
+
13
+ export async function buildSpecDrift(repoRoot, options = {}) {
14
+ const root = path.resolve(repoRoot);
15
+ const storyId = options.storyId ?? null;
16
+ if (!storyId) {
17
+ return emptyDrift({ status: 'inconclusive', reason: 'storyId required' });
18
+ }
19
+
20
+ const spec = options.spec ?? (await readInferredSpec(root, storyId));
21
+ if (!spec) {
22
+ return emptyDrift({ storyId, status: 'inconclusive', reason: 'no inferred spec found' });
23
+ }
24
+
25
+ const fingerprint = options.fingerprint ?? (await buildSpecFingerprint(root, { storyId }));
26
+ const suppressions = await readSuppressions(root, storyId);
27
+
28
+ const items = [];
29
+
30
+ for (const clause of spec.clauses ?? []) {
31
+ items.push(...(await detectSpecCodeDrift(root, clause)));
32
+ items.push(...(await detectSpecTestDrift(root, clause)));
33
+ }
34
+
35
+ items.push(...detectCodeTestDrift(spec, fingerprint));
36
+
37
+ if (options.againstRef) {
38
+ items.push(...(await detectSpecPrDrift(root, spec, options.againstRef)));
39
+ }
40
+
41
+ const finalItems = applySuppressions(items, suppressions);
42
+ await updateSuppressionLedger(root, storyId, finalItems, suppressions);
43
+
44
+ const summary = summarizeAxes(finalItems);
45
+ const status = finalItems.length > 0 ? 'drift_detected' : 'clean';
46
+
47
+ return {
48
+ schema_version: '0.1.0',
49
+ story_id: storyId,
50
+ spec_id: spec.previous_spec_id ?? null,
51
+ evaluated_at: new Date().toISOString(),
52
+ status,
53
+ summary,
54
+ items: finalItems
55
+ };
56
+ }
57
+
58
+ export function renderDriftMarkdown(drift) {
59
+ if (!drift) return '# Spec Drift\n\n- 未生成\n';
60
+ const lines = ['# Spec Drift', ''];
61
+ lines.push(`- Status: ${drift.status}`);
62
+ lines.push(`- Story: ${drift.story_id}`);
63
+ lines.push(`- Evaluated at: ${drift.evaluated_at}`);
64
+ lines.push('');
65
+ if (drift.summary) {
66
+ lines.push('| Axis | Count |');
67
+ lines.push('|------|-------|');
68
+ for (const [axis, count] of Object.entries(drift.summary)) {
69
+ lines.push(`| ${axis} | ${count} |`);
70
+ }
71
+ lines.push('');
72
+ }
73
+ if (!drift.items || drift.items.length === 0) {
74
+ lines.push('## Items', '', '- なし');
75
+ return `${lines.join('\n')}\n`;
76
+ }
77
+ lines.push('## Items');
78
+ for (const item of drift.items) {
79
+ lines.push('');
80
+ lines.push(`### ${item.id} [${item.severity}] (${item.axis})`);
81
+ lines.push(`- Clause: ${item.clause_id ?? '-'}`);
82
+ lines.push(`- Title: ${item.title}`);
83
+ if (item.detail) lines.push(`- Detail: ${item.detail}`);
84
+ if (item.suggested_action) lines.push(`- Suggested action: ${item.suggested_action}`);
85
+ }
86
+ return `${lines.join('\n')}\n`;
87
+ }
88
+
89
+ async function detectSpecCodeDrift(repoRoot, clause) {
90
+ const items = [];
91
+ const patterns = clause?.verifiable_by?.code_pattern ?? [];
92
+ for (let i = 0; i < patterns.length; i += 1) {
93
+ items.push(...(await checkPattern(repoRoot, clause, patterns[i], 'spec_code', `code_pattern[${i}]`)));
94
+ }
95
+ for (const ref of clause?.origin?.code_refs ?? []) {
96
+ if (!ref?.file) continue;
97
+ const stats = await statFile(repoRoot, ref.file);
98
+ if (!stats.exists) {
99
+ items.push({
100
+ id: `DRIFT-${randomDriftId()}`,
101
+ axis: 'spec_code',
102
+ clause_id: clause.id,
103
+ severity: 'high',
104
+ title: `${clause.id} の参照ファイルが存在しない`,
105
+ detail: `${ref.file} が repository に無い (Code が削除/移動された可能性)`,
106
+ suggested_action: `clause "${clause.id}" の origin.code_refs を更新するか、Spec を再生成する`
107
+ });
108
+ continue;
109
+ }
110
+ if (ref.anchor && !(await fileIncludes(repoRoot, ref.file, ref.anchor))) {
111
+ items.push({
112
+ id: `DRIFT-${randomDriftId()}`,
113
+ axis: 'spec_code',
114
+ clause_id: clause.id,
115
+ severity: 'medium',
116
+ title: `${clause.id} の anchor が ${ref.file} に見つからない`,
117
+ detail: `anchor "${ref.anchor}" が ${ref.file} に存在しない (リネーム/削除の可能性)`,
118
+ suggested_action: `clause "${clause.id}" の anchor を更新するか、Spec を再生成する`
119
+ });
120
+ }
121
+ }
122
+ return items;
123
+ }
124
+
125
+ async function detectSpecTestDrift(repoRoot, clause) {
126
+ const items = [];
127
+ const patterns = clause?.verifiable_by?.test_pattern ?? [];
128
+ for (let i = 0; i < patterns.length; i += 1) {
129
+ items.push(...(await checkPattern(repoRoot, clause, patterns[i], 'spec_test', `test_pattern[${i}]`)));
130
+ }
131
+ if (patterns.length === 0 && clause.type === 'invariant') {
132
+ items.push({
133
+ id: `DRIFT-${randomDriftId()}`,
134
+ axis: 'spec_test',
135
+ clause_id: clause.id,
136
+ severity: 'low',
137
+ title: `${clause.id} を機械検証する test_pattern が宣言されていない`,
138
+ detail: '不変条件は test_pattern を持つことを推奨',
139
+ suggested_action: `clause "${clause.id}" に verifiable_by.test_pattern を追加`
140
+ });
141
+ }
142
+ return items;
143
+ }
144
+
145
+ async function checkPattern(repoRoot, clause, pattern, axis, locator) {
146
+ const items = [];
147
+ if (!pattern?.file_glob) return items;
148
+ const matched = await matchGlob(repoRoot, pattern.file_glob);
149
+ if (matched.length === 0) {
150
+ items.push({
151
+ id: `DRIFT-${randomDriftId()}`,
152
+ axis,
153
+ clause_id: clause.id,
154
+ severity: 'medium',
155
+ title: `${clause.id}.${locator} の file_glob にマッチするファイルが無い`,
156
+ detail: `file_glob "${pattern.file_glob}" matched 0 files`,
157
+ suggested_action: 'pattern.file_glob を更新するか、不要なら spec から削除する'
158
+ });
159
+ return items;
160
+ }
161
+ if (pattern.must_contain && !(await anyFileContains(repoRoot, matched, pattern.must_contain))) {
162
+ items.push({
163
+ id: `DRIFT-${randomDriftId()}`,
164
+ axis,
165
+ clause_id: clause.id,
166
+ severity: 'high',
167
+ title: `${clause.id}.${locator}.must_contain が満たされていない`,
168
+ detail: `must_contain "${pattern.must_contain}" が ${pattern.file_glob} のいずれにも存在しない`,
169
+ suggested_action: clause.type === 'invariant'
170
+ ? '実装が不変条件を満たしていない可能性。Code を修正するか Spec を再評価する'
171
+ : 'Spec と実装の整合性を確認する'
172
+ });
173
+ }
174
+ if (pattern.must_not_contain) {
175
+ const offender = await firstFileContaining(repoRoot, matched, pattern.must_not_contain);
176
+ if (offender) {
177
+ items.push({
178
+ id: `DRIFT-${randomDriftId()}`,
179
+ axis,
180
+ clause_id: clause.id,
181
+ severity: 'high',
182
+ title: `${clause.id}.${locator}.must_not_contain 違反`,
183
+ detail: `must_not_contain "${pattern.must_not_contain}" が ${offender} に存在する`,
184
+ suggested_action: '不変条件と矛盾する実装。Code を修正するか Spec を再評価する'
185
+ });
186
+ }
187
+ }
188
+ if (pattern.must_cover && !(await anyFileContains(repoRoot, matched, pattern.must_cover))) {
189
+ items.push({
190
+ id: `DRIFT-${randomDriftId()}`,
191
+ axis,
192
+ clause_id: clause.id,
193
+ severity: axis === 'spec_test' ? 'high' : 'medium',
194
+ title: `${clause.id}.${locator}.must_cover が満たされていない`,
195
+ detail: `must_cover "${pattern.must_cover}" が ${pattern.file_glob} のテストで参照されていない`,
196
+ suggested_action: 'テストを追加するか、clause の verifiable_by.test_pattern を見直す'
197
+ });
198
+ }
199
+ return items;
200
+ }
201
+
202
+ function detectCodeTestDrift(spec, fingerprint) {
203
+ const items = [];
204
+ if (!fingerprint?.test_fingerprint?.files) return items;
205
+ const allExpects = fingerprint.test_fingerprint.files
206
+ .flatMap((file) => file.cases.flatMap((entry) => entry.expects))
207
+ .map((entry) => entry.toLowerCase());
208
+ for (const branch of fingerprint.code_fingerprint?.branches ?? []) {
209
+ if (!branch.domain_keywords || branch.domain_keywords.length === 0) continue;
210
+ const condition = (branch.condition ?? '').toLowerCase();
211
+ if (!condition) continue;
212
+ const fragment = condition.split(/\s+/).find((token) => token.length >= 4) ?? condition.slice(0, 12);
213
+ if (!fragment) continue;
214
+ const covered = allExpects.some((expect) => expect.includes(fragment));
215
+ if (!covered) {
216
+ items.push({
217
+ id: `DRIFT-${randomDriftId()}`,
218
+ axis: 'code_test',
219
+ clause_id: null,
220
+ severity: 'low',
221
+ title: `${branch.file} の domain 分岐がテスト で参照されていない`,
222
+ detail: `condition "${branch.condition}" を検証している assertion / case が見当たらない`,
223
+ suggested_action: 'domain 分岐に対する test を追加するか、Spec を更新して分岐の意図を明示する'
224
+ });
225
+ if (items.length >= 8) return items;
226
+ }
227
+ }
228
+ return items;
229
+ }
230
+
231
+ async function detectSpecPrDrift(repoRoot, spec, againstRef) {
232
+ const items = [];
233
+ let stdout;
234
+ try {
235
+ const result = await execFileAsync('git', ['diff', '--name-only', againstRef, '--', '.'], {
236
+ cwd: repoRoot,
237
+ encoding: 'utf8'
238
+ });
239
+ stdout = result.stdout;
240
+ } catch {
241
+ return items;
242
+ }
243
+ const changedFiles = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
244
+ for (const clause of spec.clauses ?? []) {
245
+ const referencedFiles = (clause?.origin?.code_refs ?? []).map((ref) => ref.file).filter(Boolean);
246
+ const touched = referencedFiles.filter((file) => changedFiles.includes(file));
247
+ if (touched.length === 0) continue;
248
+ items.push({
249
+ id: `DRIFT-${randomDriftId()}`,
250
+ axis: 'spec_pr',
251
+ clause_id: clause.id,
252
+ severity: 'medium',
253
+ title: `${clause.id} が参照するコードが PR で変更されている`,
254
+ detail: `${touched.join(', ')} が ${againstRef} と比べて変更されている。Spec の見直しが必要かもしれない`,
255
+ suggested_action: 'Spec を再生成 (vibepro spec fingerprint → write) し、clause の有効性を確認する'
256
+ });
257
+ }
258
+ return items;
259
+ }
260
+
261
+ function summarizeAxes(items) {
262
+ const summary = { spec_code_drift: 0, spec_test_drift: 0, code_test_drift: 0, spec_pr_drift: 0 };
263
+ for (const item of items) {
264
+ if (item.axis === 'spec_code') summary.spec_code_drift += 1;
265
+ else if (item.axis === 'spec_test') summary.spec_test_drift += 1;
266
+ else if (item.axis === 'code_test') summary.code_test_drift += 1;
267
+ else if (item.axis === 'spec_pr') summary.spec_pr_drift += 1;
268
+ }
269
+ return summary;
270
+ }
271
+
272
+ function applySuppressions(items, suppressions) {
273
+ const suppressedKeys = new Map();
274
+ for (const entry of suppressions.items ?? []) {
275
+ if (entry.expires_at && new Date(entry.expires_at) < new Date()) continue;
276
+ suppressedKeys.set(entry.key, entry);
277
+ }
278
+ return items.map((item) => {
279
+ const key = driftKey(item);
280
+ if (suppressedKeys.has(key)) {
281
+ return { ...item, severity: demoteSeverity(item.severity), suppression_applied: true };
282
+ }
283
+ return item;
284
+ });
285
+ }
286
+
287
+ async function updateSuppressionLedger(repoRoot, storyId, items, suppressions) {
288
+ const ledger = { schema_version: suppressions.schema_version ?? '0.1.0', items: [...(suppressions.items ?? [])] };
289
+ const now = new Date().toISOString();
290
+ for (const item of items) {
291
+ if (item.severity !== 'high') continue;
292
+ const key = driftKey(item);
293
+ const existing = ledger.items.find((entry) => entry.key === key);
294
+ if (!existing) {
295
+ ledger.items.push({ key, first_seen_at: now, ack_count: 0, expires_at: null });
296
+ continue;
297
+ }
298
+ existing.ack_count = (existing.ack_count ?? 0) + 1;
299
+ existing.last_seen_at = now;
300
+ if (existing.ack_count >= SUPPRESS_DEMOTE_THRESHOLD && !existing.expires_at) {
301
+ existing.expires_at = futureIso(30);
302
+ existing.demoted_at = now;
303
+ }
304
+ }
305
+ await writeSuppressions(repoRoot, storyId, ledger);
306
+ }
307
+
308
+ function driftKey(item) {
309
+ return `${item.axis}|${item.clause_id ?? ''}|${item.title}`;
310
+ }
311
+
312
+ function demoteSeverity(severity) {
313
+ if (severity === 'high') return 'medium';
314
+ if (severity === 'medium') return 'low';
315
+ return 'low';
316
+ }
317
+
318
+ function futureIso(days) {
319
+ const d = new Date();
320
+ d.setUTCDate(d.getUTCDate() + days);
321
+ return d.toISOString();
322
+ }
323
+
324
+ function emptyDrift({ storyId = null, status, reason }) {
325
+ return {
326
+ schema_version: '0.1.0',
327
+ story_id: storyId,
328
+ spec_id: null,
329
+ evaluated_at: new Date().toISOString(),
330
+ status,
331
+ reason,
332
+ summary: { spec_code_drift: 0, spec_test_drift: 0, code_test_drift: 0, spec_pr_drift: 0 },
333
+ items: []
334
+ };
335
+ }
336
+
337
+ async function fileIncludes(repoRoot, relativeFile, anchor) {
338
+ try {
339
+ const content = await readFile(path.join(repoRoot, relativeFile), 'utf8');
340
+ return content.includes(anchor);
341
+ } catch {
342
+ return false;
343
+ }
344
+ }
345
+
346
+ async function anyFileContains(repoRoot, files, needle) {
347
+ for (const file of files) {
348
+ if (await fileIncludes(repoRoot, file, needle)) return true;
349
+ }
350
+ return false;
351
+ }
352
+
353
+ async function firstFileContaining(repoRoot, files, needle) {
354
+ for (const file of files) {
355
+ if (await fileIncludes(repoRoot, file, needle)) return file;
356
+ }
357
+ return null;
358
+ }
359
+
360
+ async function statFile(repoRoot, relativeFile) {
361
+ try {
362
+ const { stat } = await import('node:fs/promises');
363
+ const stats = await stat(path.join(repoRoot, relativeFile));
364
+ return { exists: stats.isFile() };
365
+ } catch (error) {
366
+ if (error.code === 'ENOENT') return { exists: false };
367
+ throw error;
368
+ }
369
+ }
370
+
371
+ function randomDriftId() {
372
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
373
+ let id = '';
374
+ for (let i = 0; i < 6; i += 1) {
375
+ id += chars[Math.floor(Math.random() * chars.length)];
376
+ }
377
+ return id;
378
+ }