@openpkg-ts/cli 0.2.3 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @openpkg-ts/cli
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bd70dc7: add diagnostics, filter primitives, new CLI commands (breaking, changelog, diagnostics, filter, semver, validate), registry system, render enhancements
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [bd70dc7]
12
+ - @openpkg-ts/sdk@0.31.0
13
+ - @openpkg-ts/adapters@0.3.0
14
+
3
15
  ## 0.2.3
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -78,6 +78,9 @@ openpkg docs openpkg.json --split -o docs/api/
78
78
 
79
79
  # Pipeline: stdin
80
80
  openpkg snapshot src/index.ts -o - | openpkg docs - -f md
81
+
82
+ # With adapter (generates framework-specific output)
83
+ openpkg docs openpkg.json --adapter fumadocs -o docs/api/
81
84
  ```
82
85
 
83
86
  Options:
@@ -86,6 +89,7 @@ Options:
86
89
  | `-o, --output <path>` | Output file or directory (default: stdout) |
87
90
  | `-f, --format <fmt>` | Format: `md`, `json`, `html` (default: md) |
88
91
  | `--split` | One file per export (requires `-o` as directory) |
92
+ | `-a, --adapter <name>` | Use adapter: `fumadocs`, `raw` (default: raw) |
89
93
 
90
94
  ### diff
91
95
 
@@ -104,6 +108,135 @@ Output includes:
104
108
  - `docsOnly` - documentation-only changes
105
109
  - `summary.semverBump` - recommended version bump
106
110
 
111
+ ### breaking
112
+
113
+ Check for breaking changes. Exit 1 if any found.
114
+
115
+ ```bash
116
+ openpkg breaking old.json new.json
117
+ ```
118
+
119
+ Output:
120
+ ```json
121
+ {
122
+ "breaking": [
123
+ { "id": "createClient", "name": "createClient", "kind": "function", "severity": "high", "reason": "signature changed" }
124
+ ],
125
+ "count": 1
126
+ }
127
+ ```
128
+
129
+ ### semver
130
+
131
+ Recommend version bump based on changes.
132
+
133
+ ```bash
134
+ openpkg semver old.json new.json
135
+ ```
136
+
137
+ Output:
138
+ ```json
139
+ {
140
+ "bump": "major",
141
+ "reason": "1 breaking change detected"
142
+ }
143
+ ```
144
+
145
+ ### validate
146
+
147
+ Validate spec against schema.
148
+
149
+ ```bash
150
+ openpkg validate openpkg.json
151
+ openpkg validate openpkg.json --version 1.0
152
+ ```
153
+
154
+ Output:
155
+ ```json
156
+ {
157
+ "valid": true,
158
+ "errors": []
159
+ }
160
+ ```
161
+
162
+ ### changelog
163
+
164
+ Generate changelog from diff.
165
+
166
+ ```bash
167
+ openpkg changelog old.json new.json
168
+ openpkg changelog old.json new.json --format json
169
+ ```
170
+
171
+ Markdown output:
172
+ ```markdown
173
+ ## Breaking Changes
174
+ - **Removed** `oldFunction` (function)
175
+
176
+ ## Added
177
+ - `newFunction`
178
+ ```
179
+
180
+ ### diagnostics
181
+
182
+ Analyze spec for quality issues.
183
+
184
+ ```bash
185
+ openpkg diagnostics openpkg.json
186
+ ```
187
+
188
+ Output:
189
+ ```json
190
+ {
191
+ "summary": {
192
+ "total": 5,
193
+ "missingDescriptions": 3,
194
+ "deprecatedNoReason": 1,
195
+ "missingParamDocs": 1
196
+ },
197
+ "diagnostics": { ... }
198
+ }
199
+ ```
200
+
201
+ ### filter
202
+
203
+ Filter spec by various criteria.
204
+
205
+ ```bash
206
+ openpkg filter openpkg.json --kind function,class
207
+ openpkg filter openpkg.json --has-description -o documented.json
208
+ openpkg filter openpkg.json --search "user" --summary
209
+ openpkg filter openpkg.json --deprecated --quiet | jq '.exports[].name'
210
+ ```
211
+
212
+ Options:
213
+ | Flag | Description |
214
+ |------|-------------|
215
+ | `--kind <kinds>` | Filter by kinds (comma-separated) |
216
+ | `--name <names>` | Filter by exact names (comma-separated) |
217
+ | `--id <ids>` | Filter by export IDs (comma-separated) |
218
+ | `--tag <tags>` | Filter by tags (comma-separated) |
219
+ | `--deprecated` | Only deprecated exports |
220
+ | `--no-deprecated` | Exclude deprecated exports |
221
+ | `--has-description` | Only exports with descriptions |
222
+ | `--missing-description` | Only exports without descriptions |
223
+ | `--search <term>` | Search name/description (case-insensitive) |
224
+ | `--module <path>` | Filter by source file path (contains) |
225
+ | `-o, --output <file>` | Output file (default: stdout) |
226
+ | `--summary` | Only output matched/total counts |
227
+ | `--quiet` | Output raw spec only (no wrapper) |
228
+
229
+ All criteria use AND logic when combined.
230
+
231
+ Output (default):
232
+ ```json
233
+ {
234
+ "spec": { ... },
235
+ "matched": 12,
236
+ "total": 45
237
+ }
238
+ ```
239
+
107
240
  ## Pipelines
108
241
 
109
242
  Commands are composable via stdin/stdout:
package/bin/openpkg.ts CHANGED
@@ -2,10 +2,16 @@
2
2
  import * as path from 'node:path';
3
3
  import { getExport, listExports } from '@openpkg-ts/sdk';
4
4
  import { Command } from 'commander';
5
+ import pkg from '../package.json';
6
+ import { createBreakingCommand } from '../src/commands/breaking';
7
+ import { createChangelogCommand } from '../src/commands/changelog';
8
+ import { createDiagnosticsCommand } from '../src/commands/diagnostics';
5
9
  import { createDiffCommand } from '../src/commands/diff';
10
+ import { createFilterCommand } from '../src/commands/filter';
6
11
  import { createDocsCommand } from '../src/commands/docs';
12
+ import { createSemverCommand } from '../src/commands/semver';
7
13
  import { createSnapshotCommand } from '../src/commands/snapshot';
8
- import pkg from '../package.json';
14
+ import { createValidateCommand } from '../src/commands/validate';
9
15
 
10
16
  const program = new Command();
11
17
 
@@ -57,5 +63,11 @@ program
57
63
  program.addCommand(createSnapshotCommand());
58
64
  program.addCommand(createDiffCommand());
59
65
  program.addCommand(createDocsCommand());
66
+ program.addCommand(createBreakingCommand());
67
+ program.addCommand(createChangelogCommand());
68
+ program.addCommand(createSemverCommand());
69
+ program.addCommand(createValidateCommand());
70
+ program.addCommand(createDiagnosticsCommand());
71
+ program.addCommand(createFilterCommand());
60
72
 
61
73
  program.parse();
package/dist/index.d.ts CHANGED
@@ -1,2 +1,12 @@
1
1
  import { getExport, listExports } from "@openpkg-ts/sdk";
2
- export { listExports, getExport };
2
+ import { OpenPkg } from "@openpkg-ts/spec";
3
+ type FilterResult = {
4
+ spec: OpenPkg;
5
+ matched: number;
6
+ total: number;
7
+ };
8
+ type FilterSummaryResult = {
9
+ matched: number;
10
+ total: number;
11
+ };
12
+ export { listExports, getExport, FilterSummaryResult, FilterResult };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpkg-ts/cli",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for OpenPkg TypeScript API extraction and documentation generation",
5
5
  "homepage": "https://github.com/ryanwaits/openpkg-ts#readme",
6
6
  "repository": {
@@ -18,7 +18,8 @@
18
18
  "test": "bun test"
19
19
  },
20
20
  "dependencies": {
21
- "@openpkg-ts/sdk": "^0.30.2",
21
+ "@openpkg-ts/adapters": "^0.3.0",
22
+ "@openpkg-ts/sdk": "^0.31.0",
22
23
  "commander": "^14.0.0"
23
24
  },
24
25
  "devDependencies": {
@@ -0,0 +1,46 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { type CategorizedBreaking, categorizeBreakingChanges, diffSpec, type OpenPkg } from '@openpkg-ts/spec';
4
+ import { Command } from 'commander';
5
+
6
+ export type BreakingResult = {
7
+ breaking: CategorizedBreaking[];
8
+ count: number;
9
+ };
10
+
11
+ function loadSpec(filePath: string): OpenPkg {
12
+ const resolved = path.resolve(filePath);
13
+ const content = fs.readFileSync(resolved, 'utf-8');
14
+ return JSON.parse(content) as OpenPkg;
15
+ }
16
+
17
+ export function createBreakingCommand(): Command {
18
+ return new Command('breaking')
19
+ .description('Check for breaking changes between two specs')
20
+ .argument('<old>', 'Path to old spec file (JSON)')
21
+ .argument('<new>', 'Path to new spec file (JSON)')
22
+ .action(async (oldPath: string, newPath: string) => {
23
+ try {
24
+ const oldSpec = loadSpec(oldPath);
25
+ const newSpec = loadSpec(newPath);
26
+
27
+ const diff = diffSpec(oldSpec, newSpec);
28
+ const categorized = categorizeBreakingChanges(diff.breaking, oldSpec, newSpec);
29
+
30
+ const result: BreakingResult = {
31
+ breaking: categorized,
32
+ count: categorized.length,
33
+ };
34
+
35
+ console.log(JSON.stringify(result, null, 2));
36
+
37
+ if (categorized.length > 0) {
38
+ process.exit(1);
39
+ }
40
+ } catch (err) {
41
+ const error = err instanceof Error ? err : new Error(String(err));
42
+ console.error(JSON.stringify({ error: error.message }, null, 2));
43
+ process.exit(1);
44
+ }
45
+ });
46
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { OpenPkg } from '@openpkg-ts/spec';
6
+ import { $ } from 'bun';
7
+
8
+ const oldSpec: OpenPkg = {
9
+ meta: { name: 'test-pkg', version: '1.0.0' },
10
+ exports: [
11
+ { id: 'fn-greet', name: 'greet', kind: 'function', signatures: [] },
12
+ { id: 'fn-removed', name: 'removed', kind: 'function', signatures: [] },
13
+ ],
14
+ types: [],
15
+ };
16
+
17
+ const newSpec: OpenPkg = {
18
+ meta: { name: 'test-pkg', version: '2.0.0' },
19
+ exports: [
20
+ { id: 'fn-greet', name: 'greet', kind: 'function', signatures: [] },
21
+ { id: 'fn-added', name: 'added', kind: 'function', signatures: [] },
22
+ ],
23
+ types: [],
24
+ };
25
+
26
+ describe('changelog command', () => {
27
+ test('outputs JSON format', async () => {
28
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-test-'));
29
+ const oldPath = path.join(tmpDir, 'old.json');
30
+ const newPath = path.join(tmpDir, 'new.json');
31
+
32
+ fs.writeFileSync(oldPath, JSON.stringify(oldSpec));
33
+ fs.writeFileSync(newPath, JSON.stringify(newSpec));
34
+
35
+ const result = await $`bun packages/cli/bin/openpkg.ts changelog ${oldPath} ${newPath} --format json`.text();
36
+ const parsed = JSON.parse(result);
37
+
38
+ expect(parsed).toHaveProperty('breaking');
39
+ expect(parsed).toHaveProperty('added');
40
+ expect(parsed).toHaveProperty('removed');
41
+ expect(parsed).toHaveProperty('summary');
42
+
43
+ fs.rmSync(tmpDir, { recursive: true });
44
+ });
45
+
46
+ test('outputs markdown format', async () => {
47
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-test-'));
48
+ const oldPath = path.join(tmpDir, 'old.json');
49
+ const newPath = path.join(tmpDir, 'new.json');
50
+
51
+ fs.writeFileSync(oldPath, JSON.stringify(oldSpec));
52
+ fs.writeFileSync(newPath, JSON.stringify(newSpec));
53
+
54
+ const result = await $`bun packages/cli/bin/openpkg.ts changelog ${oldPath} ${newPath}`.text();
55
+
56
+ expect(result).toContain('## Breaking Changes');
57
+ expect(result).toContain('removed');
58
+ expect(result).toContain('## Added');
59
+ expect(result).toContain('added');
60
+
61
+ fs.rmSync(tmpDir, { recursive: true });
62
+ });
63
+
64
+ test('empty diff shows no changes', async () => {
65
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-test-'));
66
+ const oldPath = path.join(tmpDir, 'old.json');
67
+ const newPath = path.join(tmpDir, 'new.json');
68
+
69
+ fs.writeFileSync(oldPath, JSON.stringify(oldSpec));
70
+ fs.writeFileSync(newPath, JSON.stringify(oldSpec));
71
+
72
+ const result = await $`bun packages/cli/bin/openpkg.ts changelog ${oldPath} ${newPath}`.text();
73
+
74
+ expect(result.trim()).toBe('No changes detected.');
75
+
76
+ fs.rmSync(tmpDir, { recursive: true });
77
+ });
78
+ });
@@ -0,0 +1,79 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { OpenPkg } from '@openpkg-ts/spec';
4
+ import { Command } from 'commander';
5
+ import { type DiffResult, enrichDiff } from './diff.js';
6
+
7
+ function loadSpec(filePath: string): OpenPkg {
8
+ const resolved = path.resolve(filePath);
9
+ const content = fs.readFileSync(resolved, 'utf-8');
10
+ return JSON.parse(content) as OpenPkg;
11
+ }
12
+
13
+ /**
14
+ * Format diff result as markdown changelog
15
+ */
16
+ function formatMarkdown(diff: DiffResult): string {
17
+ const lines: string[] = [];
18
+
19
+ // Breaking Changes
20
+ if (diff.removed.length > 0 || diff.changed.length > 0) {
21
+ lines.push('## Breaking Changes');
22
+ lines.push('');
23
+ for (const r of diff.removed) {
24
+ lines.push(`- **Removed** \`${r.name}\` (${r.kind})`);
25
+ }
26
+ for (const c of diff.changed) {
27
+ lines.push(`- **${c.name}** (${c.kind}): ${c.description}`);
28
+ }
29
+ lines.push('');
30
+ }
31
+
32
+ // Added
33
+ if (diff.added.length > 0) {
34
+ lines.push('## Added');
35
+ lines.push('');
36
+ for (const id of diff.added) {
37
+ lines.push(`- \`${id}\``);
38
+ }
39
+ lines.push('');
40
+ }
41
+
42
+ // Changed (docs only)
43
+ if (diff.docsOnly.length > 0) {
44
+ lines.push('## Changed');
45
+ lines.push('');
46
+ for (const id of diff.docsOnly) {
47
+ lines.push(`- \`${id}\` (docs)`);
48
+ }
49
+ lines.push('');
50
+ }
51
+
52
+ return lines.join('\n').trim() || 'No changes detected.';
53
+ }
54
+
55
+ export function createChangelogCommand(): Command {
56
+ return new Command('changelog')
57
+ .description('Generate changelog from diff between two specs')
58
+ .argument('<old>', 'Path to old spec file (JSON)')
59
+ .argument('<new>', 'Path to new spec file (JSON)')
60
+ .option('--format <format>', 'Output format: md or json', 'md')
61
+ .action(async (oldPath: string, newPath: string, options: { format?: string }) => {
62
+ try {
63
+ const oldSpec = loadSpec(oldPath);
64
+ const newSpec = loadSpec(newPath);
65
+
66
+ const diff = enrichDiff(oldSpec, newSpec);
67
+
68
+ if (options.format === 'json') {
69
+ console.log(JSON.stringify(diff, null, 2));
70
+ } else {
71
+ console.log(formatMarkdown(diff));
72
+ }
73
+ } catch (err) {
74
+ const error = err instanceof Error ? err : new Error(String(err));
75
+ console.error(JSON.stringify({ error: error.message }, null, 2));
76
+ process.exit(1);
77
+ }
78
+ });
79
+ }
@@ -0,0 +1,55 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { $ } from 'bun';
6
+
7
+ describe('openpkg diagnostics', () => {
8
+ let tmpDir: string;
9
+
10
+ beforeAll(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'diagnostics-test-'));
12
+ });
13
+
14
+ afterAll(() => {
15
+ fs.rmSync(tmpDir, { recursive: true });
16
+ });
17
+
18
+ it('outputs JSON with diagnostics', async () => {
19
+ const specPath = path.join(tmpDir, 'spec.json');
20
+ const spec = {
21
+ openpkg: '0.4.0',
22
+ meta: { name: 'test-pkg' },
23
+ exports: [
24
+ { id: 'a', name: 'noDesc', kind: 'function' },
25
+ { id: 'b', name: 'withDesc', kind: 'function', description: 'Has desc' },
26
+ {
27
+ id: 'c',
28
+ name: 'depNoReason',
29
+ kind: 'function',
30
+ description: 'Something',
31
+ deprecated: true,
32
+ },
33
+ ],
34
+ };
35
+ fs.writeFileSync(specPath, JSON.stringify(spec));
36
+
37
+ const output = await $`bun packages/cli/bin/openpkg.ts diagnostics ${specPath}`.text();
38
+ const result = JSON.parse(output);
39
+
40
+ expect(result.summary.total).toBe(2);
41
+ expect(result.summary.missingDescriptions).toBe(1);
42
+ expect(result.summary.deprecatedNoReason).toBe(1);
43
+ expect(result.diagnostics.missingDescriptions[0].exportName).toBe('noDesc');
44
+ });
45
+
46
+ it('handles malformed spec gracefully', async () => {
47
+ const specPath = path.join(tmpDir, 'bad.json');
48
+ fs.writeFileSync(specPath, '{ invalid json');
49
+
50
+ const output = await $`bun packages/cli/bin/openpkg.ts diagnostics ${specPath}`.text();
51
+ const result = JSON.parse(output);
52
+
53
+ expect(result.error).toBeDefined();
54
+ });
55
+ });
@@ -0,0 +1,55 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { analyzeSpec, type SpecDiagnostics } from '@openpkg-ts/sdk';
4
+ import type { OpenPkg } from '@openpkg-ts/spec';
5
+ import { Command } from 'commander';
6
+
7
+ export interface DiagnosticsResult {
8
+ summary: {
9
+ total: number;
10
+ missingDescriptions: number;
11
+ deprecatedNoReason: number;
12
+ missingParamDocs: number;
13
+ };
14
+ diagnostics: SpecDiagnostics;
15
+ }
16
+
17
+ function loadJSON(filePath: string): unknown {
18
+ const resolved = path.resolve(filePath);
19
+ const content = fs.readFileSync(resolved, 'utf-8');
20
+ return JSON.parse(content);
21
+ }
22
+
23
+ export function createDiagnosticsCommand(): Command {
24
+ return new Command('diagnostics')
25
+ .description('Analyze spec for quality issues (missing docs, deprecated without reason)')
26
+ .argument('<spec>', 'Path to spec file (JSON)')
27
+ .action(async (specPath: string) => {
28
+ try {
29
+ const spec = loadJSON(specPath) as OpenPkg;
30
+
31
+ const diagnostics = analyzeSpec(spec);
32
+
33
+ const result: DiagnosticsResult = {
34
+ summary: {
35
+ total:
36
+ diagnostics.missingDescriptions.length +
37
+ diagnostics.deprecatedNoReason.length +
38
+ diagnostics.missingParamDocs.length,
39
+ missingDescriptions: diagnostics.missingDescriptions.length,
40
+ deprecatedNoReason: diagnostics.deprecatedNoReason.length,
41
+ missingParamDocs: diagnostics.missingParamDocs.length,
42
+ },
43
+ diagnostics,
44
+ };
45
+
46
+ console.log(JSON.stringify(result, null, 2));
47
+ // Always exit 0 - informational only
48
+ process.exit(0);
49
+ } catch (err) {
50
+ const error = err instanceof Error ? err : new Error(String(err));
51
+ console.log(JSON.stringify({ error: error.message }, null, 2));
52
+ process.exit(0);
53
+ }
54
+ });
55
+ }
@@ -75,7 +75,6 @@ export function enrichDiff(oldSpec: OpenPkg, newSpec: OpenPkg): DiffResult {
75
75
  const semver = recommendSemverBump(rawDiff);
76
76
 
77
77
  const oldExports = toExportMap(oldSpec);
78
- const _newExports = toExportMap(newSpec);
79
78
 
80
79
  // Separate removed from changed
81
80
  const removed: RemovedExport[] = [];
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { OpenPkg } from '@openpkg-ts/spec';
6
+ import { $ } from 'bun';
7
+
8
+ const testSpec: OpenPkg = {
9
+ meta: { name: 'test-pkg', version: '1.0.0' },
10
+ exports: [
11
+ { id: 'fn-hello', name: 'hello', kind: 'function', signatures: [] },
12
+ { id: 'fn-world', name: 'world', kind: 'function', signatures: [] },
13
+ ],
14
+ types: [],
15
+ };
16
+
17
+ describe('docs command --adapter', () => {
18
+ test('--adapter fumadocs generates docs', async () => {
19
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-adapter-test-'));
20
+ const specPath = path.join(tmpDir, 'spec.json');
21
+ const outDir = path.join(tmpDir, 'out');
22
+
23
+ fs.writeFileSync(specPath, JSON.stringify(testSpec));
24
+
25
+ await $`bun packages/cli/bin/openpkg.ts docs ${specPath} --adapter fumadocs --output ${outDir}`.text();
26
+
27
+ expect(fs.existsSync(outDir)).toBe(true);
28
+ expect(fs.existsSync(path.join(outDir, 'hello.md'))).toBe(true);
29
+ expect(fs.existsSync(path.join(outDir, 'world.md'))).toBe(true);
30
+
31
+ fs.rmSync(tmpDir, { recursive: true });
32
+ });
33
+
34
+ test('--adapter raw falls through to default', async () => {
35
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-adapter-test-'));
36
+ const specPath = path.join(tmpDir, 'spec.json');
37
+
38
+ fs.writeFileSync(specPath, JSON.stringify(testSpec));
39
+
40
+ const result = await $`bun packages/cli/bin/openpkg.ts docs ${specPath} --adapter raw`.text();
41
+
42
+ expect(result).toContain('hello');
43
+
44
+ fs.rmSync(tmpDir, { recursive: true });
45
+ });
46
+
47
+ test('--adapter unknown exits with error', async () => {
48
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-adapter-test-'));
49
+ const specPath = path.join(tmpDir, 'spec.json');
50
+ const outDir = path.join(tmpDir, 'out');
51
+
52
+ fs.writeFileSync(specPath, JSON.stringify(testSpec));
53
+
54
+ const result = await $`bun packages/cli/bin/openpkg.ts docs ${specPath} --adapter unknown --output ${outDir}`.nothrow();
55
+
56
+ expect(result.exitCode).toBe(1);
57
+ // Error is written to stderr as JSON
58
+ const stderr = result.stderr.toString();
59
+ expect(stderr).toContain('Failed to load adapter');
60
+
61
+ fs.rmSync(tmpDir, { recursive: true });
62
+ });
63
+ });
@@ -10,6 +10,8 @@ interface DocsCommandOptions {
10
10
  output?: string;
11
11
  format?: OutputFormat;
12
12
  split?: boolean;
13
+ export?: string;
14
+ adapter?: string;
13
15
  }
14
16
 
15
17
  async function readStdin(): Promise<string> {
@@ -37,11 +39,11 @@ function renderExport(docs: DocsInstance, exportId: string, format: OutputFormat
37
39
 
38
40
  switch (format) {
39
41
  case 'json':
40
- return JSON.stringify(docs.toJSON({ exportId }), null, 2);
42
+ return JSON.stringify(docs.toJSON({ export: exportId }), null, 2);
41
43
  case 'html':
42
- return docs.toHTML({ exportId });
44
+ return docs.toHTML({ export: exportId });
43
45
  default:
44
- return docs.toMarkdown({ exportId, frontmatter: true, codeSignatures: true });
46
+ return docs.toMarkdown({ export: exportId, frontmatter: true, codeSignatures: true });
45
47
  }
46
48
  }
47
49
 
@@ -63,10 +65,55 @@ export function createDocsCommand(): Command {
63
65
  .option('-o, --output <path>', 'Output file or directory (default: stdout)')
64
66
  .option('-f, --format <format>', 'Output format: md, json, html (default: md)', 'md')
65
67
  .option('--split', 'Output one file per export (requires --output as directory)')
68
+ .option('-e, --export <name>', 'Generate docs for a single export by name')
69
+ .option('-a, --adapter <name>', 'Use adapter for generation (default: raw)')
66
70
  .action(async (specPath: string, options: DocsCommandOptions) => {
67
71
  const format = (options.format || 'md') as OutputFormat;
68
72
 
69
73
  try {
74
+ // Handle adapter mode
75
+ if (options.adapter && options.adapter !== 'raw') {
76
+ // Dynamic import adapter to trigger self-registration
77
+ let getAdapter: typeof import('@openpkg-ts/adapters').getAdapter;
78
+ try {
79
+ const adapterModule = await import(`@openpkg-ts/adapters/${options.adapter}`);
80
+ const registryModule = await import('@openpkg-ts/adapters');
81
+ getAdapter = registryModule.getAdapter;
82
+ } catch {
83
+ console.error(JSON.stringify({ error: `Failed to load adapter: ${options.adapter}` }));
84
+ process.exit(1);
85
+ }
86
+
87
+ const adapter = getAdapter(options.adapter);
88
+ if (!adapter) {
89
+ console.error(JSON.stringify({ error: `Unknown adapter: ${options.adapter}` }));
90
+ process.exit(1);
91
+ }
92
+
93
+ if (!options.output) {
94
+ console.error(JSON.stringify({ error: '--adapter requires --output <directory>' }));
95
+ process.exit(1);
96
+ }
97
+
98
+ // Load spec
99
+ let spec: OpenPkg;
100
+ if (specPath === '-') {
101
+ const input = await readStdin();
102
+ spec = JSON.parse(input);
103
+ } else {
104
+ const specFile = path.resolve(specPath);
105
+ if (!fs.existsSync(specFile)) {
106
+ console.error(JSON.stringify({ error: `Spec file not found: ${specFile}` }));
107
+ process.exit(1);
108
+ }
109
+ spec = JSON.parse(fs.readFileSync(specFile, 'utf-8'));
110
+ }
111
+
112
+ await adapter.generate(spec, path.resolve(options.output));
113
+ console.error(`Generated docs with ${options.adapter} adapter to ${options.output}`);
114
+ return;
115
+ }
116
+
70
117
  let docs: DocsInstance;
71
118
 
72
119
  // Handle stdin
@@ -83,6 +130,25 @@ export function createDocsCommand(): Command {
83
130
  docs = createDocs(specFile);
84
131
  }
85
132
 
133
+ // Single export mode
134
+ if (options.export) {
135
+ const exports = docs.getAllExports();
136
+ const exp = exports.find(e => e.name === options.export);
137
+ if (!exp) {
138
+ console.error(JSON.stringify({ error: `Export not found: ${options.export}` }));
139
+ process.exit(1);
140
+ }
141
+ const output = renderExport(docs, exp.id, format);
142
+ if (options.output && options.output !== '-') {
143
+ const outputPath = path.resolve(options.output);
144
+ fs.writeFileSync(outputPath, output);
145
+ console.error(`Wrote ${outputPath}`);
146
+ } else {
147
+ console.log(output);
148
+ }
149
+ return;
150
+ }
151
+
86
152
  // Split mode: one file per export
87
153
  if (options.split) {
88
154
  if (!options.output) {
@@ -0,0 +1,210 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { $ } from 'bun';
6
+
7
+ describe('openpkg filter', () => {
8
+ let tmpDir: string;
9
+ let specPath: string;
10
+
11
+ const spec = {
12
+ openpkg: '0.4.0',
13
+ meta: { name: 'test-pkg' },
14
+ exports: [
15
+ { id: 'a', name: 'myFunction', kind: 'function', description: 'A function' },
16
+ { id: 'b', name: 'MyClass', kind: 'class', description: 'A class' },
17
+ { id: 'c', name: 'myVar', kind: 'variable' },
18
+ { id: 'd', name: 'deprecatedFn', kind: 'function', deprecated: true, description: 'Old' },
19
+ {
20
+ id: 'e',
21
+ name: 'taggedFn',
22
+ kind: 'function',
23
+ tags: [{ name: 'beta' }],
24
+ description: 'Beta feature',
25
+ },
26
+ {
27
+ id: 'f',
28
+ name: 'anotherClass',
29
+ kind: 'class',
30
+ source: { file: 'src/utils/helpers.ts' },
31
+ },
32
+ ],
33
+ };
34
+
35
+ beforeAll(() => {
36
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filter-test-'));
37
+ specPath = path.join(tmpDir, 'spec.json');
38
+ fs.writeFileSync(specPath, JSON.stringify(spec));
39
+ });
40
+
41
+ afterAll(() => {
42
+ fs.rmSync(tmpDir, { recursive: true });
43
+ });
44
+
45
+ it('filters by kind', async () => {
46
+ const output = await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --kind function`.text();
47
+ const result = JSON.parse(output);
48
+
49
+ expect(result.matched).toBe(3);
50
+ expect(result.total).toBe(6);
51
+ expect(result.spec.exports.every((e: { kind: string }) => e.kind === 'function')).toBe(true);
52
+ });
53
+
54
+ it('filters by multiple kinds', async () => {
55
+ const output =
56
+ await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --kind function,class`.text();
57
+ const result = JSON.parse(output);
58
+
59
+ expect(result.matched).toBe(5);
60
+ });
61
+
62
+ it('filters by name', async () => {
63
+ const output =
64
+ await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --name myFunction,MyClass`.text();
65
+ const result = JSON.parse(output);
66
+
67
+ expect(result.matched).toBe(2);
68
+ });
69
+
70
+ it('filters by deprecated flag', async () => {
71
+ const output = await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --deprecated`.text();
72
+ const result = JSON.parse(output);
73
+
74
+ expect(result.matched).toBe(1);
75
+ expect(result.spec.exports[0].name).toBe('deprecatedFn');
76
+ });
77
+
78
+ it('filters by non-deprecated', async () => {
79
+ const output =
80
+ await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --no-deprecated`.text();
81
+ const result = JSON.parse(output);
82
+
83
+ expect(result.matched).toBe(5);
84
+ });
85
+
86
+ it('filters by has-description', async () => {
87
+ const output =
88
+ await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --has-description`.text();
89
+ const result = JSON.parse(output);
90
+
91
+ expect(result.matched).toBe(4);
92
+ });
93
+
94
+ it('filters by missing-description', async () => {
95
+ const output =
96
+ await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --missing-description`.text();
97
+ const result = JSON.parse(output);
98
+
99
+ expect(result.matched).toBe(2);
100
+ });
101
+
102
+ it('filters by search term', async () => {
103
+ const output = await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --search beta`.text();
104
+ const result = JSON.parse(output);
105
+
106
+ expect(result.matched).toBe(1);
107
+ expect(result.spec.exports[0].name).toBe('taggedFn');
108
+ });
109
+
110
+ it('filters by tag', async () => {
111
+ const output = await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --tag beta`.text();
112
+ const result = JSON.parse(output);
113
+
114
+ expect(result.matched).toBe(1);
115
+ expect(result.spec.exports[0].name).toBe('taggedFn');
116
+ });
117
+
118
+ it('filters by module path', async () => {
119
+ const output =
120
+ await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --module src/utils`.text();
121
+ const result = JSON.parse(output);
122
+
123
+ expect(result.matched).toBe(1);
124
+ expect(result.spec.exports[0].name).toBe('anotherClass');
125
+ });
126
+
127
+ it('combines filters with AND logic', async () => {
128
+ const output =
129
+ await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --kind function --has-description`.text();
130
+ const result = JSON.parse(output);
131
+
132
+ expect(result.matched).toBe(3);
133
+ });
134
+
135
+ it('outputs summary only with --summary', async () => {
136
+ const output =
137
+ await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --kind function --summary`.text();
138
+ const result = JSON.parse(output);
139
+
140
+ expect(result.matched).toBe(3);
141
+ expect(result.total).toBe(6);
142
+ expect(result.spec).toBeUndefined();
143
+ });
144
+
145
+ it('outputs raw spec with --quiet', async () => {
146
+ const output =
147
+ await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --kind function --quiet`.text();
148
+ const result = JSON.parse(output);
149
+
150
+ expect(result.openpkg).toBe('0.4.0');
151
+ expect(result.exports.length).toBe(3);
152
+ expect(result.matched).toBeUndefined();
153
+ });
154
+
155
+ it('writes to file with --output', async () => {
156
+ const outPath = path.join(tmpDir, 'filtered.json');
157
+ await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --kind class -o ${outPath}`.text();
158
+
159
+ const content = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
160
+ expect(content.matched).toBe(2);
161
+ });
162
+
163
+ it('errors on invalid kind', async () => {
164
+ const proc = await $`bun packages/cli/bin/openpkg.ts filter ${specPath} --kind invalid`
165
+ .nothrow()
166
+ .quiet();
167
+ const result = JSON.parse(proc.stderr.toString());
168
+
169
+ expect(result.error).toContain('Invalid kind(s): invalid');
170
+ expect(result.error).toContain('Valid kinds:');
171
+ });
172
+
173
+ it('returns all exports with no criteria', async () => {
174
+ const output = await $`bun packages/cli/bin/openpkg.ts filter ${specPath}`.text();
175
+ const result = JSON.parse(output);
176
+
177
+ expect(result.matched).toBe(6);
178
+ expect(result.total).toBe(6);
179
+ });
180
+
181
+ it('handles empty spec', async () => {
182
+ const emptyPath = path.join(tmpDir, 'empty.json');
183
+ fs.writeFileSync(emptyPath, JSON.stringify({ openpkg: '0.4.0', meta: {}, exports: [] }));
184
+
185
+ const output = await $`bun packages/cli/bin/openpkg.ts filter ${emptyPath} --kind function`.text();
186
+ const result = JSON.parse(output);
187
+
188
+ expect(result.matched).toBe(0);
189
+ expect(result.total).toBe(0);
190
+ });
191
+
192
+ it('errors on malformed JSON', async () => {
193
+ const badPath = path.join(tmpDir, 'bad.json');
194
+ fs.writeFileSync(badPath, '{ invalid json }');
195
+
196
+ const proc = await $`bun packages/cli/bin/openpkg.ts filter ${badPath}`.nothrow().quiet();
197
+ const result = JSON.parse(proc.stderr.toString());
198
+
199
+ expect(result.error).toBeDefined();
200
+ });
201
+
202
+ it('errors on missing file', async () => {
203
+ const proc = await $`bun packages/cli/bin/openpkg.ts filter /nonexistent/path.json`
204
+ .nothrow()
205
+ .quiet();
206
+ const result = JSON.parse(proc.stderr.toString());
207
+
208
+ expect(result.error).toBeDefined();
209
+ });
210
+ });
@@ -0,0 +1,131 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { filterSpec, type FilterCriteria } from '@openpkg-ts/sdk';
4
+ import type { OpenPkg, SpecExportKind } from '@openpkg-ts/spec';
5
+ import { Command } from 'commander';
6
+
7
+ const VALID_KINDS: SpecExportKind[] = [
8
+ 'function',
9
+ 'class',
10
+ 'variable',
11
+ 'interface',
12
+ 'type',
13
+ 'enum',
14
+ 'module',
15
+ 'namespace',
16
+ 'reference',
17
+ 'external',
18
+ ];
19
+
20
+ export type FilterResult = {
21
+ spec: OpenPkg;
22
+ matched: number;
23
+ total: number;
24
+ };
25
+
26
+ export type FilterSummaryResult = {
27
+ matched: number;
28
+ total: number;
29
+ };
30
+
31
+ function loadSpec(filePath: string): OpenPkg {
32
+ const resolved = path.resolve(filePath);
33
+ const content = fs.readFileSync(resolved, 'utf-8');
34
+ return JSON.parse(content) as OpenPkg;
35
+ }
36
+
37
+ function parseList(val?: string): string[] | undefined {
38
+ if (!val) return undefined;
39
+ return val
40
+ .split(',')
41
+ .map((s) => s.trim())
42
+ .filter(Boolean);
43
+ }
44
+
45
+ function validateKinds(kinds: string[]): SpecExportKind[] {
46
+ const invalid = kinds.filter((k) => !VALID_KINDS.includes(k as SpecExportKind));
47
+ if (invalid.length > 0) {
48
+ throw new Error(`Invalid kind(s): ${invalid.join(', ')}. Valid kinds: ${VALID_KINDS.join(', ')}`);
49
+ }
50
+ return kinds as SpecExportKind[];
51
+ }
52
+
53
+ export function createFilterCommand(): Command {
54
+ return new Command('filter')
55
+ .description('Filter an OpenPkg spec by various criteria')
56
+ .argument('<spec>', 'Path to spec file (JSON)')
57
+ .option('--kind <kinds>', 'Filter by kinds (comma-separated)')
58
+ .option('--name <names>', 'Filter by exact names (comma-separated)')
59
+ .option('--id <ids>', 'Filter by IDs (comma-separated)')
60
+ .option('--tag <tags>', 'Filter by tags (comma-separated)')
61
+ .option('--deprecated', 'Only deprecated exports')
62
+ .option('--no-deprecated', 'Exclude deprecated exports')
63
+ .option('--has-description', 'Only exports with descriptions')
64
+ .option('--missing-description', 'Only exports without descriptions')
65
+ .option('--search <term>', 'Search name/description (case-insensitive)')
66
+ .option('--module <path>', 'Filter by source file path (contains)')
67
+ .option('-o, --output <file>', 'Output file (default: stdout)')
68
+ .option('--summary', 'Only output matched/total counts')
69
+ .option('--quiet', 'Output raw spec only (no wrapper)')
70
+ .action(
71
+ async (
72
+ specPath: string,
73
+ options: {
74
+ kind?: string;
75
+ name?: string;
76
+ id?: string;
77
+ tag?: string;
78
+ deprecated?: boolean;
79
+ hasDescription?: boolean;
80
+ missingDescription?: boolean;
81
+ search?: string;
82
+ module?: string;
83
+ output?: string;
84
+ summary?: boolean;
85
+ quiet?: boolean;
86
+ }
87
+ ) => {
88
+ try {
89
+ const spec = loadSpec(specPath);
90
+
91
+ const criteria: FilterCriteria = {};
92
+
93
+ if (options.kind) {
94
+ const kinds = parseList(options.kind);
95
+ if (kinds) criteria.kinds = validateKinds(kinds);
96
+ }
97
+ if (options.name) criteria.names = parseList(options.name);
98
+ if (options.id) criteria.ids = parseList(options.id);
99
+ if (options.tag) criteria.tags = parseList(options.tag);
100
+ if (options.deprecated !== undefined) criteria.deprecated = options.deprecated;
101
+ if (options.hasDescription) criteria.hasDescription = true;
102
+ if (options.missingDescription) criteria.hasDescription = false;
103
+ if (options.search) criteria.search = options.search;
104
+ if (options.module) criteria.module = options.module;
105
+
106
+ const result = filterSpec(spec, criteria);
107
+
108
+ let output: OpenPkg | FilterResult | FilterSummaryResult;
109
+ if (options.summary) {
110
+ output = { matched: result.matched, total: result.total };
111
+ } else if (options.quiet) {
112
+ output = result.spec;
113
+ } else {
114
+ output = { spec: result.spec, matched: result.matched, total: result.total };
115
+ }
116
+
117
+ const json = JSON.stringify(output, null, 2);
118
+
119
+ if (options.output) {
120
+ fs.writeFileSync(path.resolve(options.output), json);
121
+ } else {
122
+ console.log(json);
123
+ }
124
+ } catch (err) {
125
+ const error = err instanceof Error ? err : new Error(String(err));
126
+ console.error(JSON.stringify({ error: error.message }, null, 2));
127
+ process.exit(1);
128
+ }
129
+ }
130
+ );
131
+ }
@@ -0,0 +1,43 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { diffSpec, type OpenPkg, recommendSemverBump, type SemverBump } from '@openpkg-ts/spec';
4
+ import { Command } from 'commander';
5
+
6
+ export type SemverResult = {
7
+ bump: SemverBump;
8
+ reason: string;
9
+ };
10
+
11
+ function loadSpec(filePath: string): OpenPkg {
12
+ const resolved = path.resolve(filePath);
13
+ const content = fs.readFileSync(resolved, 'utf-8');
14
+ return JSON.parse(content) as OpenPkg;
15
+ }
16
+
17
+ export function createSemverCommand(): Command {
18
+ return new Command('semver')
19
+ .description('Recommend semver bump based on spec changes')
20
+ .argument('<old>', 'Path to old spec file (JSON)')
21
+ .argument('<new>', 'Path to new spec file (JSON)')
22
+ .action(async (oldPath: string, newPath: string) => {
23
+ try {
24
+ const oldSpec = loadSpec(oldPath);
25
+ const newSpec = loadSpec(newPath);
26
+
27
+ const diff = diffSpec(oldSpec, newSpec);
28
+ const recommendation = recommendSemverBump(diff);
29
+
30
+ const result: SemverResult = {
31
+ bump: recommendation.bump,
32
+ reason: recommendation.reason,
33
+ };
34
+
35
+ console.log(JSON.stringify(result, null, 2));
36
+ // Always exit 0 - this is a recommendation only
37
+ } catch (err) {
38
+ const error = err instanceof Error ? err : new Error(String(err));
39
+ console.error(JSON.stringify({ error: error.message }, null, 2));
40
+ process.exit(1);
41
+ }
42
+ });
43
+ }
@@ -0,0 +1,45 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { getValidationErrors, type SchemaVersion, type SpecError } from '@openpkg-ts/spec';
4
+ import { Command } from 'commander';
5
+
6
+ export type ValidateResult = {
7
+ valid: boolean;
8
+ errors: SpecError[];
9
+ };
10
+
11
+ function loadJSON(filePath: string): unknown {
12
+ const resolved = path.resolve(filePath);
13
+ const content = fs.readFileSync(resolved, 'utf-8');
14
+ return JSON.parse(content);
15
+ }
16
+
17
+ export function createValidateCommand(): Command {
18
+ return new Command('validate')
19
+ .description('Validate an OpenPkg spec against the schema')
20
+ .argument('<spec>', 'Path to spec file (JSON)')
21
+ .option('--version <version>', 'Schema version to validate against (default: latest)')
22
+ .action(async (specPath: string, options: { version?: string }) => {
23
+ try {
24
+ const spec = loadJSON(specPath);
25
+ const version = (options.version ?? 'latest') as SchemaVersion;
26
+
27
+ const errors = getValidationErrors(spec, version);
28
+
29
+ const result: ValidateResult = {
30
+ valid: errors.length === 0,
31
+ errors,
32
+ };
33
+
34
+ console.log(JSON.stringify(result, null, 2));
35
+
36
+ if (errors.length > 0) {
37
+ process.exit(1);
38
+ }
39
+ } catch (err) {
40
+ const error = err instanceof Error ? err : new Error(String(err));
41
+ console.error(JSON.stringify({ error: error.message }, null, 2));
42
+ process.exit(1);
43
+ }
44
+ });
45
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  // CLI package - entry point for programmatic use if needed
2
2
  export { getExport, listExports } from '@openpkg-ts/sdk';
3
+ export { type FilterResult, type FilterSummaryResult } from './commands/filter';
@@ -0,0 +1,189 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { OpenPkg } from '@openpkg-ts/spec';
6
+ import { $ } from 'bun';
7
+
8
+ /**
9
+ * E2E test for full release workflow:
10
+ * old.json → new.json → breaking check → semver → changelog → docs (fumadocs)
11
+ */
12
+ describe('release workflow e2e', () => {
13
+ let tmpDir: string;
14
+ let oldPath: string;
15
+ let newPath: string;
16
+
17
+ const oldSpec: OpenPkg = {
18
+ openpkg: '0.4.0',
19
+ meta: { name: 'test-pkg', version: '1.0.0' },
20
+ exports: [
21
+ {
22
+ id: 'fn-greet',
23
+ name: 'greet',
24
+ kind: 'function',
25
+ description: 'Greets a person',
26
+ signatures: [
27
+ {
28
+ parameters: [
29
+ { name: 'name', schema: { type: 'string' }, description: 'Person name' },
30
+ ],
31
+ returns: { schema: { type: 'string' }, description: 'Greeting message' },
32
+ },
33
+ ],
34
+ },
35
+ {
36
+ id: 'fn-goodbye',
37
+ name: 'goodbye',
38
+ kind: 'function',
39
+ description: 'Says goodbye',
40
+ signatures: [],
41
+ },
42
+ ],
43
+ types: [],
44
+ };
45
+
46
+ const newSpec: OpenPkg = {
47
+ openpkg: '0.4.0',
48
+ meta: { name: 'test-pkg', version: '2.0.0' },
49
+ exports: [
50
+ {
51
+ id: 'fn-greet',
52
+ name: 'greet',
53
+ kind: 'function',
54
+ description: 'Greets a person with optional message',
55
+ signatures: [
56
+ {
57
+ parameters: [
58
+ { name: 'name', schema: { type: 'string' }, description: 'Person name' },
59
+ { name: 'message', schema: { type: 'string' }, description: 'Optional message' },
60
+ ],
61
+ returns: { schema: { type: 'string' }, description: 'Greeting message' },
62
+ },
63
+ ],
64
+ },
65
+ {
66
+ id: 'fn-hello',
67
+ name: 'hello',
68
+ kind: 'function',
69
+ description: 'New hello function',
70
+ signatures: [],
71
+ },
72
+ ],
73
+ types: [],
74
+ };
75
+
76
+ beforeAll(() => {
77
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'release-workflow-test-'));
78
+ oldPath = path.join(tmpDir, 'old.json');
79
+ newPath = path.join(tmpDir, 'new.json');
80
+
81
+ fs.writeFileSync(oldPath, JSON.stringify(oldSpec));
82
+ fs.writeFileSync(newPath, JSON.stringify(newSpec));
83
+ });
84
+
85
+ afterAll(() => {
86
+ fs.rmSync(tmpDir, { recursive: true });
87
+ });
88
+
89
+ it('detects breaking changes', async () => {
90
+ const proc = Bun.spawn(['bun', 'packages/cli/bin/openpkg.ts', 'breaking', oldPath, newPath]);
91
+ const stdout = await new Response(proc.stdout).text();
92
+ const exitCode = await proc.exited;
93
+
94
+ const result = JSON.parse(stdout);
95
+
96
+ // Should find breaking change: goodbye removed
97
+ expect(result.count).toBeGreaterThan(0);
98
+ expect(exitCode).toBe(1); // exits 1 when breaking changes found
99
+ });
100
+
101
+ it('recommends semver bump', async () => {
102
+ const output = await $`bun packages/cli/bin/openpkg.ts semver ${oldPath} ${newPath}`.text();
103
+ const result = JSON.parse(output);
104
+
105
+ expect(result.bump).toBe('major'); // breaking change = major
106
+ expect(result.reason).toBeDefined();
107
+ });
108
+
109
+ it('generates changelog', async () => {
110
+ const output = await $`bun packages/cli/bin/openpkg.ts changelog ${oldPath} ${newPath} --format json`.text();
111
+ const result = JSON.parse(output);
112
+
113
+ expect(result.breaking).toBeDefined();
114
+ expect(result.added).toBeDefined();
115
+ expect(result.removed).toBeDefined();
116
+ expect(result.summary).toBeDefined();
117
+
118
+ // Verify removed function detected (removed has full objects)
119
+ const removed = result.removed.find((r: { name: string }) => r.name === 'goodbye');
120
+ expect(removed).toBeDefined();
121
+
122
+ // Verify added function detected (added is array of IDs)
123
+ expect(result.added).toContain('fn-hello');
124
+ });
125
+
126
+ it('generates markdown docs', async () => {
127
+ const output = await $`bun packages/cli/bin/openpkg.ts docs ${newPath}`.text();
128
+
129
+ expect(output).toContain('greet');
130
+ expect(output).toContain('hello');
131
+ expect(output).toContain('Greets a person');
132
+ });
133
+
134
+ it('generates json docs', async () => {
135
+ const output = await $`bun packages/cli/bin/openpkg.ts docs ${newPath} --format json`.text();
136
+ const result = JSON.parse(output);
137
+
138
+ expect(result.name).toBe('test-pkg');
139
+ expect(result.exports).toBeDefined();
140
+ expect(result.exports.length).toBe(2);
141
+ });
142
+
143
+ it('generates fumadocs output', async () => {
144
+ const docsDir = path.join(tmpDir, 'fumadocs-output');
145
+
146
+ await $`bun packages/cli/bin/openpkg.ts docs ${newPath} --adapter fumadocs --output ${docsDir}`;
147
+
148
+ // Verify directory created
149
+ expect(fs.existsSync(docsDir)).toBe(true);
150
+
151
+ // Verify markdown files created (one per export)
152
+ const files = fs.readdirSync(docsDir);
153
+ const mdFiles = files.filter((f) => f.endsWith('.md'));
154
+ expect(mdFiles.length).toBe(2); // greet.md, hello.md
155
+ });
156
+
157
+ it('validates new spec', async () => {
158
+ const output = await $`bun packages/cli/bin/openpkg.ts validate ${newPath}`.text();
159
+ const result = JSON.parse(output);
160
+
161
+ expect(result.valid).toBe(true);
162
+ expect(result.errors).toHaveLength(0);
163
+ });
164
+
165
+ it('runs diagnostics on new spec', async () => {
166
+ const output = await $`bun packages/cli/bin/openpkg.ts diagnostics ${newPath}`.text();
167
+ const result = JSON.parse(output);
168
+
169
+ expect(result.summary).toBeDefined();
170
+ expect(result.diagnostics).toBeDefined();
171
+ });
172
+
173
+ it('full workflow runs without error', async () => {
174
+ // Simulate complete release workflow sequence
175
+ const steps = [
176
+ $`bun packages/cli/bin/openpkg.ts validate ${oldPath}`,
177
+ $`bun packages/cli/bin/openpkg.ts validate ${newPath}`,
178
+ $`bun packages/cli/bin/openpkg.ts semver ${oldPath} ${newPath}`,
179
+ $`bun packages/cli/bin/openpkg.ts changelog ${oldPath} ${newPath} --format json`,
180
+ $`bun packages/cli/bin/openpkg.ts diagnostics ${newPath}`,
181
+ $`bun packages/cli/bin/openpkg.ts docs ${newPath} --format json`,
182
+ ];
183
+
184
+ for (const step of steps) {
185
+ const output = await step.text();
186
+ expect(() => JSON.parse(output)).not.toThrow();
187
+ }
188
+ });
189
+ });