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.
Files changed (67) hide show
  1. package/.nvmrc +1 -0
  2. package/AGENTS.md +69 -0
  3. package/ARCHITECTURE.md +123 -0
  4. package/README.md +52 -0
  5. package/dist/ai/anthropic.d.ts +7 -0
  6. package/dist/ai/anthropic.js +20 -0
  7. package/dist/ai/interface.d.ts +3 -0
  8. package/dist/ai/interface.js +1 -0
  9. package/dist/ai/minimax.d.ts +7 -0
  10. package/dist/ai/minimax.js +21 -0
  11. package/dist/ai/openai.d.ts +7 -0
  12. package/dist/ai/openai.js +16 -0
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.js +103 -0
  15. package/dist/core/diff.d.ts +17 -0
  16. package/dist/core/diff.js +46 -0
  17. package/dist/core/generator.d.ts +10 -0
  18. package/dist/core/generator.js +238 -0
  19. package/dist/core/relevance.d.ts +3 -0
  20. package/dist/core/relevance.js +29 -0
  21. package/dist/core/writer.d.ts +2 -0
  22. package/dist/core/writer.js +23 -0
  23. package/dist/providers/github.d.ts +13 -0
  24. package/dist/providers/github.js +43 -0
  25. package/dist/providers/gitlab.d.ts +9 -0
  26. package/dist/providers/gitlab.js +6 -0
  27. package/dist/providers/interface.d.ts +8 -0
  28. package/dist/providers/interface.js +1 -0
  29. package/docs/DESIGN.md +94 -0
  30. package/docs/QUALITY_SCORE.md +74 -0
  31. package/docs/design-docs/core-beliefs.md +71 -0
  32. package/docs/design-docs/index.md +32 -0
  33. package/docs/exec-plans/tech-debt-tracker.md +26 -0
  34. package/docs/product-specs/index.md +39 -0
  35. package/docs/references/anthropic-sdk-llms.txt +40 -0
  36. package/docs/references/octokit-rest-llms.txt +44 -0
  37. package/docs/references/openai-sdk-llms.txt +38 -0
  38. package/docs/superpowers/plans/2026-04-03-harness-engineering-auto-docs.md +1863 -0
  39. package/docs/superpowers/specs/2026-04-03-harness-engineering-auto-docs-design.md +169 -0
  40. package/examples/github-workflow.yml +32 -0
  41. package/markdown/harness-engineering-codex-agent-first-world.md +215 -0
  42. package/package.json +30 -0
  43. package/src/ai/anthropic.ts +23 -0
  44. package/src/ai/interface.ts +3 -0
  45. package/src/ai/minimax.ts +25 -0
  46. package/src/ai/openai.ts +20 -0
  47. package/src/cli.ts +122 -0
  48. package/src/core/diff.ts +77 -0
  49. package/src/core/generator.ts +294 -0
  50. package/src/core/relevance.ts +53 -0
  51. package/src/core/writer.ts +25 -0
  52. package/src/providers/github.ts +53 -0
  53. package/src/providers/gitlab.ts +16 -0
  54. package/src/providers/interface.ts +9 -0
  55. package/tests/core/anthropic.test.ts +33 -0
  56. package/tests/core/diff.test.ts +49 -0
  57. package/tests/core/generator.test.ts +93 -0
  58. package/tests/core/openai.test.ts +38 -0
  59. package/tests/core/relevance.test.ts +62 -0
  60. package/tests/core/writer.test.ts +56 -0
  61. package/tests/fixtures/diff-frontend.txt +11 -0
  62. package/tests/fixtures/diff-schema.txt +12 -0
  63. package/tests/fixtures/diff-small.txt +16 -0
  64. package/tests/integration/generate.test.ts +49 -0
  65. package/tests/providers/github.test.ts +69 -0
  66. package/tsconfig.json +15 -0
  67. package/vitest.config.ts +7 -0
@@ -0,0 +1,93 @@
1
+ // tests/core/generator.test.ts
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { mkdirSync, rmSync, existsSync, readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { generateDocs, writeResults } from '../../src/core/generator.js';
6
+ import type { AIProvider } from '../../src/ai/interface.js';
7
+ import type { DiffResult } from '../../src/core/diff.js';
8
+
9
+ const TMP = join(import.meta.dirname, '../../tmp-generator-test');
10
+
11
+ beforeEach(() => mkdirSync(TMP, { recursive: true }));
12
+ afterEach(() => rmSync(TMP, { recursive: true, force: true }));
13
+
14
+ const mockAI: AIProvider = {
15
+ generate: vi.fn().mockResolvedValue('AI generated content for this section'),
16
+ };
17
+
18
+ const fakeDiff: DiffResult = {
19
+ raw: 'diff --git a/src/auth/login.ts b/src/auth/login.ts\n+new code',
20
+ prevTag: 'v1.0.0',
21
+ currentTag: 'v1.1.0',
22
+ changedFiles: ['src/auth/login.ts'],
23
+ fileGroups: {
24
+ frontend: [],
25
+ schema: [],
26
+ auth: ['src/auth/login.ts'],
27
+ infra: [],
28
+ other: [],
29
+ },
30
+ };
31
+
32
+ describe('generateDocs', () => {
33
+ it('calls AI for each target and returns results', async () => {
34
+ const results = await generateDocs(mockAI, fakeDiff, ['AGENTS.md', 'ARCHITECTURE.md']);
35
+ expect(results).toHaveLength(2);
36
+ expect(results[0].target).toBe('AGENTS.md');
37
+ expect(results[0].content).toBe('AI generated content for this section');
38
+ expect(mockAI.generate).toHaveBeenCalledTimes(2);
39
+ });
40
+
41
+ it('captures errors without throwing', async () => {
42
+ const failingAI: AIProvider = {
43
+ generate: vi.fn().mockRejectedValue(new Error('API rate limit')),
44
+ };
45
+ const results = await generateDocs(failingAI, fakeDiff, ['AGENTS.md']);
46
+ expect(results[0].error).toContain('API rate limit');
47
+ expect(results[0].content).toBe('');
48
+ });
49
+
50
+ it('runs targets concurrently', async () => {
51
+ const timedAI: AIProvider = {
52
+ generate: vi.fn().mockImplementation(async () => {
53
+ await new Promise(r => setTimeout(r, 50));
54
+ return 'content';
55
+ }),
56
+ };
57
+ const start = Date.now();
58
+ await generateDocs(timedAI, fakeDiff, ['AGENTS.md', 'ARCHITECTURE.md', 'DESIGN.md']);
59
+ const elapsed = Date.now() - start;
60
+ expect(elapsed).toBeLessThan(130);
61
+ });
62
+ });
63
+
64
+ describe('writeResults', () => {
65
+ it('writes AGENTS.md as appended section', async () => {
66
+ const results = await generateDocs(mockAI, fakeDiff, ['AGENTS.md']);
67
+ const written = writeResults(results, fakeDiff, TMP);
68
+ expect(written).toContain(`${TMP}/AGENTS.md`);
69
+ const content = readFileSync(`${TMP}/AGENTS.md`, 'utf-8');
70
+ expect(content).toContain('## Changes in v1.1.0');
71
+ });
72
+
73
+ it('creates changelog file at versioned path', async () => {
74
+ const results = await generateDocs(mockAI, fakeDiff, ['changelog']);
75
+ writeResults(results, fakeDiff, TMP);
76
+ expect(existsSync(`${TMP}/changelog/v1.1.0.md`)).toBe(true);
77
+ });
78
+
79
+ it('creates design-doc at versioned path', async () => {
80
+ const results = await generateDocs(mockAI, fakeDiff, ['design-doc']);
81
+ writeResults(results, fakeDiff, TMP);
82
+ expect(existsSync(`${TMP}/docs/design-docs/v1.1.0.md`)).toBe(true);
83
+ });
84
+
85
+ it('skips results with errors', async () => {
86
+ const failingAI: AIProvider = {
87
+ generate: vi.fn().mockRejectedValue(new Error('fail')),
88
+ };
89
+ const results = await generateDocs(failingAI, fakeDiff, ['AGENTS.md']);
90
+ const written = writeResults(results, fakeDiff, TMP);
91
+ expect(written).toHaveLength(0);
92
+ });
93
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { OpenAIProvider } from '../../src/ai/openai.js';
3
+
4
+ vi.mock('openai', () => ({
5
+ default: vi.fn().mockImplementation(() => ({
6
+ chat: {
7
+ completions: {
8
+ create: vi.fn().mockResolvedValue({
9
+ choices: [{ message: { content: 'gpt generated content' } }],
10
+ }),
11
+ },
12
+ },
13
+ })),
14
+ }));
15
+
16
+ describe('OpenAIProvider', () => {
17
+ it('returns text content from the API response', async () => {
18
+ const provider = new OpenAIProvider('test-key', 'gpt-4o');
19
+ const result = await provider.generate('write docs for this diff');
20
+ expect(result).toBe('gpt generated content');
21
+ });
22
+
23
+ it('returns empty string when message content is null', async () => {
24
+ const { default: OpenAI } = await import('openai');
25
+ vi.mocked(OpenAI).mockImplementationOnce(() => ({
26
+ chat: {
27
+ completions: {
28
+ create: vi.fn().mockResolvedValue({
29
+ choices: [{ message: { content: null } }],
30
+ }),
31
+ },
32
+ },
33
+ }) as any);
34
+ const provider = new OpenAIProvider('test-key', 'gpt-4o');
35
+ const result = await provider.generate('prompt');
36
+ expect(result).toBe('');
37
+ });
38
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { selectTargets } from '../../src/core/relevance.js';
3
+ import type { FileGroups } from '../../src/core/diff.js';
4
+
5
+ const emptyGroups: FileGroups = {
6
+ frontend: [], schema: [], auth: [], infra: [], other: [],
7
+ };
8
+
9
+ describe('selectTargets', () => {
10
+ it('always includes core targets', () => {
11
+ const targets = selectTargets(emptyGroups, []);
12
+ expect(targets).toContain('AGENTS.md');
13
+ expect(targets).toContain('ARCHITECTURE.md');
14
+ expect(targets).toContain('DESIGN.md');
15
+ expect(targets).toContain('QUALITY_SCORE.md');
16
+ expect(targets).toContain('changelog');
17
+ expect(targets).toContain('design-doc');
18
+ expect(targets).toContain('design-doc-index');
19
+ expect(targets).toContain('tech-debt-tracker');
20
+ });
21
+
22
+ it('includes FRONTEND.md only when frontend files changed', () => {
23
+ const withFrontend = selectTargets(
24
+ { ...emptyGroups, frontend: ['src/ui/Button.tsx'] }, []
25
+ );
26
+ expect(withFrontend).toContain('FRONTEND.md');
27
+
28
+ const withoutFrontend = selectTargets(emptyGroups, []);
29
+ expect(withoutFrontend).not.toContain('FRONTEND.md');
30
+ });
31
+
32
+ it('includes SECURITY.md only when auth files changed', () => {
33
+ const targets = selectTargets(
34
+ { ...emptyGroups, auth: ['src/auth/login.ts'] }, []
35
+ );
36
+ expect(targets).toContain('SECURITY.md');
37
+ });
38
+
39
+ it('includes RELIABILITY.md only when infra files changed', () => {
40
+ const targets = selectTargets(
41
+ { ...emptyGroups, infra: ['infra/deploy.yaml'] }, []
42
+ );
43
+ expect(targets).toContain('RELIABILITY.md');
44
+ });
45
+
46
+ it('includes db-schema only when schema files changed', () => {
47
+ const targets = selectTargets(
48
+ { ...emptyGroups, schema: ['migrations/001.sql'] }, []
49
+ );
50
+ expect(targets).toContain('db-schema');
51
+ });
52
+
53
+ it('includes references when package.json changed', () => {
54
+ const targets = selectTargets(emptyGroups, ['package.json']);
55
+ expect(targets).toContain('references');
56
+ });
57
+
58
+ it('returns no duplicates', () => {
59
+ const targets = selectTargets(emptyGroups, []);
60
+ expect(targets.length).toBe(new Set(targets).size);
61
+ });
62
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdirSync, rmSync, existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { appendSection, createFile } from '../../src/core/writer.js';
5
+
6
+ const TMP = join(import.meta.dirname, '../../tmp-writer-test');
7
+
8
+ beforeEach(() => mkdirSync(TMP, { recursive: true }));
9
+ afterEach(() => rmSync(TMP, { recursive: true, force: true }));
10
+
11
+ describe('appendSection', () => {
12
+ it('creates file if it does not exist', () => {
13
+ const path = join(TMP, 'AGENTS.md');
14
+ appendSection(path, 'Changes in v1.1.0', 'New auth module added.');
15
+ expect(existsSync(path)).toBe(true);
16
+ const content = readFileSync(path, 'utf-8');
17
+ expect(content).toContain('## Changes in v1.1.0');
18
+ expect(content).toContain('New auth module added.');
19
+ });
20
+
21
+ it('appends to existing file', () => {
22
+ const path = join(TMP, 'ARCHITECTURE.md');
23
+ appendSection(path, 'Changes in v1.0.0', 'Initial architecture.');
24
+ appendSection(path, 'Changes in v1.1.0', 'Added caching layer.');
25
+ const content = readFileSync(path, 'utf-8');
26
+ expect(content).toContain('## Changes in v1.0.0');
27
+ expect(content).toContain('## Changes in v1.1.0');
28
+ });
29
+
30
+ it('creates missing parent directories', () => {
31
+ const path = join(TMP, 'docs/design-docs/index.md');
32
+ appendSection(path, 'v1.0.0', 'entry');
33
+ expect(existsSync(path)).toBe(true);
34
+ });
35
+ });
36
+
37
+ describe('createFile', () => {
38
+ it('creates a file with the given content', () => {
39
+ const path = join(TMP, 'changelog/v1.0.0.md');
40
+ createFile(path, '# Changelog: v1.0.0\n\n## Added\n- First release');
41
+ expect(readFileSync(path, 'utf-8')).toContain('First release');
42
+ });
43
+
44
+ it('overwrites an existing file', () => {
45
+ const path = join(TMP, 'test.md');
46
+ createFile(path, 'first content');
47
+ createFile(path, 'second content');
48
+ expect(readFileSync(path, 'utf-8')).toBe('second content');
49
+ });
50
+
51
+ it('creates missing parent directories', () => {
52
+ const path = join(TMP, 'a/b/c/file.md');
53
+ createFile(path, 'content');
54
+ expect(existsSync(path)).toBe(true);
55
+ });
56
+ });
@@ -0,0 +1,11 @@
1
+ diff --git a/src/ui/Button.tsx b/src/ui/Button.tsx
2
+ new file mode 100644
3
+ index 0000000..aabbcc
4
+ --- /dev/null
5
+ +++ b/src/ui/Button.tsx
6
+ @@ -0,0 +1,8 @@
7
+ +export function Button({ label }: { label: string }) {
8
+ + return <button>{label}</button>;
9
+ +}
10
+ diff --git a/src/ui/styles.css b/src/ui/styles.css
11
+ new file mode 100644
@@ -0,0 +1,12 @@
1
+ diff --git a/migrations/001_add_users.sql b/migrations/001_add_users.sql
2
+ new file mode 100644
3
+ index 0000000..aabbcc
4
+ --- /dev/null
5
+ +++ b/migrations/001_add_users.sql
6
+ @@ -0,0 +1,5 @@
7
+ +CREATE TABLE users (
8
+ + id UUID PRIMARY KEY,
9
+ + email TEXT NOT NULL UNIQUE
10
+ +);
11
+ diff --git a/src/db/schema.ts b/src/db/schema.ts
12
+ new file mode 100644
@@ -0,0 +1,16 @@
1
+ diff --git a/src/auth/login.ts b/src/auth/login.ts
2
+ index abc1234..def5678 100644
3
+ --- a/src/auth/login.ts
4
+ +++ b/src/auth/login.ts
5
+ @@ -1,5 +1,10 @@
6
+ +export function login(email: string, password: string) {
7
+ + return { token: 'jwt' };
8
+ +}
9
+ diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
10
+ index 111aaaa..222bbbb 100644
11
+ --- a/src/utils/helpers.ts
12
+ +++ b/src/utils/helpers.ts
13
+ @@ -1,3 +1,6 @@
14
+ +export function sleep(ms: number) {
15
+ + return new Promise(r => setTimeout(r, ms));
16
+ +}
@@ -0,0 +1,49 @@
1
+ // tests/integration/generate.test.ts
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { mkdirSync, rmSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { generateDocs, writeResults } from '../../src/core/generator.js';
6
+ import { selectTargets } from '../../src/core/relevance.js';
7
+ import { parseChangedFiles, groupFiles } from '../../src/core/diff.js';
8
+ import { readFileSync } from 'fs';
9
+ import type { AIProvider } from '../../src/ai/interface.js';
10
+
11
+ const TMP = join(import.meta.dirname, '../../tmp-integration-test');
12
+ const FIXTURE = readFileSync(
13
+ join(import.meta.dirname, '../fixtures/diff-schema.txt'), 'utf-8'
14
+ );
15
+
16
+ beforeEach(() => mkdirSync(TMP, { recursive: true }));
17
+ afterEach(() => rmSync(TMP, { recursive: true, force: true }));
18
+
19
+ describe('full pipeline: diff → relevance → generate → write', () => {
20
+ it('generates and writes expected documents for schema diff', async () => {
21
+ const changedFiles = parseChangedFiles(FIXTURE);
22
+ const fileGroups = groupFiles(changedFiles);
23
+ const targets = selectTargets(fileGroups, changedFiles);
24
+
25
+ expect(targets).toContain('db-schema');
26
+ expect(targets).toContain('AGENTS.md');
27
+
28
+ const mockAI: AIProvider = {
29
+ generate: vi.fn().mockResolvedValue('Auto-generated documentation content'),
30
+ };
31
+
32
+ const diff = {
33
+ raw: FIXTURE,
34
+ prevTag: 'v1.0.0',
35
+ currentTag: 'v1.1.0',
36
+ changedFiles,
37
+ fileGroups,
38
+ };
39
+
40
+ const results = await generateDocs(mockAI, diff, targets);
41
+ const written = writeResults(results, diff, TMP);
42
+
43
+ expect(written.length).toBeGreaterThan(0);
44
+ expect(existsSync(`${TMP}/AGENTS.md`)).toBe(true);
45
+ expect(existsSync(`${TMP}/docs/generated/db-schema.md`)).toBe(true);
46
+ expect(existsSync(`${TMP}/changelog/v1.1.0.md`)).toBe(true);
47
+ expect(existsSync(`${TMP}/docs/design-docs/v1.1.0.md`)).toBe(true);
48
+ });
49
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { GitHubProvider } from '../../src/providers/github.js';
3
+
4
+ vi.mock('child_process', () => ({
5
+ execSync: vi.fn().mockReturnValue('git@github.com:myorg/myrepo.git\n'),
6
+ }));
7
+
8
+ const mockOctokit = {
9
+ pulls: {
10
+ list: vi.fn().mockResolvedValue({ data: [] }),
11
+ create: vi.fn().mockResolvedValue({
12
+ data: { html_url: 'https://github.com/myorg/myrepo/pull/42', number: 42 },
13
+ }),
14
+ update: vi.fn().mockResolvedValue({
15
+ data: { html_url: 'https://github.com/myorg/myrepo/pull/1' },
16
+ }),
17
+ },
18
+ };
19
+
20
+ vi.mock('@octokit/rest', () => ({
21
+ Octokit: vi.fn().mockImplementation(() => mockOctokit),
22
+ }));
23
+
24
+ describe('GitHubProvider', () => {
25
+ beforeEach(() => vi.clearAllMocks());
26
+
27
+ it('parses owner and repo from SSH remote URL', () => {
28
+ expect(() => new GitHubProvider('token')).not.toThrow();
29
+ });
30
+
31
+ it('creates a PR and returns its URL', async () => {
32
+ const provider = new GitHubProvider('token');
33
+ const url = await provider.createOrUpdatePR({
34
+ branch: 'harness-docs/v1.1.0',
35
+ title: 'docs: update for v1.1.0',
36
+ body: 'Auto-generated docs update',
37
+ baseBranch: 'main',
38
+ });
39
+ expect(url).toBe('https://github.com/myorg/myrepo/pull/42');
40
+ expect(mockOctokit.pulls.create).toHaveBeenCalledWith(
41
+ expect.objectContaining({
42
+ head: 'harness-docs/v1.1.0',
43
+ base: 'main',
44
+ })
45
+ );
46
+ });
47
+
48
+ it('updates existing PR if one already exists for the branch', async () => {
49
+ mockOctokit.pulls.list.mockResolvedValueOnce({
50
+ data: [{ number: 1, html_url: 'https://github.com/myorg/myrepo/pull/1' }],
51
+ });
52
+ const provider = new GitHubProvider('token');
53
+ const url = await provider.createOrUpdatePR({
54
+ branch: 'harness-docs/v1.1.0',
55
+ title: 'docs: update for v1.1.0',
56
+ body: 'Updated',
57
+ baseBranch: 'main',
58
+ });
59
+ expect(mockOctokit.pulls.update).toHaveBeenCalled();
60
+ expect(mockOctokit.pulls.create).not.toHaveBeenCalled();
61
+ expect(url).toBe('https://github.com/myorg/myrepo/pull/1');
62
+ });
63
+
64
+ it('throws on unrecognized remote URL', async () => {
65
+ const { execSync } = await import('child_process');
66
+ vi.mocked(execSync).mockReturnValueOnce('https://not-github.com/something\n' as any);
67
+ expect(() => new GitHubProvider('token')).toThrow('Cannot parse GitHub remote URL');
68
+ });
69
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules", "dist", "tests"]
15
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ },
7
+ });