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.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.ja.md +448 -0
- package/README.md +520 -0
- package/agent-instructions/codex/AGENTS.vibepro.md +45 -0
- package/bin/vibepro.js +9 -0
- package/docs/assets/vibepro-header.png +0 -0
- package/package.json +51 -0
- package/skills/vibepro-diagnosis-packages/SKILL.md +133 -0
- package/skills/vibepro-human-review/SKILL.md +73 -0
- package/skills/vibepro-story-refactor/SKILL.md +89 -0
- package/skills/vibepro-workflow/SKILL.md +139 -0
- package/src/agent-harness-map.js +230 -0
- package/src/agent-harness-scanner.js +337 -0
- package/src/agent-review.js +2180 -0
- package/src/api-boundary-scanner.js +452 -0
- package/src/architecture-profiler.js +423 -0
- package/src/authorization-scoring.js +149 -0
- package/src/brainbase-importer.js +534 -0
- package/src/change-risk-classifier.js +195 -0
- package/src/check-packs.js +605 -0
- package/src/checkpoint-manager.js +233 -0
- package/src/cli.js +2213 -0
- package/src/code-quality-scanner.js +310 -0
- package/src/codex-manager.js +143 -0
- package/src/component-style-scanner.js +336 -0
- package/src/coverage-report.js +99 -0
- package/src/database-access-scanner.js +163 -0
- package/src/decision-records.js +315 -0
- package/src/design-modernize.js +1435 -0
- package/src/design-system.js +1732 -0
- package/src/diagnostic-engine.js +1945 -0
- package/src/diagram-requirement-resolver.js +194 -0
- package/src/doctor.js +677 -0
- package/src/environment-graph.js +424 -0
- package/src/execution-state.js +849 -0
- package/src/explore-evidence.js +425 -0
- package/src/flow-design-scanner.js +896 -0
- package/src/flow-verifier.js +887 -0
- package/src/gesture-interaction-scanner.js +330 -0
- package/src/graph-context.js +263 -0
- package/src/graphify-adapter.js +189 -0
- package/src/html-report.js +1035 -0
- package/src/journey-map.js +1299 -0
- package/src/language.js +48 -0
- package/src/lazy-pattern-detector.js +182 -0
- package/src/local-dev-scanner.js +135 -0
- package/src/managed-worktree-gate.js +187 -0
- package/src/managed-worktree.js +766 -0
- package/src/merge-manager.js +501 -0
- package/src/network-contract-scanner.js +442 -0
- package/src/nocodb-story-sync.js +386 -0
- package/src/oss-readiness-scanner.js +417 -0
- package/src/performance-evidence.js +756 -0
- package/src/performance-measurer.js +591 -0
- package/src/pr-manager.js +8220 -0
- package/src/presets.js +682 -0
- package/src/public-discovery-scanner.js +519 -0
- package/src/refactoring-delta-reporter.js +367 -0
- package/src/refactoring-opportunity-generator.js +797 -0
- package/src/regression-risk-scanner.js +146 -0
- package/src/repo-status.js +266 -0
- package/src/report-fingerprint.js +188 -0
- package/src/report-pr-body-prompt-template.md +108 -0
- package/src/report-pr-body-schema.json +95 -0
- package/src/report-store.js +135 -0
- package/src/report-validator.js +192 -0
- package/src/requirement-consistency.js +1066 -0
- package/src/runtime-info.js +134 -0
- package/src/self-dogfood-scanner.js +476 -0
- package/src/session-learning.js +164 -0
- package/src/skills-manager.js +157 -0
- package/src/spec-drift.js +378 -0
- package/src/spec-fingerprint.js +445 -0
- package/src/spec-prompt-template.md +155 -0
- package/src/spec-schema.json +219 -0
- package/src/spec-store.js +258 -0
- package/src/spec-validator.js +459 -0
- package/src/static-site-scanner.js +316 -0
- package/src/story-candidate-generator.js +85 -0
- package/src/story-catalog-generator.js +2813 -0
- package/src/story-html.js +156 -0
- package/src/story-manager.js +2144 -0
- package/src/story-task-generator.js +522 -0
- package/src/task-manager.js +1029 -0
- package/src/terminal-link-scanner.js +238 -0
- package/src/usage-report.js +417 -0
- package/src/verification-evidence.js +284 -0
- package/src/workspace.js +126 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# VibePro PR-body narrative authoring (for the calling AI)
|
|
2
|
+
|
|
3
|
+
You are receiving a JSON payload from `vibepro report fingerprint --kind pr-body`.
|
|
4
|
+
Emit a JSON narrative back into `vibepro report write --kind pr-body --from-stdin`.
|
|
5
|
+
|
|
6
|
+
You do NOT write the whole PR body. VibePro keeps the skeleton (headings, file
|
|
7
|
+
lists, gate-dag, verification commands). You write the **prose that requires
|
|
8
|
+
judgment** — synthesis a template cannot do.
|
|
9
|
+
|
|
10
|
+
## Four slots
|
|
11
|
+
|
|
12
|
+
1. **`summary`** — 1 slot. 2–5 sentences. "What does this PR change and why
|
|
13
|
+
does it matter?" Lead with the user-visible effect, not the file count.
|
|
14
|
+
2. **`review_focus`** — 1–5 slots, one bullet each. The 1–3 things a reviewer
|
|
15
|
+
should examine that are not obvious from the diff. Avoid generic advice
|
|
16
|
+
("check tests"). Be specific: which file's invariant is brittle, which clause
|
|
17
|
+
is contested, which drift item is load-bearing.
|
|
18
|
+
3. **`risks_synthesis`** — 1 slot, 1–3 sentences. Synthesize across the
|
|
19
|
+
mechanical risk list. "What is the dominant failure mode this PR
|
|
20
|
+
introduces?" If there is no real risk beyond the enumeration, output:
|
|
21
|
+
`text: "特記事項なし"`.
|
|
22
|
+
4. **`open_questions`** — 0–5 slots, one item each. Things you could not
|
|
23
|
+
determine from Story+Code+Test+drift. Reviewer must adjudicate. Skip
|
|
24
|
+
entirely if nothing is open.
|
|
25
|
+
|
|
26
|
+
## Rules
|
|
27
|
+
|
|
28
|
+
1. **Cite, do not paraphrase silently.** If you mention a file, list it in
|
|
29
|
+
`citations.files`. If you reference a finding (`VP-TASK-001`), drift item
|
|
30
|
+
(`DRIFT-ABC123`), or clause (`INV-001`), list it in the matching
|
|
31
|
+
citations array. Validator rejects citations to objects that do not exist.
|
|
32
|
+
2. **Numerical claims must use `numerical_claims[]`.** If you write
|
|
33
|
+
"drift items が 3 件", you must also declare
|
|
34
|
+
`{"field":"drift_total_count","value":3}`. Validator compares to fingerprint.
|
|
35
|
+
3. **Reuse TP ids when possible.** If `previous_narrative` contains a slot
|
|
36
|
+
whose text is semantically equivalent, copy its id. Otherwise use
|
|
37
|
+
`TP-NEW-<n>` — validator assigns stable ids.
|
|
38
|
+
4. **No markdown headings.** Plain text per slot item. Bullets render as `- `
|
|
39
|
+
automatically.
|
|
40
|
+
5. **No filler.** "This PR makes changes" / "Tests should pass" / "Please
|
|
41
|
+
review carefully" — delete. Each slot must give the reviewer something
|
|
42
|
+
they couldn't see by reading the diff.
|
|
43
|
+
|
|
44
|
+
## Output format
|
|
45
|
+
|
|
46
|
+
```jsonc
|
|
47
|
+
{
|
|
48
|
+
"schema_version": "0.1.0",
|
|
49
|
+
"story_id": "<copy from fingerprint.story_id>",
|
|
50
|
+
"kind": "pr-body",
|
|
51
|
+
"generated_by": { "caller": "claude-code", "stage": "ai_synthesis" },
|
|
52
|
+
"narrative_slots": [
|
|
53
|
+
{
|
|
54
|
+
"id": "TP-NEW-1",
|
|
55
|
+
"slot": "summary",
|
|
56
|
+
"text": "premium ユーザーの cancelAtPeriodEnd 経路で userType=1 に降格していた回帰を src/lib/services/billing.ts で修正。INV-001 で宣言された不変条件と一致するように分岐を再整列した。",
|
|
57
|
+
"citations": {
|
|
58
|
+
"files": ["src/lib/services/billing.ts"],
|
|
59
|
+
"clause_ids": ["INV-001"]
|
|
60
|
+
},
|
|
61
|
+
"numerical_claims": []
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"id": "TP-NEW-2",
|
|
65
|
+
"slot": "review_focus",
|
|
66
|
+
"text": "src/lib/services/billing.ts:142 の早期 return が、テスト対象外の status='premium_pending_cancel' 経路を引き起こす。テスト追加が無いまま invariant に依存しているので、reviewer は手で経路を辿るべき。",
|
|
67
|
+
"citations": {
|
|
68
|
+
"files": ["src/lib/services/billing.ts"]
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "TP-NEW-3",
|
|
73
|
+
"slot": "risks_synthesis",
|
|
74
|
+
"text": "drift items は spec_test 軸に偏っており、INV-001 を機械検証する test が無い。実装が変更されたら回帰検出が手作業になる。",
|
|
75
|
+
"citations": {
|
|
76
|
+
"clause_ids": ["INV-001"],
|
|
77
|
+
"drift_ids": ["DRIFT-AB12CD"]
|
|
78
|
+
},
|
|
79
|
+
"numerical_claims": [
|
|
80
|
+
{ "field": "drift_high_count", "value": 1 }
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## What VibePro does with your output
|
|
88
|
+
|
|
89
|
+
- JSON schema validation.
|
|
90
|
+
- Per-slot: every `citations.files[]` must exist in repo. Every
|
|
91
|
+
`finding_ids[]` must appear in `evidence.findings[]`. Every `clause_ids[]`
|
|
92
|
+
must appear in inferred spec. Every `drift_ids[]` must appear in drift.json.
|
|
93
|
+
Every `numerical_claims[]` must match fingerprint values.
|
|
94
|
+
- TP id stabilization via text similarity vs previous narrative.
|
|
95
|
+
- Writes `.vibepro/report/<story-id>/pr-body/narrative.json`.
|
|
96
|
+
- `vibepro pr prepare` splices your text into pr-body.md as 4 dedicated
|
|
97
|
+
sections at the top, attributed to TP ids.
|
|
98
|
+
|
|
99
|
+
## Common mistakes
|
|
100
|
+
|
|
101
|
+
- **Generic summary.** "This PR addresses the story." Useless. Tell the
|
|
102
|
+
reviewer the *causal mechanism* of the change.
|
|
103
|
+
- **Inventing IDs.** If you write `DRIFT-XYZ123` but it isn't in
|
|
104
|
+
`fingerprint.drift.items[]`, you will be rejected. Always copy ids verbatim
|
|
105
|
+
from the fingerprint.
|
|
106
|
+
- **Numerical drift.** Writing "5 drift items" when there are 3 → rejection.
|
|
107
|
+
- **Filling open_questions with noise.** If you have no real open question,
|
|
108
|
+
return an empty array.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://vibepro.local/schemas/report-pr-body.json",
|
|
4
|
+
"title": "VibePro PR-body narrative",
|
|
5
|
+
"description": "AI-authored narrative slots inserted into pr-body.md. VibePro keeps the section skeleton fixed; AI only writes the prose inside the declared slots, citing only files / findings / clauses / drift items that exist.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schema_version", "story_id", "kind", "narrative_slots"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": { "type": "string", "const": "0.1.0" },
|
|
10
|
+
"story_id": { "type": "string", "minLength": 1 },
|
|
11
|
+
"kind": { "type": "string", "const": "pr-body" },
|
|
12
|
+
"generated_at": { "type": "string" },
|
|
13
|
+
"generated_by": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"caller": { "type": "string" },
|
|
17
|
+
"stage": { "type": "string", "enum": ["ai_synthesis", "static_fallback"] }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"inputs_digest": { "type": "object" },
|
|
21
|
+
"previous_report_id": { "type": ["string", "null"] },
|
|
22
|
+
"narrative_slots": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": { "$ref": "#/$defs/slot" }
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"$defs": {
|
|
28
|
+
"slot": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"required": ["id", "slot", "text"],
|
|
31
|
+
"properties": {
|
|
32
|
+
"id": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Talking-point id. Use TP-<n>. New slots may use TP-NEW-<n>; validator assigns stable ids."
|
|
35
|
+
},
|
|
36
|
+
"slot": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"enum": ["summary", "review_focus", "risks_synthesis", "open_questions"],
|
|
39
|
+
"description": "Where in pr-body.md the text is inserted. One id per slot per run; multiple ids per slot allowed for review_focus and open_questions only."
|
|
40
|
+
},
|
|
41
|
+
"text": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"minLength": 4,
|
|
44
|
+
"description": "Plain prose / single bullet. No markdown headings. <= 280 chars per slot item recommended."
|
|
45
|
+
},
|
|
46
|
+
"citations": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"description": "What the text refers to. Validator rejects any reference to a non-existent file / finding / clause / drift item.",
|
|
49
|
+
"properties": {
|
|
50
|
+
"files": {
|
|
51
|
+
"type": "array",
|
|
52
|
+
"items": { "type": "string" }
|
|
53
|
+
},
|
|
54
|
+
"finding_ids": {
|
|
55
|
+
"type": "array",
|
|
56
|
+
"items": { "type": "string" }
|
|
57
|
+
},
|
|
58
|
+
"clause_ids": {
|
|
59
|
+
"type": "array",
|
|
60
|
+
"items": { "type": "string" }
|
|
61
|
+
},
|
|
62
|
+
"drift_ids": {
|
|
63
|
+
"type": "array",
|
|
64
|
+
"items": { "type": "string" }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"numerical_claims": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"description": "Claims about counts that must match fingerprint values. Validator rejects on contradiction.",
|
|
71
|
+
"items": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"required": ["field", "value"],
|
|
74
|
+
"properties": {
|
|
75
|
+
"field": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"enum": [
|
|
78
|
+
"changed_files_count",
|
|
79
|
+
"drift_high_count",
|
|
80
|
+
"drift_total_count",
|
|
81
|
+
"requirement_invariant_count",
|
|
82
|
+
"requirement_contradiction_count",
|
|
83
|
+
"acceptance_criteria_count"
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
"value": { "type": "integer", "minimum": 0 }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"first_seen_at": { "type": "string" },
|
|
91
|
+
"last_revised_at": { "type": "string" }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, writeFile, rm } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { similarity } from './spec-store.js';
|
|
5
|
+
import { WORKSPACE_DIR } from './workspace.js';
|
|
6
|
+
|
|
7
|
+
export const REPORT_SCHEMA_VERSION = '0.1.0';
|
|
8
|
+
export const REPORT_KINDS = new Set(['pr-body']);
|
|
9
|
+
const HISTORY_KEEP = 10;
|
|
10
|
+
const TP_SIMILARITY_THRESHOLD = 0.7;
|
|
11
|
+
|
|
12
|
+
export function getReportDir(repoRoot, storyId, kind) {
|
|
13
|
+
if (!storyId) throw new Error('storyId is required');
|
|
14
|
+
if (!REPORT_KINDS.has(kind)) throw new Error(`Unsupported report kind: ${kind}`);
|
|
15
|
+
return path.join(path.resolve(repoRoot), WORKSPACE_DIR, 'report', storyId, kind);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getNarrativeFile(repoRoot, storyId, kind) {
|
|
19
|
+
return path.join(getReportDir(repoRoot, storyId, kind), 'narrative.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function ensureReportDir(repoRoot, storyId, kind) {
|
|
23
|
+
const dir = getReportDir(repoRoot, storyId, kind);
|
|
24
|
+
await mkdir(path.join(dir, 'narrative.history'), { recursive: true });
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function readNarrative(repoRoot, storyId, kind) {
|
|
29
|
+
if (!storyId) return null;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(await readFile(getNarrativeFile(repoRoot, storyId, kind), 'utf8'));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT') return null;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function writeNarrative(repoRoot, storyId, kind, narrative) {
|
|
39
|
+
await ensureReportDir(repoRoot, storyId, kind);
|
|
40
|
+
const target = getNarrativeFile(repoRoot, storyId, kind);
|
|
41
|
+
await writeFile(target, `${JSON.stringify(narrative, null, 2)}\n`);
|
|
42
|
+
|
|
43
|
+
const historyDir = path.join(getReportDir(repoRoot, storyId, kind), 'narrative.history');
|
|
44
|
+
const stamp = (narrative.generated_at ?? new Date().toISOString()).replace(/[:.]/g, '-');
|
|
45
|
+
const historyPath = path.join(historyDir, `narrative-${stamp}.json`);
|
|
46
|
+
await writeFile(historyPath, `${JSON.stringify(narrative, null, 2)}\n`);
|
|
47
|
+
await pruneHistory(historyDir);
|
|
48
|
+
return target;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function pruneHistory(historyDir) {
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = await readdir(historyDir);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code === 'ENOENT') return;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
const sorted = entries
|
|
60
|
+
.filter((name) => name.startsWith('narrative-') && name.endsWith('.json'))
|
|
61
|
+
.sort()
|
|
62
|
+
.reverse();
|
|
63
|
+
const stale = sorted.slice(HISTORY_KEEP);
|
|
64
|
+
if (stale.length === 0) return;
|
|
65
|
+
await Promise.all(stale.map((name) => rm(path.join(historyDir, name), { force: true })));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function stabilizeTalkingPointIds(narrative, previous) {
|
|
69
|
+
if (!narrative || !Array.isArray(narrative.narrative_slots)) {
|
|
70
|
+
return { ...narrative, narrative_slots: [] };
|
|
71
|
+
}
|
|
72
|
+
if (!previous || !Array.isArray(previous.narrative_slots)) {
|
|
73
|
+
return assignFreshTpIds(narrative);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const usedPrevious = new Set();
|
|
77
|
+
const usedNew = new Set();
|
|
78
|
+
const issuedAt = new Date().toISOString();
|
|
79
|
+
|
|
80
|
+
const slots = narrative.narrative_slots.map((slot) => {
|
|
81
|
+
let best = null;
|
|
82
|
+
for (const prev of previous.narrative_slots) {
|
|
83
|
+
if (usedPrevious.has(prev.id)) continue;
|
|
84
|
+
if (prev.slot !== slot.slot) continue;
|
|
85
|
+
const score = similarity(slot.text, prev.text);
|
|
86
|
+
if (!best || score > best.score) best = { slot: prev, score };
|
|
87
|
+
}
|
|
88
|
+
if (best && best.score >= TP_SIMILARITY_THRESHOLD) {
|
|
89
|
+
usedPrevious.add(best.slot.id);
|
|
90
|
+
usedNew.add(best.slot.id);
|
|
91
|
+
return {
|
|
92
|
+
...slot,
|
|
93
|
+
id: best.slot.id,
|
|
94
|
+
first_seen_at: best.slot.first_seen_at ?? issuedAt,
|
|
95
|
+
last_revised_at: issuedAt
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
...slot,
|
|
100
|
+
id: nextFreshTpId(usedNew, previous, issuedAt),
|
|
101
|
+
first_seen_at: issuedAt,
|
|
102
|
+
last_revised_at: issuedAt
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return { ...narrative, narrative_slots: slots };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function assignFreshTpIds(narrative) {
|
|
110
|
+
const usedNew = new Set();
|
|
111
|
+
const issuedAt = new Date().toISOString();
|
|
112
|
+
const slots = narrative.narrative_slots.map((slot) => ({
|
|
113
|
+
...slot,
|
|
114
|
+
id: nextFreshTpId(usedNew, null, issuedAt),
|
|
115
|
+
first_seen_at: slot.first_seen_at ?? issuedAt,
|
|
116
|
+
last_revised_at: issuedAt
|
|
117
|
+
}));
|
|
118
|
+
return { ...narrative, narrative_slots: slots };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nextFreshTpId(usedNew, previous, issuedAt) {
|
|
122
|
+
const reserved = new Set(usedNew);
|
|
123
|
+
if (previous && Array.isArray(previous.narrative_slots)) {
|
|
124
|
+
for (const slot of previous.narrative_slots) reserved.add(slot.id);
|
|
125
|
+
}
|
|
126
|
+
let n = 1;
|
|
127
|
+
while (reserved.has(`TP-${pad3(n)}`)) n += 1;
|
|
128
|
+
const id = `TP-${pad3(n)}`;
|
|
129
|
+
usedNew.add(id);
|
|
130
|
+
return id;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pad3(n) {
|
|
134
|
+
return String(n).padStart(3, '0');
|
|
135
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { readDrift, readInferredSpec } from './spec-store.js';
|
|
5
|
+
|
|
6
|
+
const SLOT_KINDS = new Set(['summary', 'review_focus', 'risks_synthesis', 'open_questions']);
|
|
7
|
+
const SLOT_LIMITS = {
|
|
8
|
+
summary: { min: 1, max: 1 },
|
|
9
|
+
review_focus: { min: 0, max: 5 },
|
|
10
|
+
risks_synthesis: { min: 1, max: 1 },
|
|
11
|
+
open_questions: { min: 0, max: 5 }
|
|
12
|
+
};
|
|
13
|
+
const NUMERICAL_FIELDS = new Set([
|
|
14
|
+
'changed_files_count',
|
|
15
|
+
'drift_high_count',
|
|
16
|
+
'drift_total_count',
|
|
17
|
+
'requirement_invariant_count',
|
|
18
|
+
'requirement_contradiction_count',
|
|
19
|
+
'acceptance_criteria_count'
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export async function validateReportNarrative(repoRoot, narrative, fingerprint, options = {}) {
|
|
23
|
+
const errors = [];
|
|
24
|
+
const warnings = [];
|
|
25
|
+
|
|
26
|
+
if (!narrative || typeof narrative !== 'object') {
|
|
27
|
+
return { ok: false, errors: [{ code: 'invalid_root', message: 'narrative must be a JSON object' }], warnings };
|
|
28
|
+
}
|
|
29
|
+
if (narrative.schema_version !== '0.1.0') {
|
|
30
|
+
errors.push({ code: 'schema_version', message: `expected schema_version "0.1.0", got "${narrative.schema_version}"` });
|
|
31
|
+
}
|
|
32
|
+
if (narrative.kind !== 'pr-body') {
|
|
33
|
+
errors.push({ code: 'kind', message: `narrative.kind must be "pr-body"` });
|
|
34
|
+
}
|
|
35
|
+
if (!narrative.story_id || typeof narrative.story_id !== 'string') {
|
|
36
|
+
errors.push({ code: 'story_id', message: 'story_id is required' });
|
|
37
|
+
}
|
|
38
|
+
if (options.expectedStoryId && narrative.story_id !== options.expectedStoryId) {
|
|
39
|
+
errors.push({
|
|
40
|
+
code: 'story_id_mismatch',
|
|
41
|
+
message: `narrative.story_id "${narrative.story_id}" does not match expected "${options.expectedStoryId}"`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(narrative.narrative_slots)) {
|
|
45
|
+
errors.push({ code: 'slots_missing', message: 'narrative_slots must be an array' });
|
|
46
|
+
return { ok: false, errors, warnings, narrative };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const slotCounts = countSlots(narrative.narrative_slots);
|
|
50
|
+
for (const [slot, { min, max }] of Object.entries(SLOT_LIMITS)) {
|
|
51
|
+
const count = slotCounts.get(slot) ?? 0;
|
|
52
|
+
if (count < min) {
|
|
53
|
+
errors.push({ code: 'slot_min', message: `slot "${slot}" requires at least ${min} entry, got ${count}` });
|
|
54
|
+
}
|
|
55
|
+
if (count > max) {
|
|
56
|
+
errors.push({ code: 'slot_max', message: `slot "${slot}" allows at most ${max} entries, got ${count}` });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const driftItems = fingerprint?.drift?.items ?? [];
|
|
61
|
+
const driftIds = new Set(driftItems.map((item) => item.id));
|
|
62
|
+
const findingIds = new Set((fingerprint?.findings ?? []).map((entry) => entry.id));
|
|
63
|
+
const clauseIds = new Set((fingerprint?.inferred_spec?.clauses ?? []).map((entry) => entry.id));
|
|
64
|
+
const numericalTruth = fingerprint?.numerical_truth ?? {};
|
|
65
|
+
|
|
66
|
+
for (let index = 0; index < narrative.narrative_slots.length; index += 1) {
|
|
67
|
+
const slot = narrative.narrative_slots[index];
|
|
68
|
+
const slotErrors = await validateSlot(repoRoot, slot, index, {
|
|
69
|
+
driftIds, findingIds, clauseIds, numericalTruth
|
|
70
|
+
});
|
|
71
|
+
errors.push(...slotErrors);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
ok: errors.length === 0,
|
|
76
|
+
errors,
|
|
77
|
+
warnings,
|
|
78
|
+
narrative
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function countSlots(slots) {
|
|
83
|
+
const counts = new Map();
|
|
84
|
+
for (const slot of slots) {
|
|
85
|
+
if (!slot || typeof slot !== 'object') continue;
|
|
86
|
+
counts.set(slot.slot, (counts.get(slot.slot) ?? 0) + 1);
|
|
87
|
+
}
|
|
88
|
+
return counts;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function validateSlot(repoRoot, slot, index, ctx) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
const locator = slot?.id ? `narrative_slots[${index}] (${slot.id})` : `narrative_slots[${index}]`;
|
|
94
|
+
if (!slot || typeof slot !== 'object') {
|
|
95
|
+
errors.push({ code: 'slot_shape', message: `${locator} must be an object` });
|
|
96
|
+
return errors;
|
|
97
|
+
}
|
|
98
|
+
if (!slot.id || typeof slot.id !== 'string') {
|
|
99
|
+
errors.push({ code: 'slot_id', message: `${locator}.id required` });
|
|
100
|
+
}
|
|
101
|
+
if (!SLOT_KINDS.has(slot.slot)) {
|
|
102
|
+
errors.push({
|
|
103
|
+
code: 'slot_kind',
|
|
104
|
+
message: `${locator}.slot must be one of ${[...SLOT_KINDS].join('|')}`
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (typeof slot.text !== 'string' || slot.text.trim().length < 4) {
|
|
108
|
+
errors.push({ code: 'slot_text', message: `${locator}.text must be a non-empty string` });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const citations = slot.citations ?? {};
|
|
112
|
+
for (const file of citations.files ?? []) {
|
|
113
|
+
const fileResult = await fileExists(repoRoot, file);
|
|
114
|
+
if (!fileResult) {
|
|
115
|
+
errors.push({
|
|
116
|
+
code: 'citation_file_missing',
|
|
117
|
+
message: `${locator}.citations.files: "${file}" not found in repository`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const id of citations.finding_ids ?? []) {
|
|
122
|
+
if (!ctx.findingIds.has(id)) {
|
|
123
|
+
errors.push({
|
|
124
|
+
code: 'citation_finding_missing',
|
|
125
|
+
message: `${locator}.citations.finding_ids: "${id}" not found in evidence.findings`
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const id of citations.clause_ids ?? []) {
|
|
130
|
+
if (!ctx.clauseIds.has(id)) {
|
|
131
|
+
errors.push({
|
|
132
|
+
code: 'citation_clause_missing',
|
|
133
|
+
message: `${locator}.citations.clause_ids: "${id}" not found in inferred spec clauses`
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const id of citations.drift_ids ?? []) {
|
|
138
|
+
if (!ctx.driftIds.has(id)) {
|
|
139
|
+
errors.push({
|
|
140
|
+
code: 'citation_drift_missing',
|
|
141
|
+
message: `${locator}.citations.drift_ids: "${id}" not found in drift.items`
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const claim of slot.numerical_claims ?? []) {
|
|
147
|
+
if (!claim || !NUMERICAL_FIELDS.has(claim.field)) {
|
|
148
|
+
errors.push({
|
|
149
|
+
code: 'numerical_field',
|
|
150
|
+
message: `${locator}.numerical_claims: field must be one of ${[...NUMERICAL_FIELDS].join('|')}`
|
|
151
|
+
});
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const truth = ctx.numericalTruth[claim.field];
|
|
155
|
+
if (truth === undefined) {
|
|
156
|
+
errors.push({
|
|
157
|
+
code: 'numerical_truth_missing',
|
|
158
|
+
message: `${locator}.numerical_claims: fingerprint lacks "${claim.field}" — cannot verify`
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (typeof claim.value !== 'number' || claim.value !== truth) {
|
|
163
|
+
errors.push({
|
|
164
|
+
code: 'numerical_contradiction',
|
|
165
|
+
message: `${locator}.numerical_claims: ${claim.field}=${claim.value} contradicts fingerprint=${truth}`
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return errors;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function fileExists(repoRoot, relativeFile) {
|
|
174
|
+
try {
|
|
175
|
+
const stats = await stat(path.join(repoRoot, relativeFile));
|
|
176
|
+
return stats.isFile();
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Cross-check that fingerprint slot existence matches expectation when AI is rerun
|
|
183
|
+
// without changes. Used in tests / drift detection later.
|
|
184
|
+
export async function reconcileWithRunArtifacts(repoRoot, narrative, storyId) {
|
|
185
|
+
const drift = await readDrift(repoRoot, storyId);
|
|
186
|
+
const spec = await readInferredSpec(repoRoot, storyId);
|
|
187
|
+
return {
|
|
188
|
+
drift_present: Boolean(drift),
|
|
189
|
+
spec_present: Boolean(spec),
|
|
190
|
+
narrative_slots: narrative?.narrative_slots?.length ?? 0
|
|
191
|
+
};
|
|
192
|
+
}
|