harness-auto-docs 0.1.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/.nvmrc +1 -0
- package/AGENTS.md +69 -0
- package/ARCHITECTURE.md +123 -0
- package/README.md +52 -0
- package/dist/ai/anthropic.d.ts +7 -0
- package/dist/ai/anthropic.js +20 -0
- package/dist/ai/interface.d.ts +3 -0
- package/dist/ai/interface.js +1 -0
- package/dist/ai/minimax.d.ts +7 -0
- package/dist/ai/minimax.js +21 -0
- package/dist/ai/openai.d.ts +7 -0
- package/dist/ai/openai.js +16 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +103 -0
- package/dist/core/diff.d.ts +17 -0
- package/dist/core/diff.js +46 -0
- package/dist/core/generator.d.ts +10 -0
- package/dist/core/generator.js +238 -0
- package/dist/core/relevance.d.ts +3 -0
- package/dist/core/relevance.js +29 -0
- package/dist/core/writer.d.ts +2 -0
- package/dist/core/writer.js +23 -0
- package/dist/providers/github.d.ts +13 -0
- package/dist/providers/github.js +43 -0
- package/dist/providers/gitlab.d.ts +9 -0
- package/dist/providers/gitlab.js +6 -0
- package/dist/providers/interface.d.ts +8 -0
- package/dist/providers/interface.js +1 -0
- package/docs/DESIGN.md +94 -0
- package/docs/QUALITY_SCORE.md +74 -0
- package/docs/design-docs/core-beliefs.md +71 -0
- package/docs/design-docs/index.md +32 -0
- package/docs/exec-plans/tech-debt-tracker.md +26 -0
- package/docs/product-specs/index.md +39 -0
- package/docs/references/anthropic-sdk-llms.txt +40 -0
- package/docs/references/octokit-rest-llms.txt +44 -0
- package/docs/references/openai-sdk-llms.txt +38 -0
- package/docs/superpowers/plans/2026-04-03-harness-engineering-auto-docs.md +1863 -0
- package/docs/superpowers/specs/2026-04-03-harness-engineering-auto-docs-design.md +169 -0
- package/examples/github-workflow.yml +32 -0
- package/markdown/harness-engineering-codex-agent-first-world.md +215 -0
- package/package.json +30 -0
- package/src/ai/anthropic.ts +23 -0
- package/src/ai/interface.ts +3 -0
- package/src/ai/minimax.ts +25 -0
- package/src/ai/openai.ts +20 -0
- package/src/cli.ts +122 -0
- package/src/core/diff.ts +77 -0
- package/src/core/generator.ts +294 -0
- package/src/core/relevance.ts +53 -0
- package/src/core/writer.ts +25 -0
- package/src/providers/github.ts +53 -0
- package/src/providers/gitlab.ts +16 -0
- package/src/providers/interface.ts +9 -0
- package/tests/core/anthropic.test.ts +33 -0
- package/tests/core/diff.test.ts +49 -0
- package/tests/core/generator.test.ts +93 -0
- package/tests/core/openai.test.ts +38 -0
- package/tests/core/relevance.test.ts +62 -0
- package/tests/core/writer.test.ts +56 -0
- package/tests/fixtures/diff-frontend.txt +11 -0
- package/tests/fixtures/diff-schema.txt +12 -0
- package/tests/fixtures/diff-small.txt +16 -0
- package/tests/integration/generate.test.ts +49 -0
- package/tests/providers/github.test.ts +69 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,1863 @@
|
|
|
1
|
+
# Harness Engineering Auto Docs Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build and publish an npm CLI package (`harness-engineering-auto-docs`) that runs via `npx`, extracts the git diff between two tags, generates Harness Engineering-style documentation using a configurable AI model, and opens a PR.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Three independent layers — `core/` (diff, relevance, generation, file writing), `ai/` (Anthropic + OpenAI providers behind a shared interface), and `providers/` (GitHub PR creation; GitLab stub). The CLI (`cli.ts`) wires them together: write docs to disk → git commit + push → platform API creates PR.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript 5, Node.js 18+, `@anthropic-ai/sdk`, `openai`, `@octokit/rest`, `tsx` (CLI runner), Vitest (tests)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
harness-engineering-auto-docs/
|
|
17
|
+
├── package.json
|
|
18
|
+
├── tsconfig.json
|
|
19
|
+
├── vitest.config.ts
|
|
20
|
+
├── src/
|
|
21
|
+
│ ├── cli.ts # Entry point: orchestrates full pipeline
|
|
22
|
+
│ ├── ai/
|
|
23
|
+
│ │ ├── interface.ts # AIProvider interface
|
|
24
|
+
│ │ ├── anthropic.ts # Claude implementation
|
|
25
|
+
│ │ └── openai.ts # GPT implementation
|
|
26
|
+
│ ├── core/
|
|
27
|
+
│ │ ├── diff.ts # git diff extraction + file grouping
|
|
28
|
+
│ │ ├── relevance.ts # diff → target document selection
|
|
29
|
+
│ │ ├── generator.ts # AI prompts + concurrent doc generation
|
|
30
|
+
│ │ └── writer.ts # append-to / create-file utilities
|
|
31
|
+
│ └── providers/
|
|
32
|
+
│ ├── interface.ts # PlatformProvider interface
|
|
33
|
+
│ ├── github.ts # GitHub PR creation via Octokit
|
|
34
|
+
│ └── gitlab.ts # GitLab stub (not yet implemented)
|
|
35
|
+
├── tests/
|
|
36
|
+
│ ├── fixtures/
|
|
37
|
+
│ │ ├── diff-small.txt
|
|
38
|
+
│ │ ├── diff-schema.txt
|
|
39
|
+
│ │ └── diff-frontend.txt
|
|
40
|
+
│ ├── core/
|
|
41
|
+
│ │ ├── diff.test.ts
|
|
42
|
+
│ │ ├── relevance.test.ts
|
|
43
|
+
│ │ ├── writer.test.ts
|
|
44
|
+
│ │ └── generator.test.ts
|
|
45
|
+
│ ├── providers/
|
|
46
|
+
│ │ └── github.test.ts
|
|
47
|
+
│ └── integration/
|
|
48
|
+
│ └── generate.test.ts
|
|
49
|
+
└── examples/
|
|
50
|
+
└── github-workflow.yml
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Task 1: Project Scaffolding
|
|
56
|
+
|
|
57
|
+
**Files:**
|
|
58
|
+
- Create: `package.json`
|
|
59
|
+
- Create: `tsconfig.json`
|
|
60
|
+
- Create: `vitest.config.ts`
|
|
61
|
+
|
|
62
|
+
- [ ] **Step 1: Create package.json**
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"name": "harness-engineering-auto-docs",
|
|
67
|
+
"version": "0.1.0",
|
|
68
|
+
"description": "Auto-generate Harness Engineering docs on git tag",
|
|
69
|
+
"type": "module",
|
|
70
|
+
"bin": {
|
|
71
|
+
"harness-engineering-auto-docs": "./dist/cli.js"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "tsc",
|
|
75
|
+
"dev": "tsx src/cli.ts",
|
|
76
|
+
"test": "vitest run",
|
|
77
|
+
"test:watch": "vitest"
|
|
78
|
+
},
|
|
79
|
+
"dependencies": {
|
|
80
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
81
|
+
"@octokit/rest": "^21.0.0",
|
|
82
|
+
"openai": "^4.85.0"
|
|
83
|
+
},
|
|
84
|
+
"devDependencies": {
|
|
85
|
+
"@types/node": "^22.0.0",
|
|
86
|
+
"tsx": "^4.19.0",
|
|
87
|
+
"typescript": "^5.7.0",
|
|
88
|
+
"vitest": "^3.0.0"
|
|
89
|
+
},
|
|
90
|
+
"engines": {
|
|
91
|
+
"node": ">=18"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- [ ] **Step 2: Create tsconfig.json**
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"compilerOptions": {
|
|
101
|
+
"target": "ES2022",
|
|
102
|
+
"module": "NodeNext",
|
|
103
|
+
"moduleResolution": "NodeNext",
|
|
104
|
+
"outDir": "./dist",
|
|
105
|
+
"rootDir": "./src",
|
|
106
|
+
"strict": true,
|
|
107
|
+
"esModuleInterop": true,
|
|
108
|
+
"skipLibCheck": true,
|
|
109
|
+
"declaration": true
|
|
110
|
+
},
|
|
111
|
+
"include": ["src/**/*"],
|
|
112
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- [ ] **Step 3: Create vitest.config.ts**
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { defineConfig } from 'vitest/config';
|
|
120
|
+
|
|
121
|
+
export default defineConfig({
|
|
122
|
+
test: {
|
|
123
|
+
globals: true,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- [ ] **Step 4: Install dependencies**
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
npm install
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Expected: `node_modules/` created, no errors.
|
|
135
|
+
|
|
136
|
+
- [ ] **Step 5: Commit**
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
git add package.json tsconfig.json vitest.config.ts
|
|
140
|
+
git commit -m "chore: project scaffolding"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Task 2: AI Provider Interface + Anthropic
|
|
146
|
+
|
|
147
|
+
**Files:**
|
|
148
|
+
- Create: `src/ai/interface.ts`
|
|
149
|
+
- Create: `src/ai/anthropic.ts`
|
|
150
|
+
- Create: `tests/core/anthropic.test.ts`
|
|
151
|
+
|
|
152
|
+
- [ ] **Step 1: Write the failing test**
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
// tests/core/anthropic.test.ts
|
|
156
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
157
|
+
import { AnthropicProvider } from '../../src/ai/anthropic.js';
|
|
158
|
+
|
|
159
|
+
vi.mock('@anthropic-ai/sdk', () => ({
|
|
160
|
+
default: vi.fn().mockImplementation(() => ({
|
|
161
|
+
messages: {
|
|
162
|
+
create: vi.fn().mockResolvedValue({
|
|
163
|
+
content: [{ type: 'text', text: 'generated content' }],
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
})),
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
describe('AnthropicProvider', () => {
|
|
170
|
+
it('returns text content from the API response', async () => {
|
|
171
|
+
const provider = new AnthropicProvider('test-key', 'claude-sonnet-4-6');
|
|
172
|
+
const result = await provider.generate('write docs for this diff');
|
|
173
|
+
expect(result).toBe('generated content');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('throws if response content is not text', async () => {
|
|
177
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
178
|
+
vi.mocked(Anthropic).mockImplementationOnce(() => ({
|
|
179
|
+
messages: {
|
|
180
|
+
create: vi.fn().mockResolvedValue({
|
|
181
|
+
content: [{ type: 'image', source: {} }],
|
|
182
|
+
}),
|
|
183
|
+
},
|
|
184
|
+
}) as any);
|
|
185
|
+
const provider = new AnthropicProvider('test-key', 'claude-sonnet-4-6');
|
|
186
|
+
await expect(provider.generate('prompt')).rejects.toThrow('Unexpected response type');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
npm test -- tests/core/anthropic.test.ts
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Expected: FAIL — `Cannot find module '../../src/ai/anthropic.js'`
|
|
198
|
+
|
|
199
|
+
- [ ] **Step 3: Create src/ai/interface.ts**
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
// src/ai/interface.ts
|
|
203
|
+
export interface AIProvider {
|
|
204
|
+
generate(prompt: string): Promise<string>;
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- [ ] **Step 4: Create src/ai/anthropic.ts**
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
// src/ai/anthropic.ts
|
|
212
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
213
|
+
import type { AIProvider } from './interface.js';
|
|
214
|
+
|
|
215
|
+
export class AnthropicProvider implements AIProvider {
|
|
216
|
+
private client: Anthropic;
|
|
217
|
+
private model: string;
|
|
218
|
+
|
|
219
|
+
constructor(apiKey: string, model: string) {
|
|
220
|
+
this.client = new Anthropic({ apiKey });
|
|
221
|
+
this.model = model;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async generate(prompt: string): Promise<string> {
|
|
225
|
+
const message = await this.client.messages.create({
|
|
226
|
+
model: this.model,
|
|
227
|
+
max_tokens: 4096,
|
|
228
|
+
messages: [{ role: 'user', content: prompt }],
|
|
229
|
+
});
|
|
230
|
+
const content = message.content[0];
|
|
231
|
+
if (content.type !== 'text') throw new Error('Unexpected response type');
|
|
232
|
+
return content.text;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
npm test -- tests/core/anthropic.test.ts
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Expected: PASS
|
|
244
|
+
|
|
245
|
+
- [ ] **Step 6: Commit**
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
git add src/ai/interface.ts src/ai/anthropic.ts tests/core/anthropic.test.ts
|
|
249
|
+
git commit -m "feat: add AI provider interface and Anthropic implementation"
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Task 3: OpenAI Provider
|
|
255
|
+
|
|
256
|
+
**Files:**
|
|
257
|
+
- Create: `src/ai/openai.ts`
|
|
258
|
+
- Create: `tests/core/openai.test.ts`
|
|
259
|
+
|
|
260
|
+
- [ ] **Step 1: Write the failing test**
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
// tests/core/openai.test.ts
|
|
264
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
265
|
+
import { OpenAIProvider } from '../../src/ai/openai.js';
|
|
266
|
+
|
|
267
|
+
vi.mock('openai', () => ({
|
|
268
|
+
default: vi.fn().mockImplementation(() => ({
|
|
269
|
+
chat: {
|
|
270
|
+
completions: {
|
|
271
|
+
create: vi.fn().mockResolvedValue({
|
|
272
|
+
choices: [{ message: { content: 'gpt generated content' } }],
|
|
273
|
+
}),
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
})),
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
describe('OpenAIProvider', () => {
|
|
280
|
+
it('returns text content from the API response', async () => {
|
|
281
|
+
const provider = new OpenAIProvider('test-key', 'gpt-4o');
|
|
282
|
+
const result = await provider.generate('write docs for this diff');
|
|
283
|
+
expect(result).toBe('gpt generated content');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('returns empty string when message content is null', async () => {
|
|
287
|
+
const { default: OpenAI } = await import('openai');
|
|
288
|
+
vi.mocked(OpenAI).mockImplementationOnce(() => ({
|
|
289
|
+
chat: {
|
|
290
|
+
completions: {
|
|
291
|
+
create: vi.fn().mockResolvedValue({
|
|
292
|
+
choices: [{ message: { content: null } }],
|
|
293
|
+
}),
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
}) as any);
|
|
297
|
+
const provider = new OpenAIProvider('test-key', 'gpt-4o');
|
|
298
|
+
const result = await provider.generate('prompt');
|
|
299
|
+
expect(result).toBe('');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
npm test -- tests/core/openai.test.ts
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Expected: FAIL — `Cannot find module '../../src/ai/openai.js'`
|
|
311
|
+
|
|
312
|
+
- [ ] **Step 3: Create src/ai/openai.ts**
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
// src/ai/openai.ts
|
|
316
|
+
import OpenAI from 'openai';
|
|
317
|
+
import type { AIProvider } from './interface.js';
|
|
318
|
+
|
|
319
|
+
export class OpenAIProvider implements AIProvider {
|
|
320
|
+
private client: OpenAI;
|
|
321
|
+
private model: string;
|
|
322
|
+
|
|
323
|
+
constructor(apiKey: string, model: string) {
|
|
324
|
+
this.client = new OpenAI({ apiKey });
|
|
325
|
+
this.model = model;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async generate(prompt: string): Promise<string> {
|
|
329
|
+
const completion = await this.client.chat.completions.create({
|
|
330
|
+
model: this.model,
|
|
331
|
+
messages: [{ role: 'user', content: prompt }],
|
|
332
|
+
});
|
|
333
|
+
return completion.choices[0].message.content ?? '';
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
npm test -- tests/core/openai.test.ts
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Expected: PASS
|
|
345
|
+
|
|
346
|
+
- [ ] **Step 5: Commit**
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
git add src/ai/openai.ts tests/core/openai.test.ts
|
|
350
|
+
git commit -m "feat: add OpenAI provider"
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## Task 4: Diff Extraction
|
|
356
|
+
|
|
357
|
+
**Files:**
|
|
358
|
+
- Create: `src/core/diff.ts`
|
|
359
|
+
- Create: `tests/fixtures/diff-small.txt`
|
|
360
|
+
- Create: `tests/fixtures/diff-schema.txt`
|
|
361
|
+
- Create: `tests/fixtures/diff-frontend.txt`
|
|
362
|
+
- Create: `tests/core/diff.test.ts`
|
|
363
|
+
|
|
364
|
+
- [ ] **Step 1: Create test fixtures**
|
|
365
|
+
|
|
366
|
+
`tests/fixtures/diff-small.txt`:
|
|
367
|
+
```
|
|
368
|
+
diff --git a/src/auth/login.ts b/src/auth/login.ts
|
|
369
|
+
index abc1234..def5678 100644
|
|
370
|
+
--- a/src/auth/login.ts
|
|
371
|
+
+++ b/src/auth/login.ts
|
|
372
|
+
@@ -1,5 +1,10 @@
|
|
373
|
+
+export function login(email: string, password: string) {
|
|
374
|
+
+ return { token: 'jwt' };
|
|
375
|
+
+}
|
|
376
|
+
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
|
|
377
|
+
index 111aaaa..222bbbb 100644
|
|
378
|
+
--- a/src/utils/helpers.ts
|
|
379
|
+
+++ b/src/utils/helpers.ts
|
|
380
|
+
@@ -1,3 +1,6 @@
|
|
381
|
+
+export function sleep(ms: number) {
|
|
382
|
+
+ return new Promise(r => setTimeout(r, ms));
|
|
383
|
+
+}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
`tests/fixtures/diff-schema.txt`:
|
|
387
|
+
```
|
|
388
|
+
diff --git a/migrations/001_add_users.sql b/migrations/001_add_users.sql
|
|
389
|
+
new file mode 100644
|
|
390
|
+
index 0000000..aabbcc
|
|
391
|
+
--- /dev/null
|
|
392
|
+
+++ b/migrations/001_add_users.sql
|
|
393
|
+
@@ -0,0 +1,5 @@
|
|
394
|
+
+CREATE TABLE users (
|
|
395
|
+
+ id UUID PRIMARY KEY,
|
|
396
|
+
+ email TEXT NOT NULL UNIQUE
|
|
397
|
+
+);
|
|
398
|
+
diff --git a/src/db/schema.ts b/src/db/schema.ts
|
|
399
|
+
new file mode 100644
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`tests/fixtures/diff-frontend.txt`:
|
|
403
|
+
```
|
|
404
|
+
diff --git a/src/ui/Button.tsx b/src/ui/Button.tsx
|
|
405
|
+
new file mode 100644
|
|
406
|
+
index 0000000..aabbcc
|
|
407
|
+
--- /dev/null
|
|
408
|
+
+++ b/src/ui/Button.tsx
|
|
409
|
+
@@ -0,0 +1,8 @@
|
|
410
|
+
+export function Button({ label }: { label: string }) {
|
|
411
|
+
+ return <button>{label}</button>;
|
|
412
|
+
+}
|
|
413
|
+
diff --git a/src/ui/styles.css b/src/ui/styles.css
|
|
414
|
+
new file mode 100644
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- [ ] **Step 2: Write the failing test**
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
// tests/core/diff.test.ts
|
|
421
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
422
|
+
import { readFileSync } from 'fs';
|
|
423
|
+
import { join } from 'path';
|
|
424
|
+
import { parseChangedFiles, groupFiles } from '../../src/core/diff.js';
|
|
425
|
+
|
|
426
|
+
const smallDiff = readFileSync(
|
|
427
|
+
join(import.meta.dirname, '../fixtures/diff-small.txt'), 'utf-8'
|
|
428
|
+
);
|
|
429
|
+
const schemaDiff = readFileSync(
|
|
430
|
+
join(import.meta.dirname, '../fixtures/diff-schema.txt'), 'utf-8'
|
|
431
|
+
);
|
|
432
|
+
const frontendDiff = readFileSync(
|
|
433
|
+
join(import.meta.dirname, '../fixtures/diff-frontend.txt'), 'utf-8'
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
describe('parseChangedFiles', () => {
|
|
437
|
+
it('extracts file paths from diff output', () => {
|
|
438
|
+
const files = parseChangedFiles(smallDiff);
|
|
439
|
+
expect(files).toContain('src/auth/login.ts');
|
|
440
|
+
expect(files).toContain('src/utils/helpers.ts');
|
|
441
|
+
expect(files).toHaveLength(2);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('returns empty array for empty diff', () => {
|
|
445
|
+
expect(parseChangedFiles('')).toEqual([]);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe('groupFiles', () => {
|
|
450
|
+
it('puts auth files in auth group', () => {
|
|
451
|
+
const groups = groupFiles(['src/auth/login.ts', 'src/utils/helpers.ts']);
|
|
452
|
+
expect(groups.auth).toContain('src/auth/login.ts');
|
|
453
|
+
expect(groups.auth).not.toContain('src/utils/helpers.ts');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('puts sql files in schema group', () => {
|
|
457
|
+
const files = parseChangedFiles(schemaDiff);
|
|
458
|
+
const groups = groupFiles(files);
|
|
459
|
+
expect(groups.schema.length).toBeGreaterThan(0);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('puts tsx files in frontend group', () => {
|
|
463
|
+
const files = parseChangedFiles(frontendDiff);
|
|
464
|
+
const groups = groupFiles(files);
|
|
465
|
+
expect(groups.frontend).toContain('src/ui/Button.tsx');
|
|
466
|
+
expect(groups.frontend).toContain('src/ui/styles.css');
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
- [ ] **Step 3: Run test to verify it fails**
|
|
472
|
+
|
|
473
|
+
```bash
|
|
474
|
+
npm test -- tests/core/diff.test.ts
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Expected: FAIL — `Cannot find module '../../src/core/diff.js'`
|
|
478
|
+
|
|
479
|
+
- [ ] **Step 4: Create src/core/diff.ts**
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
// src/core/diff.ts
|
|
483
|
+
import { execSync } from 'child_process';
|
|
484
|
+
|
|
485
|
+
export interface FileGroups {
|
|
486
|
+
frontend: string[];
|
|
487
|
+
schema: string[];
|
|
488
|
+
auth: string[];
|
|
489
|
+
infra: string[];
|
|
490
|
+
other: string[];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface DiffResult {
|
|
494
|
+
raw: string;
|
|
495
|
+
prevTag: string;
|
|
496
|
+
currentTag: string;
|
|
497
|
+
changedFiles: string[];
|
|
498
|
+
fileGroups: FileGroups;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function extractDiff(): DiffResult {
|
|
502
|
+
const tags = execSync('git tag --sort=version:refname')
|
|
503
|
+
.toString()
|
|
504
|
+
.trim()
|
|
505
|
+
.split('\n')
|
|
506
|
+
.filter(Boolean);
|
|
507
|
+
|
|
508
|
+
if (tags.length === 0) throw new Error('No tags found in repository');
|
|
509
|
+
|
|
510
|
+
const currentTag = tags[tags.length - 1];
|
|
511
|
+
const prevTag = tags.length >= 2 ? tags[tags.length - 2] : null;
|
|
512
|
+
|
|
513
|
+
const raw = prevTag
|
|
514
|
+
? execSync(`git diff ${prevTag} ${currentTag}`).toString()
|
|
515
|
+
: execSync(`git show ${currentTag} --format='' -p`).toString();
|
|
516
|
+
|
|
517
|
+
const changedFiles = parseChangedFiles(raw);
|
|
518
|
+
const fileGroups = groupFiles(changedFiles);
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
raw,
|
|
522
|
+
prevTag: prevTag ?? '(initial)',
|
|
523
|
+
currentTag,
|
|
524
|
+
changedFiles,
|
|
525
|
+
fileGroups,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function parseChangedFiles(diff: string): string[] {
|
|
530
|
+
const matches = diff.match(/^diff --git a\/.+ b\/(.+)$/gm) ?? [];
|
|
531
|
+
return matches.map(line => {
|
|
532
|
+
const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
533
|
+
return match ? match[1] : '';
|
|
534
|
+
}).filter(Boolean);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function groupFiles(files: string[]): FileGroups {
|
|
538
|
+
const isFrontend = (f: string) =>
|
|
539
|
+
/\.(tsx?|jsx?|css|scss|html|vue|svelte)$/.test(f) ||
|
|
540
|
+
/\b(ui|frontend|components|pages|views)\b/.test(f);
|
|
541
|
+
|
|
542
|
+
const isSchema = (f: string) =>
|
|
543
|
+
/\.sql$/.test(f) || /\b(schema|migration|migrate)\b/i.test(f);
|
|
544
|
+
|
|
545
|
+
const isAuth = (f: string) =>
|
|
546
|
+
/\b(auth|permission|security|oauth|jwt|session|token)\b/i.test(f);
|
|
547
|
+
|
|
548
|
+
const isInfra = (f: string) =>
|
|
549
|
+
/\b(infra|deploy|k8s|docker|compose|helm|terraform|service|gateway)\b/i.test(f);
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
frontend: files.filter(isFrontend),
|
|
553
|
+
schema: files.filter(isSchema),
|
|
554
|
+
auth: files.filter(isAuth),
|
|
555
|
+
infra: files.filter(isInfra),
|
|
556
|
+
other: files.filter(f => !isFrontend(f) && !isSchema(f) && !isAuth(f) && !isInfra(f)),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
562
|
+
|
|
563
|
+
```bash
|
|
564
|
+
npm test -- tests/core/diff.test.ts
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Expected: PASS
|
|
568
|
+
|
|
569
|
+
- [ ] **Step 6: Commit**
|
|
570
|
+
|
|
571
|
+
```bash
|
|
572
|
+
git add src/core/diff.ts tests/core/diff.test.ts tests/fixtures/
|
|
573
|
+
git commit -m "feat: add diff extraction and file grouping"
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
## Task 5: Relevance Judgment
|
|
579
|
+
|
|
580
|
+
**Files:**
|
|
581
|
+
- Create: `src/core/relevance.ts`
|
|
582
|
+
- Create: `tests/core/relevance.test.ts`
|
|
583
|
+
|
|
584
|
+
- [ ] **Step 1: Write the failing test**
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
// tests/core/relevance.test.ts
|
|
588
|
+
import { describe, it, expect } from 'vitest';
|
|
589
|
+
import { selectTargets } from '../../src/core/relevance.js';
|
|
590
|
+
import type { FileGroups } from '../../src/core/diff.js';
|
|
591
|
+
|
|
592
|
+
const emptyGroups: FileGroups = {
|
|
593
|
+
frontend: [], schema: [], auth: [], infra: [], other: [],
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
describe('selectTargets', () => {
|
|
597
|
+
it('always includes core targets', () => {
|
|
598
|
+
const targets = selectTargets(emptyGroups, []);
|
|
599
|
+
expect(targets).toContain('AGENTS.md');
|
|
600
|
+
expect(targets).toContain('ARCHITECTURE.md');
|
|
601
|
+
expect(targets).toContain('DESIGN.md');
|
|
602
|
+
expect(targets).toContain('QUALITY_SCORE.md');
|
|
603
|
+
expect(targets).toContain('changelog');
|
|
604
|
+
expect(targets).toContain('design-doc');
|
|
605
|
+
expect(targets).toContain('design-doc-index');
|
|
606
|
+
expect(targets).toContain('tech-debt-tracker');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('includes FRONTEND.md only when frontend files changed', () => {
|
|
610
|
+
const withFrontend = selectTargets(
|
|
611
|
+
{ ...emptyGroups, frontend: ['src/ui/Button.tsx'] }, []
|
|
612
|
+
);
|
|
613
|
+
expect(withFrontend).toContain('FRONTEND.md');
|
|
614
|
+
|
|
615
|
+
const withoutFrontend = selectTargets(emptyGroups, []);
|
|
616
|
+
expect(withoutFrontend).not.toContain('FRONTEND.md');
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('includes SECURITY.md only when auth files changed', () => {
|
|
620
|
+
const targets = selectTargets(
|
|
621
|
+
{ ...emptyGroups, auth: ['src/auth/login.ts'] }, []
|
|
622
|
+
);
|
|
623
|
+
expect(targets).toContain('SECURITY.md');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('includes RELIABILITY.md only when infra files changed', () => {
|
|
627
|
+
const targets = selectTargets(
|
|
628
|
+
{ ...emptyGroups, infra: ['infra/deploy.yaml'] }, []
|
|
629
|
+
);
|
|
630
|
+
expect(targets).toContain('RELIABILITY.md');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('includes db-schema only when schema files changed', () => {
|
|
634
|
+
const targets = selectTargets(
|
|
635
|
+
{ ...emptyGroups, schema: ['migrations/001.sql'] }, []
|
|
636
|
+
);
|
|
637
|
+
expect(targets).toContain('db-schema');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('includes references when package.json changed', () => {
|
|
641
|
+
const targets = selectTargets(emptyGroups, ['package.json']);
|
|
642
|
+
expect(targets).toContain('references');
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('returns no duplicates', () => {
|
|
646
|
+
const targets = selectTargets(emptyGroups, []);
|
|
647
|
+
expect(targets.length).toBe(new Set(targets).size);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
653
|
+
|
|
654
|
+
```bash
|
|
655
|
+
npm test -- tests/core/relevance.test.ts
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Expected: FAIL — `Cannot find module '../../src/core/relevance.js'`
|
|
659
|
+
|
|
660
|
+
- [ ] **Step 3: Create src/core/relevance.ts**
|
|
661
|
+
|
|
662
|
+
```typescript
|
|
663
|
+
// src/core/relevance.ts
|
|
664
|
+
import type { FileGroups } from './diff.js';
|
|
665
|
+
|
|
666
|
+
export type DocTarget =
|
|
667
|
+
| 'AGENTS.md'
|
|
668
|
+
| 'ARCHITECTURE.md'
|
|
669
|
+
| 'DESIGN.md'
|
|
670
|
+
| 'FRONTEND.md'
|
|
671
|
+
| 'SECURITY.md'
|
|
672
|
+
| 'RELIABILITY.md'
|
|
673
|
+
| 'QUALITY_SCORE.md'
|
|
674
|
+
| 'changelog'
|
|
675
|
+
| 'design-doc'
|
|
676
|
+
| 'design-doc-index'
|
|
677
|
+
| 'tech-debt-tracker'
|
|
678
|
+
| 'db-schema'
|
|
679
|
+
| 'product-specs-index'
|
|
680
|
+
| 'references';
|
|
681
|
+
|
|
682
|
+
const CORE_TARGETS: DocTarget[] = [
|
|
683
|
+
'AGENTS.md',
|
|
684
|
+
'ARCHITECTURE.md',
|
|
685
|
+
'DESIGN.md',
|
|
686
|
+
'QUALITY_SCORE.md',
|
|
687
|
+
'changelog',
|
|
688
|
+
'design-doc',
|
|
689
|
+
'design-doc-index',
|
|
690
|
+
'tech-debt-tracker',
|
|
691
|
+
];
|
|
692
|
+
|
|
693
|
+
export function selectTargets(
|
|
694
|
+
fileGroups: FileGroups,
|
|
695
|
+
changedFiles: string[]
|
|
696
|
+
): DocTarget[] {
|
|
697
|
+
const targets: DocTarget[] = [...CORE_TARGETS];
|
|
698
|
+
|
|
699
|
+
if (fileGroups.frontend.length > 0) targets.push('FRONTEND.md');
|
|
700
|
+
if (fileGroups.auth.length > 0) targets.push('SECURITY.md');
|
|
701
|
+
if (fileGroups.infra.length > 0) targets.push('RELIABILITY.md');
|
|
702
|
+
if (fileGroups.schema.length > 0) targets.push('db-schema');
|
|
703
|
+
|
|
704
|
+
const packageFiles = ['package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
|
|
705
|
+
if (changedFiles.some(f => packageFiles.includes(f))) {
|
|
706
|
+
targets.push('references');
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const looksLikeNewFeature = changedFiles.some(f =>
|
|
710
|
+
/\b(feature|feat|new)\b/i.test(f) || f.startsWith('src/features/')
|
|
711
|
+
);
|
|
712
|
+
if (looksLikeNewFeature) targets.push('product-specs-index');
|
|
713
|
+
|
|
714
|
+
return [...new Set(targets)];
|
|
715
|
+
}
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
719
|
+
|
|
720
|
+
```bash
|
|
721
|
+
npm test -- tests/core/relevance.test.ts
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
Expected: PASS
|
|
725
|
+
|
|
726
|
+
- [ ] **Step 5: Commit**
|
|
727
|
+
|
|
728
|
+
```bash
|
|
729
|
+
git add src/core/relevance.ts tests/core/relevance.test.ts
|
|
730
|
+
git commit -m "feat: add relevance judgment for document targets"
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
---
|
|
734
|
+
|
|
735
|
+
## Task 6: File Writer
|
|
736
|
+
|
|
737
|
+
**Files:**
|
|
738
|
+
- Create: `src/core/writer.ts`
|
|
739
|
+
- Create: `tests/core/writer.test.ts`
|
|
740
|
+
|
|
741
|
+
- [ ] **Step 1: Write the failing test**
|
|
742
|
+
|
|
743
|
+
```typescript
|
|
744
|
+
// tests/core/writer.test.ts
|
|
745
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
746
|
+
import { mkdirSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
747
|
+
import { join } from 'path';
|
|
748
|
+
import { appendSection, createFile } from '../../src/core/writer.js';
|
|
749
|
+
|
|
750
|
+
const TMP = join(import.meta.dirname, '../../tmp-writer-test');
|
|
751
|
+
|
|
752
|
+
beforeEach(() => mkdirSync(TMP, { recursive: true }));
|
|
753
|
+
afterEach(() => rmSync(TMP, { recursive: true, force: true }));
|
|
754
|
+
|
|
755
|
+
describe('appendSection', () => {
|
|
756
|
+
it('creates file if it does not exist', () => {
|
|
757
|
+
const path = join(TMP, 'AGENTS.md');
|
|
758
|
+
appendSection(path, 'Changes in v1.1.0', 'New auth module added.');
|
|
759
|
+
expect(existsSync(path)).toBe(true);
|
|
760
|
+
const content = readFileSync(path, 'utf-8');
|
|
761
|
+
expect(content).toContain('## Changes in v1.1.0');
|
|
762
|
+
expect(content).toContain('New auth module added.');
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it('appends to existing file', () => {
|
|
766
|
+
const path = join(TMP, 'ARCHITECTURE.md');
|
|
767
|
+
appendSection(path, 'Changes in v1.0.0', 'Initial architecture.');
|
|
768
|
+
appendSection(path, 'Changes in v1.1.0', 'Added caching layer.');
|
|
769
|
+
const content = readFileSync(path, 'utf-8');
|
|
770
|
+
expect(content).toContain('## Changes in v1.0.0');
|
|
771
|
+
expect(content).toContain('## Changes in v1.1.0');
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it('creates missing parent directories', () => {
|
|
775
|
+
const path = join(TMP, 'docs/design-docs/index.md');
|
|
776
|
+
appendSection(path, 'v1.0.0', 'entry');
|
|
777
|
+
expect(existsSync(path)).toBe(true);
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
describe('createFile', () => {
|
|
782
|
+
it('creates a file with the given content', () => {
|
|
783
|
+
const path = join(TMP, 'changelog/v1.0.0.md');
|
|
784
|
+
createFile(path, '# Changelog: v1.0.0\n\n## Added\n- First release');
|
|
785
|
+
expect(readFileSync(path, 'utf-8')).toContain('First release');
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('overwrites an existing file', () => {
|
|
789
|
+
const path = join(TMP, 'test.md');
|
|
790
|
+
createFile(path, 'first content');
|
|
791
|
+
createFile(path, 'second content');
|
|
792
|
+
expect(readFileSync(path, 'utf-8')).toBe('second content');
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('creates missing parent directories', () => {
|
|
796
|
+
const path = join(TMP, 'a/b/c/file.md');
|
|
797
|
+
createFile(path, 'content');
|
|
798
|
+
expect(existsSync(path)).toBe(true);
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
804
|
+
|
|
805
|
+
```bash
|
|
806
|
+
npm test -- tests/core/writer.test.ts
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
Expected: FAIL — `Cannot find module '../../src/core/writer.js'`
|
|
810
|
+
|
|
811
|
+
- [ ] **Step 3: Create src/core/writer.ts**
|
|
812
|
+
|
|
813
|
+
```typescript
|
|
814
|
+
// src/core/writer.ts
|
|
815
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
|
816
|
+
import { dirname } from 'path';
|
|
817
|
+
|
|
818
|
+
export function appendSection(filePath: string, heading: string, content: string): void {
|
|
819
|
+
ensureDir(filePath);
|
|
820
|
+
const section = `\n## ${heading}\n\n${content}\n`;
|
|
821
|
+
if (existsSync(filePath)) {
|
|
822
|
+
const existing = readFileSync(filePath, 'utf-8');
|
|
823
|
+
writeFileSync(filePath, existing + section, 'utf-8');
|
|
824
|
+
} else {
|
|
825
|
+
writeFileSync(filePath, section.trimStart(), 'utf-8');
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export function createFile(filePath: string, content: string): void {
|
|
830
|
+
ensureDir(filePath);
|
|
831
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function ensureDir(filePath: string): void {
|
|
835
|
+
const dir = dirname(filePath);
|
|
836
|
+
if (!existsSync(dir)) {
|
|
837
|
+
mkdirSync(dir, { recursive: true });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
843
|
+
|
|
844
|
+
```bash
|
|
845
|
+
npm test -- tests/core/writer.test.ts
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
Expected: PASS
|
|
849
|
+
|
|
850
|
+
- [ ] **Step 5: Commit**
|
|
851
|
+
|
|
852
|
+
```bash
|
|
853
|
+
git add src/core/writer.ts tests/core/writer.test.ts
|
|
854
|
+
git commit -m "feat: add file writer utilities"
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
---
|
|
858
|
+
|
|
859
|
+
## Task 7: Document Generator
|
|
860
|
+
|
|
861
|
+
**Files:**
|
|
862
|
+
- Create: `src/core/generator.ts`
|
|
863
|
+
- Create: `tests/core/generator.test.ts`
|
|
864
|
+
|
|
865
|
+
- [ ] **Step 1: Write the failing test**
|
|
866
|
+
|
|
867
|
+
```typescript
|
|
868
|
+
// tests/core/generator.test.ts
|
|
869
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
870
|
+
import { mkdirSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
871
|
+
import { join } from 'path';
|
|
872
|
+
import { generateDocs, writeResults } from '../../src/core/generator.js';
|
|
873
|
+
import type { AIProvider } from '../../src/ai/interface.js';
|
|
874
|
+
import type { DiffResult } from '../../src/core/diff.js';
|
|
875
|
+
|
|
876
|
+
const TMP = join(import.meta.dirname, '../../tmp-generator-test');
|
|
877
|
+
|
|
878
|
+
beforeEach(() => mkdirSync(TMP, { recursive: true }));
|
|
879
|
+
afterEach(() => rmSync(TMP, { recursive: true, force: true }));
|
|
880
|
+
|
|
881
|
+
const mockAI: AIProvider = {
|
|
882
|
+
generate: vi.fn().mockResolvedValue('AI generated content for this section'),
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const fakeDiff: DiffResult = {
|
|
886
|
+
raw: 'diff --git a/src/auth/login.ts b/src/auth/login.ts\n+new code',
|
|
887
|
+
prevTag: 'v1.0.0',
|
|
888
|
+
currentTag: 'v1.1.0',
|
|
889
|
+
changedFiles: ['src/auth/login.ts'],
|
|
890
|
+
fileGroups: {
|
|
891
|
+
frontend: [],
|
|
892
|
+
schema: [],
|
|
893
|
+
auth: ['src/auth/login.ts'],
|
|
894
|
+
infra: [],
|
|
895
|
+
other: [],
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
describe('generateDocs', () => {
|
|
900
|
+
it('calls AI for each target and returns results', async () => {
|
|
901
|
+
const results = await generateDocs(mockAI, fakeDiff, ['AGENTS.md', 'ARCHITECTURE.md']);
|
|
902
|
+
expect(results).toHaveLength(2);
|
|
903
|
+
expect(results[0].target).toBe('AGENTS.md');
|
|
904
|
+
expect(results[0].content).toBe('AI generated content for this section');
|
|
905
|
+
expect(mockAI.generate).toHaveBeenCalledTimes(2);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('captures errors without throwing', async () => {
|
|
909
|
+
const failingAI: AIProvider = {
|
|
910
|
+
generate: vi.fn().mockRejectedValue(new Error('API rate limit')),
|
|
911
|
+
};
|
|
912
|
+
const results = await generateDocs(failingAI, fakeDiff, ['AGENTS.md']);
|
|
913
|
+
expect(results[0].error).toContain('API rate limit');
|
|
914
|
+
expect(results[0].content).toBe('');
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it('runs targets concurrently', async () => {
|
|
918
|
+
const delays: number[] = [];
|
|
919
|
+
const timedAI: AIProvider = {
|
|
920
|
+
generate: vi.fn().mockImplementation(async () => {
|
|
921
|
+
delays.push(Date.now());
|
|
922
|
+
await new Promise(r => setTimeout(r, 50));
|
|
923
|
+
return 'content';
|
|
924
|
+
}),
|
|
925
|
+
};
|
|
926
|
+
const start = Date.now();
|
|
927
|
+
await generateDocs(timedAI, fakeDiff, ['AGENTS.md', 'ARCHITECTURE.md', 'DESIGN.md']);
|
|
928
|
+
const elapsed = Date.now() - start;
|
|
929
|
+
// Three 50ms tasks running concurrently should finish in ~50-100ms, not ~150ms
|
|
930
|
+
expect(elapsed).toBeLessThan(130);
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
describe('writeResults', () => {
|
|
935
|
+
it('writes AGENTS.md as appended section', async () => {
|
|
936
|
+
const results = await generateDocs(mockAI, fakeDiff, ['AGENTS.md']);
|
|
937
|
+
const written = writeResults(results, fakeDiff, TMP);
|
|
938
|
+
expect(written).toContain(`${TMP}/AGENTS.md`);
|
|
939
|
+
const content = readFileSync(`${TMP}/AGENTS.md`, 'utf-8');
|
|
940
|
+
expect(content).toContain('## Changes in v1.1.0');
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it('creates changelog file at versioned path', async () => {
|
|
944
|
+
const results = await generateDocs(mockAI, fakeDiff, ['changelog']);
|
|
945
|
+
writeResults(results, fakeDiff, TMP);
|
|
946
|
+
expect(existsSync(`${TMP}/changelog/v1.1.0.md`)).toBe(true);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('creates design-doc at versioned path', async () => {
|
|
950
|
+
const results = await generateDocs(mockAI, fakeDiff, ['design-doc']);
|
|
951
|
+
writeResults(results, fakeDiff, TMP);
|
|
952
|
+
expect(existsSync(`${TMP}/docs/design-docs/v1.1.0.md`)).toBe(true);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it('skips results with errors', async () => {
|
|
956
|
+
const failingAI: AIProvider = {
|
|
957
|
+
generate: vi.fn().mockRejectedValue(new Error('fail')),
|
|
958
|
+
};
|
|
959
|
+
const results = await generateDocs(failingAI, fakeDiff, ['AGENTS.md']);
|
|
960
|
+
const written = writeResults(results, fakeDiff, TMP);
|
|
961
|
+
expect(written).toHaveLength(0);
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
967
|
+
|
|
968
|
+
```bash
|
|
969
|
+
npm test -- tests/core/generator.test.ts
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
Expected: FAIL — `Cannot find module '../../src/core/generator.js'`
|
|
973
|
+
|
|
974
|
+
- [ ] **Step 3: Create src/core/generator.ts**
|
|
975
|
+
|
|
976
|
+
```typescript
|
|
977
|
+
// src/core/generator.ts
|
|
978
|
+
import type { AIProvider } from '../ai/interface.js';
|
|
979
|
+
import type { DocTarget } from './relevance.js';
|
|
980
|
+
import type { DiffResult } from './diff.js';
|
|
981
|
+
import { appendSection, createFile } from './writer.js';
|
|
982
|
+
|
|
983
|
+
type PromptFn = (diff: DiffResult) => string;
|
|
984
|
+
|
|
985
|
+
const PROMPTS: Record<DocTarget, PromptFn> = {
|
|
986
|
+
'AGENTS.md': (diff) =>
|
|
987
|
+
`You are a technical writer following Harness Engineering documentation style.
|
|
988
|
+
|
|
989
|
+
Based on the git diff between ${diff.prevTag} and ${diff.currentTag}, write a section for AGENTS.md.
|
|
990
|
+
Describe what AI coding agents need to know: new modules, interfaces, APIs, navigation patterns, new conventions.
|
|
991
|
+
Write in present tense. Be specific and actionable. 2-4 paragraphs max.
|
|
992
|
+
|
|
993
|
+
Git diff:
|
|
994
|
+
${diff.raw.slice(0, 8000)}`,
|
|
995
|
+
|
|
996
|
+
'ARCHITECTURE.md': (diff) =>
|
|
997
|
+
`You are a technical writer following Harness Engineering documentation style.
|
|
998
|
+
|
|
999
|
+
Based on the git diff between ${diff.prevTag} and ${diff.currentTag}, write a section for ARCHITECTURE.md.
|
|
1000
|
+
Focus on: new layers, modules, dependency changes, new abstractions, removed or restructured components.
|
|
1001
|
+
Write in present tense. 2-3 paragraphs max.
|
|
1002
|
+
|
|
1003
|
+
Git diff:
|
|
1004
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1005
|
+
|
|
1006
|
+
'DESIGN.md': (diff) =>
|
|
1007
|
+
`You are a technical writer following Harness Engineering documentation style.
|
|
1008
|
+
|
|
1009
|
+
Based on the git diff between ${diff.prevTag} and ${diff.currentTag}, write a section for DESIGN.md.
|
|
1010
|
+
Focus on key design decisions, why certain approaches were chosen, trade-offs made.
|
|
1011
|
+
Write in present tense. 2-3 paragraphs max.
|
|
1012
|
+
|
|
1013
|
+
Git diff:
|
|
1014
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1015
|
+
|
|
1016
|
+
'FRONTEND.md': (diff) =>
|
|
1017
|
+
`You are a technical writer following Harness Engineering documentation style.
|
|
1018
|
+
|
|
1019
|
+
Based on the git diff between ${diff.prevTag} and ${diff.currentTag}, write a section for FRONTEND.md.
|
|
1020
|
+
Focus on new components, UI patterns, styling changes, frontend architecture changes.
|
|
1021
|
+
Write in present tense. 2-3 paragraphs max.
|
|
1022
|
+
|
|
1023
|
+
Git diff:
|
|
1024
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1025
|
+
|
|
1026
|
+
'SECURITY.md': (diff) =>
|
|
1027
|
+
`You are a technical writer following Harness Engineering documentation style.
|
|
1028
|
+
|
|
1029
|
+
Based on the git diff between ${diff.prevTag} and ${diff.currentTag}, write a section for SECURITY.md.
|
|
1030
|
+
Focus on auth changes, permission model updates, new security boundaries, data handling changes.
|
|
1031
|
+
Write in present tense. 2-3 paragraphs max.
|
|
1032
|
+
|
|
1033
|
+
Git diff:
|
|
1034
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1035
|
+
|
|
1036
|
+
'RELIABILITY.md': (diff) =>
|
|
1037
|
+
`You are a technical writer following Harness Engineering documentation style.
|
|
1038
|
+
|
|
1039
|
+
Based on the git diff between ${diff.prevTag} and ${diff.currentTag}, write a section for RELIABILITY.md.
|
|
1040
|
+
Focus on error handling improvements, retry logic, circuit breakers, observability changes.
|
|
1041
|
+
Write in present tense. 2-3 paragraphs max.
|
|
1042
|
+
|
|
1043
|
+
Git diff:
|
|
1044
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1045
|
+
|
|
1046
|
+
'QUALITY_SCORE.md': (diff) =>
|
|
1047
|
+
`You are a technical writer following Harness Engineering documentation style.
|
|
1048
|
+
|
|
1049
|
+
Based on the git diff between ${diff.prevTag} and ${diff.currentTag}, write a quality assessment section.
|
|
1050
|
+
Assess test coverage changes, code complexity, technical debt introduced or resolved.
|
|
1051
|
+
Write in present tense. 1-2 paragraphs max.
|
|
1052
|
+
|
|
1053
|
+
Git diff:
|
|
1054
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1055
|
+
|
|
1056
|
+
'changelog': (diff) =>
|
|
1057
|
+
`Write a changelog entry in Markdown for changes from ${diff.prevTag} to ${diff.currentTag}.
|
|
1058
|
+
|
|
1059
|
+
Format:
|
|
1060
|
+
# Changelog: ${diff.currentTag}
|
|
1061
|
+
|
|
1062
|
+
## Added
|
|
1063
|
+
- ...
|
|
1064
|
+
|
|
1065
|
+
## Changed
|
|
1066
|
+
- ...
|
|
1067
|
+
|
|
1068
|
+
## Fixed
|
|
1069
|
+
- ...
|
|
1070
|
+
|
|
1071
|
+
## Removed
|
|
1072
|
+
- ...
|
|
1073
|
+
|
|
1074
|
+
Be specific and engineer-focused. Only include sections with actual content.
|
|
1075
|
+
|
|
1076
|
+
Git diff:
|
|
1077
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1078
|
+
|
|
1079
|
+
'design-doc': (diff) =>
|
|
1080
|
+
`You are a technical writer following Harness Engineering documentation style.
|
|
1081
|
+
|
|
1082
|
+
Write a design document for changes from ${diff.prevTag} to ${diff.currentTag}.
|
|
1083
|
+
|
|
1084
|
+
Format:
|
|
1085
|
+
# Design: ${diff.currentTag}
|
|
1086
|
+
|
|
1087
|
+
## Summary
|
|
1088
|
+
[What changed and why]
|
|
1089
|
+
|
|
1090
|
+
## Design Decisions
|
|
1091
|
+
[Key decisions with rationale]
|
|
1092
|
+
|
|
1093
|
+
## Agent Legibility Notes
|
|
1094
|
+
[What an AI coding agent needs to know to work in the updated codebase]
|
|
1095
|
+
|
|
1096
|
+
## Technical Debt
|
|
1097
|
+
[Any shortcuts taken, what should be cleaned up later — or "None identified"]
|
|
1098
|
+
|
|
1099
|
+
Git diff:
|
|
1100
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1101
|
+
|
|
1102
|
+
'design-doc-index': (diff) =>
|
|
1103
|
+
`Return only a single Markdown list item for a docs index. Format:
|
|
1104
|
+
- [${diff.currentTag}](${diff.currentTag}.md) — One-sentence summary of changes.
|
|
1105
|
+
|
|
1106
|
+
Changed files: ${diff.changedFiles.slice(0, 20).join(', ')}
|
|
1107
|
+
|
|
1108
|
+
Return only the list item, nothing else.`,
|
|
1109
|
+
|
|
1110
|
+
'tech-debt-tracker': (diff) =>
|
|
1111
|
+
`You are a technical writer following Harness Engineering documentation style.
|
|
1112
|
+
|
|
1113
|
+
Based on the git diff between ${diff.prevTag} and ${diff.currentTag}, identify technical debt.
|
|
1114
|
+
|
|
1115
|
+
Write this section:
|
|
1116
|
+
## ${diff.currentTag}
|
|
1117
|
+
|
|
1118
|
+
### New debt
|
|
1119
|
+
- [Shortcuts, hacks, or deferred work visible in the diff — or "None identified"]
|
|
1120
|
+
|
|
1121
|
+
### Resolved debt
|
|
1122
|
+
- [Cleanup or refactoring that resolves known issues — or "None identified"]
|
|
1123
|
+
|
|
1124
|
+
Git diff:
|
|
1125
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1126
|
+
|
|
1127
|
+
'db-schema': (diff) =>
|
|
1128
|
+
`You are a technical writer.
|
|
1129
|
+
|
|
1130
|
+
Based on the git diff between ${diff.prevTag} and ${diff.currentTag}, document database schema changes.
|
|
1131
|
+
Focus only on schema changes visible in the diff: new tables, columns, indexes, constraints.
|
|
1132
|
+
Format as Markdown with clear table descriptions.
|
|
1133
|
+
If no schema changes: write "No schema changes in this release."
|
|
1134
|
+
|
|
1135
|
+
Git diff:
|
|
1136
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1137
|
+
|
|
1138
|
+
'product-specs-index': (diff) =>
|
|
1139
|
+
`Return only a single Markdown list item for a product specs index, or an empty string if no clear new feature.
|
|
1140
|
+
Format (if applicable): - [Feature Name](feature-name.md) — One-sentence description.
|
|
1141
|
+
|
|
1142
|
+
Changed files: ${diff.changedFiles.slice(0, 20).join(', ')}
|
|
1143
|
+
|
|
1144
|
+
Return only the list item or empty string, nothing else.`,
|
|
1145
|
+
|
|
1146
|
+
'references': (diff) =>
|
|
1147
|
+
`You are a technical writer.
|
|
1148
|
+
|
|
1149
|
+
Based on the git diff between ${diff.prevTag} and ${diff.currentTag}, identify new external libraries introduced.
|
|
1150
|
+
For each new library write:
|
|
1151
|
+
|
|
1152
|
+
# library-name
|
|
1153
|
+
|
|
1154
|
+
One paragraph: what it does and how it is used in this codebase.
|
|
1155
|
+
|
|
1156
|
+
If no new external libraries: return an empty string.
|
|
1157
|
+
|
|
1158
|
+
Git diff:
|
|
1159
|
+
${diff.raw.slice(0, 8000)}`,
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
export interface GenerationResult {
|
|
1163
|
+
target: DocTarget;
|
|
1164
|
+
content: string;
|
|
1165
|
+
error?: string;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
export async function generateDocs(
|
|
1169
|
+
ai: AIProvider,
|
|
1170
|
+
diff: DiffResult,
|
|
1171
|
+
targets: DocTarget[]
|
|
1172
|
+
): Promise<GenerationResult[]> {
|
|
1173
|
+
return Promise.all(
|
|
1174
|
+
targets.map(async (target): Promise<GenerationResult> => {
|
|
1175
|
+
try {
|
|
1176
|
+
const prompt = PROMPTS[target](diff);
|
|
1177
|
+
const content = await ai.generate(prompt);
|
|
1178
|
+
return { target, content };
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
return { target, content: '', error: String(err) };
|
|
1181
|
+
}
|
|
1182
|
+
})
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
export function writeResults(
|
|
1187
|
+
results: GenerationResult[],
|
|
1188
|
+
diff: DiffResult,
|
|
1189
|
+
cwd: string
|
|
1190
|
+
): string[] {
|
|
1191
|
+
const written: string[] = [];
|
|
1192
|
+
for (const result of results) {
|
|
1193
|
+
if (result.error || !result.content.trim()) continue;
|
|
1194
|
+
const path = writeResult(result, diff, cwd);
|
|
1195
|
+
if (path) written.push(path);
|
|
1196
|
+
}
|
|
1197
|
+
return written;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function writeResult(
|
|
1201
|
+
result: GenerationResult,
|
|
1202
|
+
diff: DiffResult,
|
|
1203
|
+
cwd: string
|
|
1204
|
+
): string | null {
|
|
1205
|
+
const tag = diff.currentTag;
|
|
1206
|
+
const heading = `Changes in ${tag}`;
|
|
1207
|
+
|
|
1208
|
+
const appendTargets: DocTarget[] = [
|
|
1209
|
+
'AGENTS.md', 'ARCHITECTURE.md', 'DESIGN.md', 'FRONTEND.md',
|
|
1210
|
+
'SECURITY.md', 'RELIABILITY.md', 'QUALITY_SCORE.md',
|
|
1211
|
+
];
|
|
1212
|
+
|
|
1213
|
+
if ((appendTargets as string[]).includes(result.target)) {
|
|
1214
|
+
const path = `${cwd}/${result.target}`;
|
|
1215
|
+
appendSection(path, heading, result.content);
|
|
1216
|
+
return path;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
switch (result.target) {
|
|
1220
|
+
case 'changelog': {
|
|
1221
|
+
const path = `${cwd}/changelog/${tag}.md`;
|
|
1222
|
+
createFile(path, result.content);
|
|
1223
|
+
return path;
|
|
1224
|
+
}
|
|
1225
|
+
case 'design-doc': {
|
|
1226
|
+
const path = `${cwd}/docs/design-docs/${tag}.md`;
|
|
1227
|
+
createFile(path, result.content);
|
|
1228
|
+
return path;
|
|
1229
|
+
}
|
|
1230
|
+
case 'design-doc-index': {
|
|
1231
|
+
const path = `${cwd}/docs/design-docs/index.md`;
|
|
1232
|
+
appendSection(path, heading, result.content);
|
|
1233
|
+
return path;
|
|
1234
|
+
}
|
|
1235
|
+
case 'tech-debt-tracker': {
|
|
1236
|
+
const path = `${cwd}/docs/exec-plans/tech-debt-tracker.md`;
|
|
1237
|
+
appendSection(path, heading, result.content);
|
|
1238
|
+
return path;
|
|
1239
|
+
}
|
|
1240
|
+
case 'db-schema': {
|
|
1241
|
+
const path = `${cwd}/docs/generated/db-schema.md`;
|
|
1242
|
+
appendSection(path, heading, result.content);
|
|
1243
|
+
return path;
|
|
1244
|
+
}
|
|
1245
|
+
case 'product-specs-index': {
|
|
1246
|
+
if (!result.content.trim()) return null;
|
|
1247
|
+
const path = `${cwd}/docs/product-specs/index.md`;
|
|
1248
|
+
appendSection(path, heading, result.content);
|
|
1249
|
+
return path;
|
|
1250
|
+
}
|
|
1251
|
+
case 'references': {
|
|
1252
|
+
if (!result.content.trim()) return null;
|
|
1253
|
+
const libName = extractLibName(result.content);
|
|
1254
|
+
if (!libName) return null;
|
|
1255
|
+
const path = `${cwd}/docs/references/${libName}-llms.txt`;
|
|
1256
|
+
createFile(path, result.content);
|
|
1257
|
+
return path;
|
|
1258
|
+
}
|
|
1259
|
+
default:
|
|
1260
|
+
return null;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function extractLibName(content: string): string | null {
|
|
1265
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
1266
|
+
return match ? match[1].toLowerCase().replace(/[^a-z0-9-]/g, '-') : null;
|
|
1267
|
+
}
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1271
|
+
|
|
1272
|
+
```bash
|
|
1273
|
+
npm test -- tests/core/generator.test.ts
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1276
|
+
Expected: PASS
|
|
1277
|
+
|
|
1278
|
+
- [ ] **Step 5: Commit**
|
|
1279
|
+
|
|
1280
|
+
```bash
|
|
1281
|
+
git add src/core/generator.ts tests/core/generator.test.ts
|
|
1282
|
+
git commit -m "feat: add document generator with prompts and concurrent execution"
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
---
|
|
1286
|
+
|
|
1287
|
+
## Task 8: Platform Provider Interface + GitHub
|
|
1288
|
+
|
|
1289
|
+
**Files:**
|
|
1290
|
+
- Create: `src/providers/interface.ts`
|
|
1291
|
+
- Create: `src/providers/github.ts`
|
|
1292
|
+
- Create: `tests/providers/github.test.ts`
|
|
1293
|
+
|
|
1294
|
+
- [ ] **Step 1: Write the failing test**
|
|
1295
|
+
|
|
1296
|
+
```typescript
|
|
1297
|
+
// tests/providers/github.test.ts
|
|
1298
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
1299
|
+
import { GitHubProvider } from '../../src/providers/github.js';
|
|
1300
|
+
|
|
1301
|
+
vi.mock('child_process', () => ({
|
|
1302
|
+
execSync: vi.fn().mockReturnValue('git@github.com:myorg/myrepo.git\n'),
|
|
1303
|
+
}));
|
|
1304
|
+
|
|
1305
|
+
const mockOctokit = {
|
|
1306
|
+
pulls: {
|
|
1307
|
+
list: vi.fn().mockResolvedValue({ data: [] }),
|
|
1308
|
+
create: vi.fn().mockResolvedValue({
|
|
1309
|
+
data: { html_url: 'https://github.com/myorg/myrepo/pull/42', number: 42 },
|
|
1310
|
+
}),
|
|
1311
|
+
update: vi.fn().mockResolvedValue({
|
|
1312
|
+
data: { html_url: 'https://github.com/myorg/myrepo/pull/1' },
|
|
1313
|
+
}),
|
|
1314
|
+
},
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
vi.mock('@octokit/rest', () => ({
|
|
1318
|
+
Octokit: vi.fn().mockImplementation(() => mockOctokit),
|
|
1319
|
+
}));
|
|
1320
|
+
|
|
1321
|
+
describe('GitHubProvider', () => {
|
|
1322
|
+
beforeEach(() => vi.clearAllMocks());
|
|
1323
|
+
|
|
1324
|
+
it('parses owner and repo from SSH remote URL', () => {
|
|
1325
|
+
expect(() => new GitHubProvider('token')).not.toThrow();
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
it('creates a PR and returns its URL', async () => {
|
|
1329
|
+
const provider = new GitHubProvider('token');
|
|
1330
|
+
const url = await provider.createOrUpdatePR({
|
|
1331
|
+
branch: 'harness-docs/v1.1.0',
|
|
1332
|
+
title: 'docs: update for v1.1.0',
|
|
1333
|
+
body: 'Auto-generated docs update',
|
|
1334
|
+
baseBranch: 'main',
|
|
1335
|
+
});
|
|
1336
|
+
expect(url).toBe('https://github.com/myorg/myrepo/pull/42');
|
|
1337
|
+
expect(mockOctokit.pulls.create).toHaveBeenCalledWith(
|
|
1338
|
+
expect.objectContaining({
|
|
1339
|
+
head: 'harness-docs/v1.1.0',
|
|
1340
|
+
base: 'main',
|
|
1341
|
+
})
|
|
1342
|
+
);
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
it('updates existing PR if one already exists for the branch', async () => {
|
|
1346
|
+
mockOctokit.pulls.list.mockResolvedValueOnce({
|
|
1347
|
+
data: [{ number: 1, html_url: 'https://github.com/myorg/myrepo/pull/1' }],
|
|
1348
|
+
});
|
|
1349
|
+
const provider = new GitHubProvider('token');
|
|
1350
|
+
const url = await provider.createOrUpdatePR({
|
|
1351
|
+
branch: 'harness-docs/v1.1.0',
|
|
1352
|
+
title: 'docs: update for v1.1.0',
|
|
1353
|
+
body: 'Updated',
|
|
1354
|
+
baseBranch: 'main',
|
|
1355
|
+
});
|
|
1356
|
+
expect(mockOctokit.pulls.update).toHaveBeenCalled();
|
|
1357
|
+
expect(mockOctokit.pulls.create).not.toHaveBeenCalled();
|
|
1358
|
+
expect(url).toBe('https://github.com/myorg/myrepo/pull/1');
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
it('throws on unrecognized remote URL', () => {
|
|
1362
|
+
const { execSync } = vi.mocked(await import('child_process'));
|
|
1363
|
+
execSync.mockReturnValueOnce('https://not-github.com/something\n');
|
|
1364
|
+
expect(() => new GitHubProvider('token')).toThrow('Cannot parse GitHub remote URL');
|
|
1365
|
+
});
|
|
1366
|
+
});
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1370
|
+
|
|
1371
|
+
```bash
|
|
1372
|
+
npm test -- tests/providers/github.test.ts
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
Expected: FAIL — `Cannot find module '../../src/providers/github.js'`
|
|
1376
|
+
|
|
1377
|
+
- [ ] **Step 3: Create src/providers/interface.ts**
|
|
1378
|
+
|
|
1379
|
+
```typescript
|
|
1380
|
+
// src/providers/interface.ts
|
|
1381
|
+
export interface PlatformProvider {
|
|
1382
|
+
createOrUpdatePR(opts: {
|
|
1383
|
+
branch: string;
|
|
1384
|
+
title: string;
|
|
1385
|
+
body: string;
|
|
1386
|
+
baseBranch: string;
|
|
1387
|
+
}): Promise<string>; // returns PR/MR URL
|
|
1388
|
+
}
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
- [ ] **Step 4: Create src/providers/github.ts**
|
|
1392
|
+
|
|
1393
|
+
Note: The branch is already pushed by `cli.ts` via `git push`. This provider only creates/updates the PR via the GitHub API — it does NOT manage git refs.
|
|
1394
|
+
|
|
1395
|
+
```typescript
|
|
1396
|
+
// src/providers/github.ts
|
|
1397
|
+
import { Octokit } from '@octokit/rest';
|
|
1398
|
+
import { execSync } from 'child_process';
|
|
1399
|
+
import type { PlatformProvider } from './interface.js';
|
|
1400
|
+
|
|
1401
|
+
export class GitHubProvider implements PlatformProvider {
|
|
1402
|
+
private octokit: Octokit;
|
|
1403
|
+
private owner: string;
|
|
1404
|
+
private repo: string;
|
|
1405
|
+
|
|
1406
|
+
constructor(token: string) {
|
|
1407
|
+
this.octokit = new Octokit({ auth: token });
|
|
1408
|
+
const remoteUrl = execSync('git remote get-url origin').toString().trim();
|
|
1409
|
+
const match = remoteUrl.match(/github\.com[/:](.+?)\/(.+?)(?:\.git)?$/);
|
|
1410
|
+
if (!match) throw new Error(`Cannot parse GitHub remote URL: ${remoteUrl}`);
|
|
1411
|
+
this.owner = match[1];
|
|
1412
|
+
this.repo = match[2];
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
async createOrUpdatePR(opts: {
|
|
1416
|
+
branch: string;
|
|
1417
|
+
title: string;
|
|
1418
|
+
body: string;
|
|
1419
|
+
baseBranch: string;
|
|
1420
|
+
}): Promise<string> {
|
|
1421
|
+
// Check for an existing open PR for this branch
|
|
1422
|
+
const { data: existingPRs } = await this.octokit.pulls.list({
|
|
1423
|
+
owner: this.owner,
|
|
1424
|
+
repo: this.repo,
|
|
1425
|
+
head: `${this.owner}:${opts.branch}`,
|
|
1426
|
+
state: 'open',
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
if (existingPRs.length > 0) {
|
|
1430
|
+
const { data: pr } = await this.octokit.pulls.update({
|
|
1431
|
+
owner: this.owner,
|
|
1432
|
+
repo: this.repo,
|
|
1433
|
+
pull_number: existingPRs[0].number,
|
|
1434
|
+
title: opts.title,
|
|
1435
|
+
body: opts.body,
|
|
1436
|
+
});
|
|
1437
|
+
return pr.html_url;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const { data: pr } = await this.octokit.pulls.create({
|
|
1441
|
+
owner: this.owner,
|
|
1442
|
+
repo: this.repo,
|
|
1443
|
+
title: opts.title,
|
|
1444
|
+
body: opts.body,
|
|
1445
|
+
head: opts.branch,
|
|
1446
|
+
base: opts.baseBranch,
|
|
1447
|
+
});
|
|
1448
|
+
return pr.html_url;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
1454
|
+
|
|
1455
|
+
```bash
|
|
1456
|
+
npm test -- tests/providers/github.test.ts
|
|
1457
|
+
```
|
|
1458
|
+
|
|
1459
|
+
Expected: PASS
|
|
1460
|
+
|
|
1461
|
+
- [ ] **Step 6: Commit**
|
|
1462
|
+
|
|
1463
|
+
```bash
|
|
1464
|
+
git add src/providers/interface.ts src/providers/github.ts tests/providers/github.test.ts
|
|
1465
|
+
git commit -m "feat: add platform provider interface and GitHub implementation"
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
---
|
|
1469
|
+
|
|
1470
|
+
## Task 9: GitLab Stub
|
|
1471
|
+
|
|
1472
|
+
**Files:**
|
|
1473
|
+
- Create: `src/providers/gitlab.ts`
|
|
1474
|
+
|
|
1475
|
+
- [ ] **Step 1: Create src/providers/gitlab.ts**
|
|
1476
|
+
|
|
1477
|
+
```typescript
|
|
1478
|
+
// src/providers/gitlab.ts
|
|
1479
|
+
import type { PlatformProvider } from './interface.js';
|
|
1480
|
+
|
|
1481
|
+
export class GitLabProvider implements PlatformProvider {
|
|
1482
|
+
async createOrUpdatePR(_opts: {
|
|
1483
|
+
branch: string;
|
|
1484
|
+
title: string;
|
|
1485
|
+
body: string;
|
|
1486
|
+
baseBranch: string;
|
|
1487
|
+
}): Promise<string> {
|
|
1488
|
+
throw new Error(
|
|
1489
|
+
'GitLab provider is not yet implemented. ' +
|
|
1490
|
+
'Contributions welcome: https://github.com/your-org/harness-engineering-auto-docs'
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
- [ ] **Step 2: Commit**
|
|
1497
|
+
|
|
1498
|
+
```bash
|
|
1499
|
+
git add src/providers/gitlab.ts
|
|
1500
|
+
git commit -m "feat: add GitLab provider stub"
|
|
1501
|
+
```
|
|
1502
|
+
|
|
1503
|
+
---
|
|
1504
|
+
|
|
1505
|
+
## Task 10: CLI Entry Point
|
|
1506
|
+
|
|
1507
|
+
**Files:**
|
|
1508
|
+
- Create: `src/cli.ts`
|
|
1509
|
+
- Create: `tests/integration/generate.test.ts`
|
|
1510
|
+
|
|
1511
|
+
- [ ] **Step 1: Write the integration test**
|
|
1512
|
+
|
|
1513
|
+
```typescript
|
|
1514
|
+
// tests/integration/generate.test.ts
|
|
1515
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
1516
|
+
import { mkdirSync, rmSync, existsSync } from 'fs';
|
|
1517
|
+
import { join } from 'path';
|
|
1518
|
+
import { generateDocs, writeResults } from '../../src/core/generator.js';
|
|
1519
|
+
import { selectTargets } from '../../src/core/relevance.js';
|
|
1520
|
+
import { parseChangedFiles, groupFiles } from '../../src/core/diff.js';
|
|
1521
|
+
import { readFileSync } from 'fs';
|
|
1522
|
+
import type { AIProvider } from '../../src/ai/interface.js';
|
|
1523
|
+
|
|
1524
|
+
const TMP = join(import.meta.dirname, '../../tmp-integration-test');
|
|
1525
|
+
const FIXTURE = readFileSync(
|
|
1526
|
+
join(import.meta.dirname, '../fixtures/diff-schema.txt'), 'utf-8'
|
|
1527
|
+
);
|
|
1528
|
+
|
|
1529
|
+
beforeEach(() => mkdirSync(TMP, { recursive: true }));
|
|
1530
|
+
afterEach(() => rmSync(TMP, { recursive: true, force: true }));
|
|
1531
|
+
|
|
1532
|
+
describe('full pipeline: diff → relevance → generate → write', () => {
|
|
1533
|
+
it('generates and writes expected documents for schema diff', async () => {
|
|
1534
|
+
const changedFiles = parseChangedFiles(FIXTURE);
|
|
1535
|
+
const fileGroups = groupFiles(changedFiles);
|
|
1536
|
+
const targets = selectTargets(fileGroups, changedFiles);
|
|
1537
|
+
|
|
1538
|
+
expect(targets).toContain('db-schema');
|
|
1539
|
+
expect(targets).toContain('AGENTS.md');
|
|
1540
|
+
|
|
1541
|
+
const mockAI: AIProvider = {
|
|
1542
|
+
generate: vi.fn().mockResolvedValue('Auto-generated documentation content'),
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
const diff = {
|
|
1546
|
+
raw: FIXTURE,
|
|
1547
|
+
prevTag: 'v1.0.0',
|
|
1548
|
+
currentTag: 'v1.1.0',
|
|
1549
|
+
changedFiles,
|
|
1550
|
+
fileGroups,
|
|
1551
|
+
};
|
|
1552
|
+
|
|
1553
|
+
const results = await generateDocs(mockAI, diff, targets);
|
|
1554
|
+
const written = writeResults(results, diff, TMP);
|
|
1555
|
+
|
|
1556
|
+
expect(written.length).toBeGreaterThan(0);
|
|
1557
|
+
expect(existsSync(`${TMP}/AGENTS.md`)).toBe(true);
|
|
1558
|
+
expect(existsSync(`${TMP}/docs/generated/db-schema.md`)).toBe(true);
|
|
1559
|
+
expect(existsSync(`${TMP}/changelog/v1.1.0.md`)).toBe(true);
|
|
1560
|
+
expect(existsSync(`${TMP}/docs/design-docs/v1.1.0.md`)).toBe(true);
|
|
1561
|
+
});
|
|
1562
|
+
});
|
|
1563
|
+
```
|
|
1564
|
+
|
|
1565
|
+
- [ ] **Step 2: Run integration test to verify it passes**
|
|
1566
|
+
|
|
1567
|
+
```bash
|
|
1568
|
+
npm test -- tests/integration/generate.test.ts
|
|
1569
|
+
```
|
|
1570
|
+
|
|
1571
|
+
Expected: PASS (this tests the core pipeline, mocking AI)
|
|
1572
|
+
|
|
1573
|
+
- [ ] **Step 3: Create src/cli.ts**
|
|
1574
|
+
|
|
1575
|
+
```typescript
|
|
1576
|
+
#!/usr/bin/env node
|
|
1577
|
+
// src/cli.ts
|
|
1578
|
+
import { execSync } from 'child_process';
|
|
1579
|
+
import { extractDiff } from './core/diff.js';
|
|
1580
|
+
import { selectTargets } from './core/relevance.js';
|
|
1581
|
+
import { generateDocs, writeResults } from './core/generator.js';
|
|
1582
|
+
import { GitHubProvider } from './providers/github.js';
|
|
1583
|
+
import { GitLabProvider } from './providers/gitlab.js';
|
|
1584
|
+
import { AnthropicProvider } from './ai/anthropic.js';
|
|
1585
|
+
import { OpenAIProvider } from './ai/openai.js';
|
|
1586
|
+
import type { AIProvider } from './ai/interface.js';
|
|
1587
|
+
import type { PlatformProvider } from './providers/interface.js';
|
|
1588
|
+
|
|
1589
|
+
function requireEnv(name: string): string {
|
|
1590
|
+
const value = process.env[name];
|
|
1591
|
+
if (!value) {
|
|
1592
|
+
console.error(`Error: ${name} environment variable is required`);
|
|
1593
|
+
process.exit(1);
|
|
1594
|
+
}
|
|
1595
|
+
return value;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function detectPlatform(): PlatformProvider {
|
|
1599
|
+
if (process.env.GITHUB_ACTIONS) {
|
|
1600
|
+
return new GitHubProvider(requireEnv('GITHUB_TOKEN'));
|
|
1601
|
+
}
|
|
1602
|
+
if (process.env.GITLAB_CI) {
|
|
1603
|
+
return new GitLabProvider();
|
|
1604
|
+
}
|
|
1605
|
+
// Default to GitHub for local runs
|
|
1606
|
+
const token = process.env.GITHUB_TOKEN;
|
|
1607
|
+
if (!token) {
|
|
1608
|
+
console.error('Error: GITHUB_TOKEN is required (or run in GITHUB_ACTIONS / GITLAB_CI)');
|
|
1609
|
+
process.exit(1);
|
|
1610
|
+
}
|
|
1611
|
+
return new GitHubProvider(token);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function selectAI(model: string, apiKey: string): AIProvider {
|
|
1615
|
+
if (model.startsWith('claude-')) return new AnthropicProvider(apiKey, model);
|
|
1616
|
+
if (model.startsWith('gpt-')) return new OpenAIProvider(apiKey, model);
|
|
1617
|
+
console.error(`Error: Unknown model "${model}". Use a claude-* or gpt-* model.`);
|
|
1618
|
+
process.exit(1);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function getDefaultBranch(): string {
|
|
1622
|
+
try {
|
|
1623
|
+
return execSync('git symbolic-ref refs/remotes/origin/HEAD --short')
|
|
1624
|
+
.toString().trim().replace('origin/', '');
|
|
1625
|
+
} catch {
|
|
1626
|
+
return 'main';
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
async function main() {
|
|
1631
|
+
const model = requireEnv('AI_MODEL');
|
|
1632
|
+
const apiKey = requireEnv('AI_API_KEY');
|
|
1633
|
+
|
|
1634
|
+
const diff = extractDiff();
|
|
1635
|
+
|
|
1636
|
+
if (!diff.raw.trim()) {
|
|
1637
|
+
console.log('No diff between tags. Nothing to document.');
|
|
1638
|
+
process.exit(0);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
console.log(`Generating docs: ${diff.prevTag} → ${diff.currentTag}`);
|
|
1642
|
+
|
|
1643
|
+
const ai = selectAI(model, apiKey);
|
|
1644
|
+
const targets = selectTargets(diff.fileGroups, diff.changedFiles);
|
|
1645
|
+
console.log(`Updating ${targets.length} documents: ${targets.join(', ')}`);
|
|
1646
|
+
|
|
1647
|
+
const results = await generateDocs(ai, diff, targets);
|
|
1648
|
+
|
|
1649
|
+
const failures = results.filter(r => r.error);
|
|
1650
|
+
if (failures.length > 0) {
|
|
1651
|
+
console.warn(`\nWarning: ${failures.length} document(s) failed:`);
|
|
1652
|
+
failures.forEach(f => console.warn(` - ${f.target}: ${f.error}`));
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const cwd = process.cwd();
|
|
1656
|
+
const writtenFiles = writeResults(results.filter(r => !r.error), diff, cwd);
|
|
1657
|
+
console.log(`\nWrote ${writtenFiles.length} files.`);
|
|
1658
|
+
|
|
1659
|
+
if (writtenFiles.length === 0) {
|
|
1660
|
+
console.log('No files written. Skipping PR creation.');
|
|
1661
|
+
process.exit(0);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// Git: create branch, commit, push
|
|
1665
|
+
const branch = `harness-docs/${diff.currentTag}`;
|
|
1666
|
+
const baseBranch = getDefaultBranch();
|
|
1667
|
+
|
|
1668
|
+
execSync(`git checkout -b ${branch}`);
|
|
1669
|
+
execSync(`git add ${writtenFiles.map(f => `"${f}"`).join(' ')}`);
|
|
1670
|
+
execSync(
|
|
1671
|
+
`git commit -m "docs: Harness Engineering docs update for ${diff.currentTag}"`,
|
|
1672
|
+
{ env: { ...process.env, GIT_AUTHOR_NAME: 'harness-engineering-auto-docs', GIT_AUTHOR_EMAIL: 'bot@harness-engineering-auto-docs' } }
|
|
1673
|
+
);
|
|
1674
|
+
execSync(`git push -u origin ${branch}`);
|
|
1675
|
+
|
|
1676
|
+
// Create PR
|
|
1677
|
+
const platform = detectPlatform();
|
|
1678
|
+
const prUrl = await platform.createOrUpdatePR({
|
|
1679
|
+
branch,
|
|
1680
|
+
title: `docs: Harness Engineering docs for ${diff.currentTag}`,
|
|
1681
|
+
body: [
|
|
1682
|
+
`Auto-generated by [harness-engineering-auto-docs](https://github.com/your-org/harness-engineering-auto-docs).`,
|
|
1683
|
+
``,
|
|
1684
|
+
`Updates documentation based on changes from **${diff.prevTag}** to **${diff.currentTag}**.`,
|
|
1685
|
+
``,
|
|
1686
|
+
`**Updated files:**`,
|
|
1687
|
+
writtenFiles.map(f => `- \`${f.replace(cwd + '/', '')}\``).join('\n'),
|
|
1688
|
+
].join('\n'),
|
|
1689
|
+
baseBranch,
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
console.log(`\nPR created: ${prUrl}`);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
main().catch(err => {
|
|
1696
|
+
console.error('Fatal:', err);
|
|
1697
|
+
process.exit(1);
|
|
1698
|
+
});
|
|
1699
|
+
```
|
|
1700
|
+
|
|
1701
|
+
- [ ] **Step 4: Run all tests**
|
|
1702
|
+
|
|
1703
|
+
```bash
|
|
1704
|
+
npm test
|
|
1705
|
+
```
|
|
1706
|
+
|
|
1707
|
+
Expected: All tests PASS
|
|
1708
|
+
|
|
1709
|
+
- [ ] **Step 5: Commit**
|
|
1710
|
+
|
|
1711
|
+
```bash
|
|
1712
|
+
git add src/cli.ts tests/integration/generate.test.ts
|
|
1713
|
+
git commit -m "feat: add CLI entry point"
|
|
1714
|
+
```
|
|
1715
|
+
|
|
1716
|
+
---
|
|
1717
|
+
|
|
1718
|
+
## Task 11: Example Workflow + Build
|
|
1719
|
+
|
|
1720
|
+
**Files:**
|
|
1721
|
+
- Create: `examples/github-workflow.yml`
|
|
1722
|
+
- Modify: `README.md`
|
|
1723
|
+
|
|
1724
|
+
- [ ] **Step 1: Create examples/github-workflow.yml**
|
|
1725
|
+
|
|
1726
|
+
```yaml
|
|
1727
|
+
# examples/github-workflow.yml
|
|
1728
|
+
# Copy this file to .github/workflows/harness-docs.yml in your target project.
|
|
1729
|
+
|
|
1730
|
+
name: Harness Engineering Docs
|
|
1731
|
+
|
|
1732
|
+
on:
|
|
1733
|
+
push:
|
|
1734
|
+
tags:
|
|
1735
|
+
- 'v*'
|
|
1736
|
+
|
|
1737
|
+
jobs:
|
|
1738
|
+
update-docs:
|
|
1739
|
+
runs-on: ubuntu-latest
|
|
1740
|
+
permissions:
|
|
1741
|
+
contents: write
|
|
1742
|
+
pull-requests: write
|
|
1743
|
+
|
|
1744
|
+
steps:
|
|
1745
|
+
- uses: actions/checkout@v4
|
|
1746
|
+
with:
|
|
1747
|
+
fetch-depth: 0 # Required: fetch all tags for diff
|
|
1748
|
+
|
|
1749
|
+
- uses: actions/setup-node@v4
|
|
1750
|
+
with:
|
|
1751
|
+
node-version: '20'
|
|
1752
|
+
|
|
1753
|
+
- name: Generate and PR Harness Engineering docs
|
|
1754
|
+
run: npx harness-engineering-auto-docs@latest
|
|
1755
|
+
env:
|
|
1756
|
+
AI_MODEL: claude-sonnet-4-6 # or gpt-4o
|
|
1757
|
+
AI_API_KEY: ${{ secrets.AI_API_KEY }}
|
|
1758
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
1759
|
+
```
|
|
1760
|
+
|
|
1761
|
+
- [ ] **Step 2: Write README.md**
|
|
1762
|
+
|
|
1763
|
+
```markdown
|
|
1764
|
+
# harness-engineering-auto-docs
|
|
1765
|
+
|
|
1766
|
+
Auto-generate [Harness Engineering](https://openai.com/research/harness-engineering) style documentation when your project creates a git tag. Opens a PR with updated docs.
|
|
1767
|
+
|
|
1768
|
+
## Usage
|
|
1769
|
+
|
|
1770
|
+
Add `.github/workflows/harness-docs.yml` to your project (see `examples/github-workflow.yml`), then set:
|
|
1771
|
+
|
|
1772
|
+
- `AI_API_KEY` — your Anthropic or OpenAI API key (repository secret)
|
|
1773
|
+
- `AI_MODEL` — model name, e.g. `claude-sonnet-4-6` or `gpt-4o`
|
|
1774
|
+
- `GITHUB_TOKEN` — provided automatically by GitHub Actions
|
|
1775
|
+
|
|
1776
|
+
When you push a tag (`git tag v1.2.0 && git push --tags`), a PR is automatically opened with updated documentation.
|
|
1777
|
+
|
|
1778
|
+
## What gets updated
|
|
1779
|
+
|
|
1780
|
+
| File | Always | Conditional |
|
|
1781
|
+
|------|--------|-------------|
|
|
1782
|
+
| `AGENTS.md` | ✓ | |
|
|
1783
|
+
| `ARCHITECTURE.md` | ✓ | |
|
|
1784
|
+
| `DESIGN.md` | ✓ | |
|
|
1785
|
+
| `QUALITY_SCORE.md` | ✓ | |
|
|
1786
|
+
| `changelog/vX.Y.Z.md` | ✓ | |
|
|
1787
|
+
| `docs/design-docs/vX.Y.Z.md` | ✓ | |
|
|
1788
|
+
| `docs/design-docs/index.md` | ✓ | |
|
|
1789
|
+
| `docs/exec-plans/tech-debt-tracker.md` | ✓ | |
|
|
1790
|
+
| `FRONTEND.md` | | frontend files changed |
|
|
1791
|
+
| `SECURITY.md` | | auth/security files changed |
|
|
1792
|
+
| `RELIABILITY.md` | | infra files changed |
|
|
1793
|
+
| `docs/generated/db-schema.md` | | SQL/schema files changed |
|
|
1794
|
+
| `docs/product-specs/index.md` | | new features detected |
|
|
1795
|
+
| `docs/references/` | | new dependencies added |
|
|
1796
|
+
|
|
1797
|
+
## Local run
|
|
1798
|
+
|
|
1799
|
+
```bash
|
|
1800
|
+
AI_MODEL=claude-sonnet-4-6 AI_API_KEY=sk-ant-... GITHUB_TOKEN=ghp_... npx harness-engineering-auto-docs
|
|
1801
|
+
```
|
|
1802
|
+
|
|
1803
|
+
## Supported models
|
|
1804
|
+
|
|
1805
|
+
- Claude: `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-haiku-4-5-20251001`
|
|
1806
|
+
- OpenAI: `gpt-4o`, `gpt-4o-mini`, `o3`
|
|
1807
|
+
|
|
1808
|
+
## Future
|
|
1809
|
+
|
|
1810
|
+
- GitLab support (MR creation)
|
|
1811
|
+
```
|
|
1812
|
+
|
|
1813
|
+
- [ ] **Step 3: Build the project**
|
|
1814
|
+
|
|
1815
|
+
```bash
|
|
1816
|
+
npm run build
|
|
1817
|
+
```
|
|
1818
|
+
|
|
1819
|
+
Expected: `dist/` directory created with `cli.js` and all compiled files, no TypeScript errors.
|
|
1820
|
+
|
|
1821
|
+
- [ ] **Step 4: Verify CLI is executable**
|
|
1822
|
+
|
|
1823
|
+
```bash
|
|
1824
|
+
node dist/cli.js 2>&1 | head -5
|
|
1825
|
+
```
|
|
1826
|
+
|
|
1827
|
+
Expected: Output starts with `Error: AI_MODEL environment variable is required` — confirms the CLI loads correctly.
|
|
1828
|
+
|
|
1829
|
+
- [ ] **Step 5: Commit**
|
|
1830
|
+
|
|
1831
|
+
```bash
|
|
1832
|
+
git add examples/github-workflow.yml README.md dist/
|
|
1833
|
+
git commit -m "feat: add example workflow, README, and build output"
|
|
1834
|
+
```
|
|
1835
|
+
|
|
1836
|
+
---
|
|
1837
|
+
|
|
1838
|
+
## Task 12: Full Test Suite
|
|
1839
|
+
|
|
1840
|
+
- [ ] **Step 1: Run complete test suite**
|
|
1841
|
+
|
|
1842
|
+
```bash
|
|
1843
|
+
npm test
|
|
1844
|
+
```
|
|
1845
|
+
|
|
1846
|
+
Expected: All tests PASS, zero failures.
|
|
1847
|
+
|
|
1848
|
+
- [ ] **Step 2: Verify test count**
|
|
1849
|
+
|
|
1850
|
+
```bash
|
|
1851
|
+
npm test -- --reporter=verbose 2>&1 | tail -20
|
|
1852
|
+
```
|
|
1853
|
+
|
|
1854
|
+
Expected: At minimum 20 tests across all files, all green.
|
|
1855
|
+
|
|
1856
|
+
- [ ] **Step 3: Final commit**
|
|
1857
|
+
|
|
1858
|
+
```bash
|
|
1859
|
+
git add -A
|
|
1860
|
+
git status
|
|
1861
|
+
# Verify nothing unexpected is staged
|
|
1862
|
+
git commit -m "chore: final cleanup and verified test suite"
|
|
1863
|
+
```
|