@fission-ai/openspec 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 (77) hide show
  1. package/README.md +119 -0
  2. package/bin/openspec.js +3 -0
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.js +240 -0
  5. package/dist/commands/change.d.ts +35 -0
  6. package/dist/commands/change.js +276 -0
  7. package/dist/commands/show.d.ts +14 -0
  8. package/dist/commands/show.js +131 -0
  9. package/dist/commands/spec.d.ts +15 -0
  10. package/dist/commands/spec.js +224 -0
  11. package/dist/commands/validate.d.ts +23 -0
  12. package/dist/commands/validate.js +275 -0
  13. package/dist/core/archive.d.ts +15 -0
  14. package/dist/core/archive.js +529 -0
  15. package/dist/core/config.d.ts +14 -0
  16. package/dist/core/config.js +12 -0
  17. package/dist/core/configurators/base.d.ts +7 -0
  18. package/dist/core/configurators/base.js +2 -0
  19. package/dist/core/configurators/claude.d.ts +8 -0
  20. package/dist/core/configurators/claude.js +15 -0
  21. package/dist/core/configurators/registry.d.ts +9 -0
  22. package/dist/core/configurators/registry.js +22 -0
  23. package/dist/core/converters/json-converter.d.ts +6 -0
  24. package/dist/core/converters/json-converter.js +48 -0
  25. package/dist/core/diff.d.ts +11 -0
  26. package/dist/core/diff.js +193 -0
  27. package/dist/core/index.d.ts +2 -0
  28. package/dist/core/index.js +2 -0
  29. package/dist/core/init.d.ts +10 -0
  30. package/dist/core/init.js +109 -0
  31. package/dist/core/list.d.ts +4 -0
  32. package/dist/core/list.js +89 -0
  33. package/dist/core/parsers/change-parser.d.ts +13 -0
  34. package/dist/core/parsers/change-parser.js +192 -0
  35. package/dist/core/parsers/markdown-parser.d.ts +21 -0
  36. package/dist/core/parsers/markdown-parser.js +183 -0
  37. package/dist/core/parsers/requirement-blocks.d.ts +31 -0
  38. package/dist/core/parsers/requirement-blocks.js +173 -0
  39. package/dist/core/schemas/base.schema.d.ts +13 -0
  40. package/dist/core/schemas/base.schema.js +13 -0
  41. package/dist/core/schemas/change.schema.d.ts +73 -0
  42. package/dist/core/schemas/change.schema.js +31 -0
  43. package/dist/core/schemas/index.d.ts +4 -0
  44. package/dist/core/schemas/index.js +4 -0
  45. package/dist/core/schemas/spec.schema.d.ts +18 -0
  46. package/dist/core/schemas/spec.schema.js +15 -0
  47. package/dist/core/templates/claude-template.d.ts +2 -0
  48. package/dist/core/templates/claude-template.js +96 -0
  49. package/dist/core/templates/index.d.ts +11 -0
  50. package/dist/core/templates/index.js +21 -0
  51. package/dist/core/templates/project-template.d.ts +8 -0
  52. package/dist/core/templates/project-template.js +32 -0
  53. package/dist/core/templates/readme-template.d.ts +2 -0
  54. package/dist/core/templates/readme-template.js +519 -0
  55. package/dist/core/update.d.ts +4 -0
  56. package/dist/core/update.js +47 -0
  57. package/dist/core/validation/constants.d.ts +34 -0
  58. package/dist/core/validation/constants.js +40 -0
  59. package/dist/core/validation/types.d.ts +18 -0
  60. package/dist/core/validation/types.js +2 -0
  61. package/dist/core/validation/validator.d.ts +32 -0
  62. package/dist/core/validation/validator.js +355 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/index.js +3 -0
  65. package/dist/utils/file-system.d.ts +10 -0
  66. package/dist/utils/file-system.js +83 -0
  67. package/dist/utils/index.d.ts +2 -0
  68. package/dist/utils/index.js +2 -0
  69. package/dist/utils/interactive.d.ts +2 -0
  70. package/dist/utils/interactive.js +8 -0
  71. package/dist/utils/item-discovery.d.ts +3 -0
  72. package/dist/utils/item-discovery.js +49 -0
  73. package/dist/utils/match.d.ts +3 -0
  74. package/dist/utils/match.js +22 -0
  75. package/dist/utils/task-progress.d.ts +8 -0
  76. package/dist/utils/task-progress.js +36 -0
  77. package/package.json +68 -0
@@ -0,0 +1,48 @@
1
+ import { readFileSync } from 'fs';
2
+ import path from 'path';
3
+ import { MarkdownParser } from '../parsers/markdown-parser.js';
4
+ import { ChangeParser } from '../parsers/change-parser.js';
5
+ export class JsonConverter {
6
+ convertSpecToJson(filePath) {
7
+ const content = readFileSync(filePath, 'utf-8');
8
+ const parser = new MarkdownParser(content);
9
+ const specName = this.extractNameFromPath(filePath);
10
+ const spec = parser.parseSpec(specName);
11
+ const jsonSpec = {
12
+ ...spec,
13
+ metadata: {
14
+ ...spec.metadata,
15
+ sourcePath: filePath,
16
+ },
17
+ };
18
+ return JSON.stringify(jsonSpec, null, 2);
19
+ }
20
+ async convertChangeToJson(filePath) {
21
+ const content = readFileSync(filePath, 'utf-8');
22
+ const changeName = this.extractNameFromPath(filePath);
23
+ const changeDir = path.dirname(filePath);
24
+ const parser = new ChangeParser(content, changeDir);
25
+ const change = await parser.parseChangeWithDeltas(changeName);
26
+ const jsonChange = {
27
+ ...change,
28
+ metadata: {
29
+ ...change.metadata,
30
+ sourcePath: filePath,
31
+ },
32
+ };
33
+ return JSON.stringify(jsonChange, null, 2);
34
+ }
35
+ extractNameFromPath(filePath) {
36
+ const parts = filePath.split('/');
37
+ for (let i = parts.length - 1; i >= 0; i--) {
38
+ if (parts[i] === 'specs' || parts[i] === 'changes') {
39
+ if (i < parts.length - 1) {
40
+ return parts[i + 1];
41
+ }
42
+ }
43
+ }
44
+ const fileName = parts[parts.length - 1];
45
+ return fileName.replace('.md', '');
46
+ }
47
+ }
48
+ //# sourceMappingURL=json-converter.js.map
@@ -0,0 +1,11 @@
1
+ export declare class DiffCommand {
2
+ private filesChanged;
3
+ private linesAdded;
4
+ private linesRemoved;
5
+ execute(changeName?: string): Promise<void>;
6
+ private selectChange;
7
+ private showDiffs;
8
+ private walkAndDiff;
9
+ private diffFile;
10
+ }
11
+ //# sourceMappingURL=diff.d.ts.map
@@ -0,0 +1,193 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import { diffStringsUnified } from 'jest-diff';
5
+ import { select } from '@inquirer/prompts';
6
+ import { Validator } from './validation/validator.js';
7
+ // Constants
8
+ const ARCHIVE_DIR = 'archive';
9
+ const MARKDOWN_EXT = '.md';
10
+ const OPENSPEC_DIR = 'openspec';
11
+ const CHANGES_DIR = 'changes';
12
+ const SPECS_DIR = 'specs';
13
+ export class DiffCommand {
14
+ filesChanged = 0;
15
+ linesAdded = 0;
16
+ linesRemoved = 0;
17
+ async execute(changeName) {
18
+ const changesDir = path.join(process.cwd(), OPENSPEC_DIR, CHANGES_DIR);
19
+ try {
20
+ await fs.access(changesDir);
21
+ }
22
+ catch {
23
+ throw new Error('No OpenSpec changes directory found');
24
+ }
25
+ if (!changeName) {
26
+ changeName = await this.selectChange(changesDir);
27
+ if (!changeName)
28
+ return;
29
+ }
30
+ const changeDir = path.join(changesDir, changeName);
31
+ try {
32
+ await fs.access(changeDir);
33
+ }
34
+ catch {
35
+ throw new Error(`Change '${changeName}' not found`);
36
+ }
37
+ const changeSpecsDir = path.join(changeDir, SPECS_DIR);
38
+ try {
39
+ await fs.access(changeSpecsDir);
40
+ }
41
+ catch {
42
+ console.log(`No spec changes found for '${changeName}'`);
43
+ return;
44
+ }
45
+ // Validate specs and show warnings (non-blocking)
46
+ const validator = new Validator();
47
+ let hasWarnings = false;
48
+ try {
49
+ const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
50
+ for (const entry of entries) {
51
+ if (entry.isDirectory()) {
52
+ const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
53
+ try {
54
+ await fs.access(specFile);
55
+ const report = await validator.validateSpec(specFile);
56
+ if (report.issues.length > 0) {
57
+ const warnings = report.issues.filter(i => i.level === 'WARNING');
58
+ const errors = report.issues.filter(i => i.level === 'ERROR');
59
+ if (errors.length > 0 || warnings.length > 0) {
60
+ if (!hasWarnings) {
61
+ console.log(chalk.yellow('\n⚠️ Validation warnings found:'));
62
+ hasWarnings = true;
63
+ }
64
+ console.log(chalk.yellow(`\n ${entry.name}/spec.md:`));
65
+ for (const issue of errors) {
66
+ console.log(chalk.red(` ✗ ${issue.message}`));
67
+ }
68
+ for (const issue of warnings) {
69
+ console.log(chalk.yellow(` ⚠ ${issue.message}`));
70
+ }
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ // Spec file doesn't exist, skip validation
76
+ }
77
+ }
78
+ }
79
+ if (hasWarnings) {
80
+ console.log(chalk.yellow('\nConsider fixing these issues before archiving.\n'));
81
+ }
82
+ }
83
+ catch {
84
+ // No specs directory, skip validation
85
+ }
86
+ // Reset counters
87
+ this.filesChanged = 0;
88
+ this.linesAdded = 0;
89
+ this.linesRemoved = 0;
90
+ await this.showDiffs(changeSpecsDir);
91
+ // Show summary
92
+ if (this.filesChanged > 0) {
93
+ console.log(chalk.bold(`\n📊 Summary: ${this.filesChanged} file(s) changed, ${chalk.green(`+${this.linesAdded}`)} ${chalk.red(`-${this.linesRemoved}`)}`));
94
+ }
95
+ }
96
+ async selectChange(changesDir) {
97
+ const entries = await fs.readdir(changesDir, { withFileTypes: true });
98
+ const changes = entries
99
+ .filter(entry => entry.isDirectory() && entry.name !== ARCHIVE_DIR)
100
+ .map(entry => entry.name);
101
+ if (changes.length === 0) {
102
+ console.log('No changes found');
103
+ return undefined;
104
+ }
105
+ console.log('Available changes:');
106
+ const choices = changes.map((name) => ({
107
+ name: name,
108
+ value: name
109
+ }));
110
+ const answer = await select({
111
+ message: 'Select a change',
112
+ choices
113
+ });
114
+ return answer;
115
+ }
116
+ async showDiffs(changeSpecsDir) {
117
+ const currentSpecsDir = path.join(process.cwd(), OPENSPEC_DIR, SPECS_DIR);
118
+ await this.walkAndDiff(changeSpecsDir, currentSpecsDir, '');
119
+ }
120
+ async walkAndDiff(changeDir, currentDir, relativePath) {
121
+ const entries = await fs.readdir(path.join(changeDir, relativePath), { withFileTypes: true });
122
+ for (const entry of entries) {
123
+ const entryPath = path.join(relativePath, entry.name);
124
+ if (entry.isDirectory()) {
125
+ await this.walkAndDiff(changeDir, currentDir, entryPath);
126
+ }
127
+ else if (entry.isFile() && entry.name.endsWith(MARKDOWN_EXT)) {
128
+ await this.diffFile(path.join(changeDir, entryPath), path.join(currentDir, entryPath), entryPath);
129
+ }
130
+ }
131
+ }
132
+ async diffFile(changePath, currentPath, displayPath) {
133
+ let changeContent = '';
134
+ let currentContent = '';
135
+ let isNewFile = false;
136
+ let isDeleted = false;
137
+ try {
138
+ changeContent = await fs.readFile(changePath, 'utf-8');
139
+ }
140
+ catch {
141
+ changeContent = '';
142
+ }
143
+ try {
144
+ currentContent = await fs.readFile(currentPath, 'utf-8');
145
+ }
146
+ catch {
147
+ currentContent = '';
148
+ isNewFile = true;
149
+ }
150
+ if (changeContent === currentContent) {
151
+ return;
152
+ }
153
+ if (changeContent === '' && currentContent !== '') {
154
+ isDeleted = true;
155
+ }
156
+ // Enhanced header with file status
157
+ console.log(chalk.bold.cyan(`\n${'═'.repeat(60)}`));
158
+ console.log(chalk.bold.cyan(`📄 ${displayPath}`));
159
+ if (isNewFile) {
160
+ console.log(chalk.green(` Status: NEW FILE`));
161
+ }
162
+ else if (isDeleted) {
163
+ console.log(chalk.red(` Status: DELETED`));
164
+ }
165
+ else {
166
+ console.log(chalk.yellow(` Status: MODIFIED`));
167
+ }
168
+ // Use jest-diff for the actual diff with custom options
169
+ const diffOptions = {
170
+ aAnnotation: 'Current',
171
+ bAnnotation: 'Proposed',
172
+ aColor: chalk.red,
173
+ bColor: chalk.green,
174
+ commonColor: chalk.gray,
175
+ contextLines: 3,
176
+ expand: false,
177
+ includeChangeCounts: true,
178
+ };
179
+ const diff = diffStringsUnified(currentContent, changeContent, diffOptions);
180
+ // Count lines for statistics (approximate)
181
+ const addedLines = (diff.match(/^\+[^+]/gm) || []).length;
182
+ const removedLines = (diff.match(/^-[^-]/gm) || []).length;
183
+ console.log(chalk.gray(` Lines: ${chalk.green(`+${addedLines}`)} ${chalk.red(`-${removedLines}`)}`));
184
+ console.log(chalk.bold.cyan(`${'─'.repeat(60)}\n`));
185
+ // Display the diff
186
+ console.log(diff);
187
+ // Update counters
188
+ this.filesChanged++;
189
+ this.linesAdded += addedLines;
190
+ this.linesRemoved += removedLines;
191
+ }
192
+ }
193
+ //# sourceMappingURL=diff.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,10 @@
1
+ export declare class InitCommand {
2
+ execute(targetPath: string): Promise<void>;
3
+ private validate;
4
+ private getConfiguration;
5
+ private createDirectoryStructure;
6
+ private generateFiles;
7
+ private configureAITools;
8
+ private displaySuccessMessage;
9
+ }
10
+ //# sourceMappingURL=init.d.ts.map
@@ -0,0 +1,109 @@
1
+ import path from 'path';
2
+ import { select } from '@inquirer/prompts';
3
+ import ora from 'ora';
4
+ import { FileSystemUtils } from '../utils/file-system.js';
5
+ import { TemplateManager } from './templates/index.js';
6
+ import { ToolRegistry } from './configurators/registry.js';
7
+ import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
8
+ export class InitCommand {
9
+ async execute(targetPath) {
10
+ const projectPath = path.resolve(targetPath);
11
+ const openspecDir = OPENSPEC_DIR_NAME;
12
+ const openspecPath = path.join(projectPath, openspecDir);
13
+ // Validation happens silently in the background
14
+ await this.validate(projectPath, openspecPath);
15
+ // Get configuration (after validation to avoid prompts if validation fails)
16
+ const config = await this.getConfiguration();
17
+ // Step 1: Create directory structure
18
+ const structureSpinner = ora({ text: 'Creating OpenSpec structure...', stream: process.stdout }).start();
19
+ await this.createDirectoryStructure(openspecPath);
20
+ await this.generateFiles(openspecPath, config);
21
+ structureSpinner.succeed('OpenSpec structure created');
22
+ // Step 2: Configure AI tools
23
+ const toolSpinner = ora({ text: 'Configuring AI tools...', stream: process.stdout }).start();
24
+ await this.configureAITools(projectPath, openspecDir, config.aiTools);
25
+ toolSpinner.succeed('AI tools configured');
26
+ // Success message
27
+ this.displaySuccessMessage(openspecDir, config);
28
+ }
29
+ async validate(projectPath, openspecPath) {
30
+ // Check if OpenSpec already exists
31
+ if (await FileSystemUtils.directoryExists(openspecPath)) {
32
+ throw new Error(`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
33
+ `Use 'openspec update' to update the structure.`);
34
+ }
35
+ // Check write permissions
36
+ if (!await FileSystemUtils.ensureWritePermissions(projectPath)) {
37
+ throw new Error(`Insufficient permissions to write to ${projectPath}`);
38
+ }
39
+ }
40
+ async getConfiguration() {
41
+ const config = {
42
+ aiTools: []
43
+ };
44
+ // Single-select for better UX
45
+ const selectedTool = await select({
46
+ message: 'Which AI tool do you use?',
47
+ choices: AI_TOOLS.map(tool => ({
48
+ name: tool.available ? tool.name : `${tool.name} (coming soon)`,
49
+ value: tool.value,
50
+ disabled: !tool.available
51
+ }))
52
+ });
53
+ config.aiTools = [selectedTool];
54
+ return config;
55
+ }
56
+ async createDirectoryStructure(openspecPath) {
57
+ const directories = [
58
+ openspecPath,
59
+ path.join(openspecPath, 'specs'),
60
+ path.join(openspecPath, 'changes'),
61
+ path.join(openspecPath, 'changes', 'archive')
62
+ ];
63
+ for (const dir of directories) {
64
+ await FileSystemUtils.createDirectory(dir);
65
+ }
66
+ }
67
+ async generateFiles(openspecPath, config) {
68
+ const context = {
69
+ // Could be enhanced with prompts for project details
70
+ };
71
+ const templates = TemplateManager.getTemplates(context);
72
+ for (const template of templates) {
73
+ const filePath = path.join(openspecPath, template.path);
74
+ const content = typeof template.content === 'function'
75
+ ? template.content(context)
76
+ : template.content;
77
+ await FileSystemUtils.writeFile(filePath, content);
78
+ }
79
+ }
80
+ async configureAITools(projectPath, openspecDir, toolIds) {
81
+ for (const toolId of toolIds) {
82
+ const configurator = ToolRegistry.get(toolId);
83
+ if (configurator && configurator.isAvailable) {
84
+ await configurator.configure(projectPath, openspecDir);
85
+ }
86
+ }
87
+ }
88
+ displaySuccessMessage(openspecDir, config) {
89
+ console.log(); // Empty line for spacing
90
+ ora().succeed('OpenSpec initialized successfully!');
91
+ // Get the selected tool name for display
92
+ const selectedToolId = config.aiTools[0];
93
+ const selectedTool = AI_TOOLS.find(t => t.value === selectedToolId);
94
+ const toolName = selectedTool ? selectedTool.name : 'your AI assistant';
95
+ console.log(`\nNext steps - Copy these prompts to ${toolName}:\n`);
96
+ console.log('────────────────────────────────────────────────────────────');
97
+ console.log('1. Populate your project context:');
98
+ console.log(' "Please read openspec/project.md and help me fill it out');
99
+ console.log(' with details about my project, tech stack, and conventions"\n');
100
+ console.log('2. Create your first change proposal:');
101
+ console.log(' "I want to add [YOUR FEATURE HERE]. Please create an');
102
+ console.log(' OpenSpec change proposal for this feature"\n');
103
+ console.log('3. Learn the OpenSpec workflow:');
104
+ console.log(' "Please explain the OpenSpec workflow from openspec/README.md');
105
+ console.log(' and how I should work with you on this project"');
106
+ console.log('────────────────────────────────────────────────────────────\n');
107
+ }
108
+ }
109
+ //# sourceMappingURL=init.js.map
@@ -0,0 +1,4 @@
1
+ export declare class ListCommand {
2
+ execute(targetPath?: string, mode?: 'changes' | 'specs'): Promise<void>;
3
+ }
4
+ //# sourceMappingURL=list.d.ts.map
@@ -0,0 +1,89 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
4
+ import { readFileSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { MarkdownParser } from './parsers/markdown-parser.js';
7
+ export class ListCommand {
8
+ async execute(targetPath = '.', mode = 'changes') {
9
+ if (mode === 'changes') {
10
+ const changesDir = path.join(targetPath, 'openspec', 'changes');
11
+ // Check if changes directory exists
12
+ try {
13
+ await fs.access(changesDir);
14
+ }
15
+ catch {
16
+ throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
17
+ }
18
+ // Get all directories in changes (excluding archive)
19
+ const entries = await fs.readdir(changesDir, { withFileTypes: true });
20
+ const changeDirs = entries
21
+ .filter(entry => entry.isDirectory() && entry.name !== 'archive')
22
+ .map(entry => entry.name);
23
+ if (changeDirs.length === 0) {
24
+ console.log('No active changes found.');
25
+ return;
26
+ }
27
+ // Collect information about each change
28
+ const changes = [];
29
+ for (const changeDir of changeDirs) {
30
+ const progress = await getTaskProgressForChange(changesDir, changeDir);
31
+ changes.push({
32
+ name: changeDir,
33
+ completedTasks: progress.completed,
34
+ totalTasks: progress.total
35
+ });
36
+ }
37
+ // Sort alphabetically by name
38
+ changes.sort((a, b) => a.name.localeCompare(b.name));
39
+ // Display results
40
+ console.log('Changes:');
41
+ const padding = ' ';
42
+ const nameWidth = Math.max(...changes.map(c => c.name.length));
43
+ for (const change of changes) {
44
+ const paddedName = change.name.padEnd(nameWidth);
45
+ const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
46
+ console.log(`${padding}${paddedName} ${status}`);
47
+ }
48
+ return;
49
+ }
50
+ // specs mode
51
+ const specsDir = path.join(targetPath, 'openspec', 'specs');
52
+ try {
53
+ await fs.access(specsDir);
54
+ }
55
+ catch {
56
+ console.log('No specs found.');
57
+ return;
58
+ }
59
+ const entries = await fs.readdir(specsDir, { withFileTypes: true });
60
+ const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
61
+ if (specDirs.length === 0) {
62
+ console.log('No specs found.');
63
+ return;
64
+ }
65
+ const specs = [];
66
+ for (const id of specDirs) {
67
+ const specPath = join(specsDir, id, 'spec.md');
68
+ try {
69
+ const content = readFileSync(specPath, 'utf-8');
70
+ const parser = new MarkdownParser(content);
71
+ const spec = parser.parseSpec(id);
72
+ specs.push({ id, requirementCount: spec.requirements.length });
73
+ }
74
+ catch {
75
+ // If spec cannot be read or parsed, include with 0 count
76
+ specs.push({ id, requirementCount: 0 });
77
+ }
78
+ }
79
+ specs.sort((a, b) => a.id.localeCompare(b.id));
80
+ console.log('Specs:');
81
+ const padding = ' ';
82
+ const nameWidth = Math.max(...specs.map(s => s.id.length));
83
+ for (const spec of specs) {
84
+ const padded = spec.id.padEnd(nameWidth);
85
+ console.log(`${padding}${padded} requirements ${spec.requirementCount}`);
86
+ }
87
+ }
88
+ }
89
+ //# sourceMappingURL=list.js.map
@@ -0,0 +1,13 @@
1
+ import { MarkdownParser } from './markdown-parser.js';
2
+ import { Change } from '../schemas/index.js';
3
+ export declare class ChangeParser extends MarkdownParser {
4
+ private changeDir;
5
+ constructor(content: string, changeDir: string);
6
+ parseChangeWithDeltas(name: string): Promise<Change>;
7
+ private parseDeltaSpecs;
8
+ private parseSpecDeltas;
9
+ private parseRenames;
10
+ private parseSectionsFromContent;
11
+ private getContentUntilNextHeaderFromLines;
12
+ }
13
+ //# sourceMappingURL=change-parser.d.ts.map
@@ -0,0 +1,192 @@
1
+ import { MarkdownParser } from './markdown-parser.js';
2
+ import path from 'path';
3
+ import { promises as fs } from 'fs';
4
+ export class ChangeParser extends MarkdownParser {
5
+ changeDir;
6
+ constructor(content, changeDir) {
7
+ super(content);
8
+ this.changeDir = changeDir;
9
+ }
10
+ async parseChangeWithDeltas(name) {
11
+ const sections = this.parseSections();
12
+ const why = this.findSection(sections, 'Why')?.content || '';
13
+ const whatChanges = this.findSection(sections, 'What Changes')?.content || '';
14
+ if (!why) {
15
+ throw new Error('Change must have a Why section');
16
+ }
17
+ if (!whatChanges) {
18
+ throw new Error('Change must have a What Changes section');
19
+ }
20
+ // Parse deltas from the What Changes section (simple format)
21
+ const simpleDeltas = this.parseDeltas(whatChanges);
22
+ // Check if there are spec files with delta format
23
+ const specsDir = path.join(this.changeDir, 'specs');
24
+ const deltaDeltas = await this.parseDeltaSpecs(specsDir);
25
+ // Combine both types of deltas, preferring delta format if available
26
+ const deltas = deltaDeltas.length > 0 ? deltaDeltas : simpleDeltas;
27
+ return {
28
+ name,
29
+ why: why.trim(),
30
+ whatChanges: whatChanges.trim(),
31
+ deltas,
32
+ metadata: {
33
+ version: '1.0.0',
34
+ format: 'openspec-change',
35
+ },
36
+ };
37
+ }
38
+ async parseDeltaSpecs(specsDir) {
39
+ const deltas = [];
40
+ try {
41
+ const specDirs = await fs.readdir(specsDir, { withFileTypes: true });
42
+ for (const dir of specDirs) {
43
+ if (!dir.isDirectory())
44
+ continue;
45
+ const specName = dir.name;
46
+ const specFile = path.join(specsDir, specName, 'spec.md');
47
+ try {
48
+ const content = await fs.readFile(specFile, 'utf-8');
49
+ const specDeltas = this.parseSpecDeltas(specName, content);
50
+ deltas.push(...specDeltas);
51
+ }
52
+ catch (error) {
53
+ // Spec file might not exist, which is okay
54
+ continue;
55
+ }
56
+ }
57
+ }
58
+ catch (error) {
59
+ // Specs directory might not exist, which is okay
60
+ return [];
61
+ }
62
+ return deltas;
63
+ }
64
+ parseSpecDeltas(specName, content) {
65
+ const deltas = [];
66
+ const sections = this.parseSectionsFromContent(content);
67
+ // Parse ADDED requirements
68
+ const addedSection = this.findSection(sections, 'ADDED Requirements');
69
+ if (addedSection) {
70
+ const requirements = this.parseRequirements(addedSection);
71
+ requirements.forEach(req => {
72
+ deltas.push({
73
+ spec: specName,
74
+ operation: 'ADDED',
75
+ description: `Add requirement: ${req.text}`,
76
+ // Provide both single and plural forms for compatibility
77
+ requirement: req,
78
+ requirements: [req],
79
+ });
80
+ });
81
+ }
82
+ // Parse MODIFIED requirements
83
+ const modifiedSection = this.findSection(sections, 'MODIFIED Requirements');
84
+ if (modifiedSection) {
85
+ const requirements = this.parseRequirements(modifiedSection);
86
+ requirements.forEach(req => {
87
+ deltas.push({
88
+ spec: specName,
89
+ operation: 'MODIFIED',
90
+ description: `Modify requirement: ${req.text}`,
91
+ requirement: req,
92
+ requirements: [req],
93
+ });
94
+ });
95
+ }
96
+ // Parse REMOVED requirements
97
+ const removedSection = this.findSection(sections, 'REMOVED Requirements');
98
+ if (removedSection) {
99
+ const requirements = this.parseRequirements(removedSection);
100
+ requirements.forEach(req => {
101
+ deltas.push({
102
+ spec: specName,
103
+ operation: 'REMOVED',
104
+ description: `Remove requirement: ${req.text}`,
105
+ requirement: req,
106
+ requirements: [req],
107
+ });
108
+ });
109
+ }
110
+ // Parse RENAMED requirements
111
+ const renamedSection = this.findSection(sections, 'RENAMED Requirements');
112
+ if (renamedSection) {
113
+ const renames = this.parseRenames(renamedSection.content);
114
+ renames.forEach(rename => {
115
+ deltas.push({
116
+ spec: specName,
117
+ operation: 'RENAMED',
118
+ description: `Rename requirement from "${rename.from}" to "${rename.to}"`,
119
+ rename,
120
+ });
121
+ });
122
+ }
123
+ return deltas;
124
+ }
125
+ parseRenames(content) {
126
+ const renames = [];
127
+ const lines = content.split('\n');
128
+ let currentRename = {};
129
+ for (const line of lines) {
130
+ const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
131
+ const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
132
+ if (fromMatch) {
133
+ currentRename.from = fromMatch[1].trim();
134
+ }
135
+ else if (toMatch) {
136
+ currentRename.to = toMatch[1].trim();
137
+ if (currentRename.from && currentRename.to) {
138
+ renames.push({
139
+ from: currentRename.from,
140
+ to: currentRename.to,
141
+ });
142
+ currentRename = {};
143
+ }
144
+ }
145
+ }
146
+ return renames;
147
+ }
148
+ parseSectionsFromContent(content) {
149
+ const lines = content.split('\n');
150
+ const sections = [];
151
+ const stack = [];
152
+ for (let i = 0; i < lines.length; i++) {
153
+ const line = lines[i];
154
+ const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
155
+ if (headerMatch) {
156
+ const level = headerMatch[1].length;
157
+ const title = headerMatch[2].trim();
158
+ const contentLines = this.getContentUntilNextHeaderFromLines(lines, i + 1, level);
159
+ const section = {
160
+ level,
161
+ title,
162
+ content: contentLines.join('\n').trim(),
163
+ children: [],
164
+ };
165
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) {
166
+ stack.pop();
167
+ }
168
+ if (stack.length === 0) {
169
+ sections.push(section);
170
+ }
171
+ else {
172
+ stack[stack.length - 1].children.push(section);
173
+ }
174
+ stack.push(section);
175
+ }
176
+ }
177
+ return sections;
178
+ }
179
+ getContentUntilNextHeaderFromLines(lines, startLine, currentLevel) {
180
+ const contentLines = [];
181
+ for (let i = startLine; i < lines.length; i++) {
182
+ const line = lines[i];
183
+ const headerMatch = line.match(/^(#{1,6})\s+/);
184
+ if (headerMatch && headerMatch[1].length <= currentLevel) {
185
+ break;
186
+ }
187
+ contentLines.push(line);
188
+ }
189
+ return contentLines;
190
+ }
191
+ }
192
+ //# sourceMappingURL=change-parser.js.map