@applica-software-guru/sdd-core 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 (96) hide show
  1. package/dist/config/config-manager.d.ts +9 -0
  2. package/dist/config/config-manager.d.ts.map +1 -0
  3. package/dist/config/config-manager.js +44 -0
  4. package/dist/config/config-manager.js.map +1 -0
  5. package/dist/delta/delta-engine.d.ts +3 -0
  6. package/dist/delta/delta-engine.d.ts.map +1 -0
  7. package/dist/delta/delta-engine.js +18 -0
  8. package/dist/delta/delta-engine.js.map +1 -0
  9. package/dist/delta/hasher.d.ts +2 -0
  10. package/dist/delta/hasher.d.ts.map +1 -0
  11. package/dist/delta/hasher.js +8 -0
  12. package/dist/delta/hasher.js.map +1 -0
  13. package/dist/errors.d.ts +13 -0
  14. package/dist/errors.d.ts.map +1 -0
  15. package/dist/errors.js +32 -0
  16. package/dist/errors.js.map +1 -0
  17. package/dist/git/git.d.ts +12 -0
  18. package/dist/git/git.d.ts.map +1 -0
  19. package/dist/git/git.js +125 -0
  20. package/dist/git/git.js.map +1 -0
  21. package/dist/index.d.ts +6 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +14 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/lock/lock-manager.d.ts +6 -0
  26. package/dist/lock/lock-manager.d.ts.map +1 -0
  27. package/dist/lock/lock-manager.js +39 -0
  28. package/dist/lock/lock-manager.js.map +1 -0
  29. package/dist/parser/cr-parser.d.ts +8 -0
  30. package/dist/parser/cr-parser.d.ts.map +1 -0
  31. package/dist/parser/cr-parser.js +45 -0
  32. package/dist/parser/cr-parser.js.map +1 -0
  33. package/dist/parser/frontmatter.d.ts +7 -0
  34. package/dist/parser/frontmatter.d.ts.map +1 -0
  35. package/dist/parser/frontmatter.js +25 -0
  36. package/dist/parser/frontmatter.js.map +1 -0
  37. package/dist/parser/ref-extractor.d.ts +2 -0
  38. package/dist/parser/ref-extractor.d.ts.map +1 -0
  39. package/dist/parser/ref-extractor.js +17 -0
  40. package/dist/parser/ref-extractor.js.map +1 -0
  41. package/dist/parser/section-extractor.d.ts +4 -0
  42. package/dist/parser/section-extractor.d.ts.map +1 -0
  43. package/dist/parser/section-extractor.js +37 -0
  44. package/dist/parser/section-extractor.js.map +1 -0
  45. package/dist/parser/story-parser.d.ts +5 -0
  46. package/dist/parser/story-parser.d.ts.map +1 -0
  47. package/dist/parser/story-parser.js +41 -0
  48. package/dist/parser/story-parser.js.map +1 -0
  49. package/dist/prompt/prompt-generator.d.ts +3 -0
  50. package/dist/prompt/prompt-generator.d.ts.map +1 -0
  51. package/dist/prompt/prompt-generator.js +40 -0
  52. package/dist/prompt/prompt-generator.js.map +1 -0
  53. package/dist/scaffold/init.d.ts +3 -0
  54. package/dist/scaffold/init.d.ts.map +1 -0
  55. package/dist/scaffold/init.js +64 -0
  56. package/dist/scaffold/init.js.map +1 -0
  57. package/dist/scaffold/templates.d.ts +6 -0
  58. package/dist/scaffold/templates.d.ts.map +1 -0
  59. package/dist/scaffold/templates.js +164 -0
  60. package/dist/scaffold/templates.js.map +1 -0
  61. package/dist/sdd.d.ts +20 -0
  62. package/dist/sdd.d.ts.map +1 -0
  63. package/dist/sdd.js +110 -0
  64. package/dist/sdd.js.map +1 -0
  65. package/dist/types.d.ts +65 -0
  66. package/dist/types.d.ts.map +1 -0
  67. package/dist/types.js +3 -0
  68. package/dist/types.js.map +1 -0
  69. package/dist/validate/validator.d.ts +3 -0
  70. package/dist/validate/validator.d.ts.map +1 -0
  71. package/dist/validate/validator.js +44 -0
  72. package/dist/validate/validator.js.map +1 -0
  73. package/package.json +18 -0
  74. package/src/config/config-manager.ts +39 -0
  75. package/src/delta/delta-engine.ts +18 -0
  76. package/src/errors.ts +27 -0
  77. package/src/git/git.ts +113 -0
  78. package/src/index.ts +19 -0
  79. package/src/parser/cr-parser.ts +41 -0
  80. package/src/parser/frontmatter.ts +24 -0
  81. package/src/parser/ref-extractor.ts +14 -0
  82. package/src/parser/section-extractor.ts +38 -0
  83. package/src/parser/story-parser.ts +40 -0
  84. package/src/prompt/prompt-generator.ts +49 -0
  85. package/src/scaffold/init.ts +71 -0
  86. package/src/scaffold/templates.ts +166 -0
  87. package/src/sdd.ts +123 -0
  88. package/src/types.ts +76 -0
  89. package/src/validate/validator.ts +46 -0
  90. package/tests/cr.test.ts +172 -0
  91. package/tests/delta.test.ts +94 -0
  92. package/tests/integration.test.ts +132 -0
  93. package/tests/parser.test.ts +92 -0
  94. package/tests/prompt.test.ts +57 -0
  95. package/tests/validator.test.ts +54 -0
  96. package/tsconfig.json +8 -0
@@ -0,0 +1,166 @@
1
+ const now = () => new Date().toISOString();
2
+
3
+ function mdTemplate(title: string, content: string): string {
4
+ return `---
5
+ title: "${title}"
6
+ status: new
7
+ author: ""
8
+ last-modified: "${now()}"
9
+ version: "1.0"
10
+ ---
11
+
12
+ ${content}
13
+ `;
14
+ }
15
+
16
+ export interface ProjectInfo {
17
+ description: string;
18
+ }
19
+
20
+ export const AGENT_MD_TEMPLATE = `# SDD Project
21
+
22
+ This project uses **Story Driven Development (SDD)**.
23
+ Documentation drives implementation: read the docs first, then write code.
24
+
25
+ ## Workflow
26
+
27
+ 1. Run \`sdd cr pending\` — check if there are change requests to process first
28
+ 2. If there are pending CRs, apply them to the docs, then run \`sdd mark-cr-applied\`
29
+ 3. Run \`sdd sync\` to see what needs to be implemented
30
+ 4. Read the documentation files listed in the sync output
31
+ 5. Implement what each file describes, writing code inside \`code/\`
32
+ 6. After implementing, mark files as synced:
33
+
34
+ \`\`\`
35
+ sdd mark-synced product/features/auth.md
36
+ \`\`\`
37
+
38
+ Or mark all pending files at once:
39
+
40
+ \`\`\`
41
+ sdd mark-synced
42
+ \`\`\`
43
+
44
+ 7. **Commit immediately after mark-synced** — this is mandatory:
45
+
46
+ \`\`\`
47
+ git add -A && git commit -m "sdd sync: <brief description of what was implemented>"
48
+ \`\`\`
49
+
50
+ Do NOT skip this step. Every mark-synced must be followed by a git commit.
51
+
52
+ ### Removing a feature
53
+
54
+ If a documentation file has \`status: deleted\`, it means that feature should be removed.
55
+ Delete the related code in \`code/\`, then run \`sdd mark-synced <file>\` (the doc file will be removed automatically), then commit.
56
+
57
+ ## Available commands
58
+
59
+ - \`sdd status\` — See all documentation files and their state (new/changed/deleted/synced)
60
+ - \`sdd diff\` — See what changed since last sync
61
+ - \`sdd sync\` — Get the sync prompt for pending files (new/changed/deleted)
62
+ - \`sdd validate\` — Check for broken references and issues
63
+ - \`sdd mark-synced [files...]\` — Mark specific files (or all) as synced
64
+ - \`sdd cr list\` — List all change requests with their status
65
+ - \`sdd cr pending\` — Show draft change requests to process
66
+ - \`sdd mark-cr-applied [files...]\` — Mark change requests as applied
67
+
68
+ ## Rules
69
+
70
+ 1. **Always commit after mark-synced** — run \`git add -A && git commit -m "sdd sync: ..."\` immediately after \`sdd mark-synced\`. Never leave synced files uncommitted.
71
+ 2. Before running \`sdd sync\`, check for pending change requests with \`sdd cr pending\`
72
+ 3. If there are pending CRs, apply them to the docs first, then mark them with \`sdd mark-cr-applied\`
73
+ 4. Only implement what the sync prompt asks for
74
+ 5. All generated code goes inside \`code/\`
75
+ 6. Respect all constraints in \`## Agent Notes\` sections (if present)
76
+ 7. Do not edit files inside \`.sdd/\` manually
77
+
78
+ ## File format
79
+
80
+ Every \`.md\` file in \`product/\` and \`system/\` must start with this YAML frontmatter:
81
+
82
+ \`\`\`yaml
83
+ ---
84
+ title: "File title"
85
+ status: new
86
+ author: ""
87
+ last-modified: "2025-01-01T00:00:00.000Z"
88
+ version: "1.0"
89
+ ---
90
+ \`\`\`
91
+
92
+ - **status**: one of:
93
+ - \`new\` — new file, needs to be implemented
94
+ - \`changed\` — modified since last sync, code needs updating
95
+ - \`deleted\` — feature to be removed, agent should delete related code
96
+ - \`synced\` — already implemented, up to date
97
+ - **version**: patch-bump on each edit (1.0 → 1.1 → 1.2)
98
+ - **last-modified**: ISO 8601 datetime, updated on each edit
99
+
100
+ ## Change Requests
101
+
102
+ Change Requests (CRs) are markdown files in \`change-requests/\` that describe modifications to the documentation.
103
+
104
+ ### CR format
105
+
106
+ \`\`\`yaml
107
+ ---
108
+ title: "Add authentication feature"
109
+ status: draft
110
+ author: "user"
111
+ created-at: "2025-01-01T00:00:00.000Z"
112
+ ---
113
+ \`\`\`
114
+
115
+ - **status**: \`draft\` (pending) or \`applied\` (already processed)
116
+
117
+ ### CR workflow
118
+
119
+ 1. Check for pending CRs: \`sdd cr pending\`
120
+ 2. Read each pending CR and apply the described changes to the documentation files (marking them as \`new\`, \`changed\`, or \`deleted\`)
121
+ 3. After applying a CR to the docs, mark it: \`sdd mark-cr-applied change-requests/CR-001.md\`
122
+ 4. Then run \`sdd sync\` to implement the code changes
123
+
124
+ ### CR commands
125
+
126
+ - \`sdd cr list\` — See all change requests and their status
127
+ - \`sdd cr pending\` — Show only draft CRs to process
128
+ - \`sdd mark-cr-applied [files...]\` — Mark CRs as applied after updating the docs
129
+
130
+ ## UX and screenshots
131
+
132
+ When a feature has UX mockups or screenshots, place them next to the feature doc:
133
+
134
+ - **Simple feature** (no screenshots): \`product/features/auth.md\`
135
+ - **Feature with screenshots**: use a folder with \`index.md\`:
136
+
137
+ \`\`\`
138
+ product/features/auth/
139
+ index.md ← feature doc
140
+ login.png ← screenshot
141
+ register.png ← screenshot
142
+ \`\`\`
143
+
144
+ Reference images in the markdown with relative paths:
145
+
146
+ \`\`\`markdown
147
+ ## UX
148
+
149
+ ![Login screen](login.png)
150
+ ![Register screen](register.png)
151
+ \`\`\`
152
+
153
+ Both formats work — use a folder only when you have screenshots or multiple files for a feature.
154
+
155
+ ## Project structure
156
+
157
+ - \`product/\` — What to build (vision, users, features)
158
+ - \`system/\` — How to build it (entities, architecture, tech stack, interfaces)
159
+ - \`code/\` — All generated source code goes here
160
+ - \`change-requests/\` — Change requests to the documentation
161
+ - \`.sdd/\` — Project config and sync state (do not edit)
162
+ `;
163
+
164
+ export const EMPTY_LOCK_TEMPLATE = () => `synced-at: "${new Date().toISOString()}"
165
+ files: {}
166
+ `;
package/src/sdd.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import type { StoryStatus, ValidationResult, SDDConfig, ChangeRequest } from './types.js';
4
+ import { ProjectNotInitializedError } from './errors.js';
5
+ import { parseAllStoryFiles } from './parser/story-parser.js';
6
+ import { generatePrompt } from './prompt/prompt-generator.js';
7
+ import { validate } from './validate/validator.js';
8
+ import { initProject } from './scaffold/init.js';
9
+ import { isSDDProject, readConfig, writeConfig } from './config/config-manager.js';
10
+ import { parseAllCRFiles } from './parser/cr-parser.js';
11
+ import type { ProjectInfo } from './scaffold/templates.js';
12
+
13
+ export class SDD {
14
+ private root: string;
15
+
16
+ constructor(options: { root: string }) {
17
+ this.root = options.root;
18
+ }
19
+
20
+ async init(info?: ProjectInfo): Promise<string[]> {
21
+ return initProject(this.root, info);
22
+ }
23
+
24
+ async config(): Promise<SDDConfig> {
25
+ this.ensureInitialized();
26
+ return readConfig(this.root);
27
+ }
28
+
29
+ async status(): Promise<StoryStatus> {
30
+ this.ensureInitialized();
31
+ const files = await parseAllStoryFiles(this.root);
32
+
33
+ return {
34
+ files: files.map((f) => ({
35
+ relativePath: f.relativePath,
36
+ status: f.frontmatter.status,
37
+ version: f.frontmatter.version,
38
+ lastModified: f.frontmatter['last-modified'],
39
+ })),
40
+ };
41
+ }
42
+
43
+ async pending(): Promise<import('./types.js').StoryFile[]> {
44
+ this.ensureInitialized();
45
+ const files = await parseAllStoryFiles(this.root);
46
+ return files.filter((f) => f.frontmatter.status !== 'synced');
47
+ }
48
+
49
+ async sync(): Promise<string> {
50
+ const pending = await this.pending();
51
+ return generatePrompt(pending, this.root);
52
+ }
53
+
54
+ async validate(): Promise<ValidationResult> {
55
+ this.ensureInitialized();
56
+ const files = await parseAllStoryFiles(this.root);
57
+ return validate(files);
58
+ }
59
+
60
+ async markSynced(paths?: string[]): Promise<string[]> {
61
+ this.ensureInitialized();
62
+ const files = await parseAllStoryFiles(this.root);
63
+ const marked: string[] = [];
64
+
65
+ for (const file of files) {
66
+ const { status } = file.frontmatter;
67
+ if (status === 'synced') continue;
68
+ if (paths && paths.length > 0 && !paths.includes(file.relativePath)) continue;
69
+
70
+ const absPath = resolve(this.root, file.relativePath);
71
+
72
+ if (status === 'deleted') {
73
+ // File marked for deletion — remove it
74
+ const { unlink } = await import('node:fs/promises');
75
+ await unlink(absPath);
76
+ marked.push(`${file.relativePath} (removed)`);
77
+ } else {
78
+ // new or changed → synced
79
+ const content = await readFile(absPath, 'utf-8');
80
+ const updated = content.replace(/^status:\s*(new|changed)/m, 'status: synced');
81
+ await writeFile(absPath, updated, 'utf-8');
82
+ marked.push(file.relativePath);
83
+ }
84
+ }
85
+
86
+ return marked;
87
+ }
88
+
89
+ async changeRequests(): Promise<ChangeRequest[]> {
90
+ this.ensureInitialized();
91
+ return parseAllCRFiles(this.root);
92
+ }
93
+
94
+ async pendingChangeRequests(): Promise<ChangeRequest[]> {
95
+ const all = await this.changeRequests();
96
+ return all.filter((cr) => cr.frontmatter.status === 'draft');
97
+ }
98
+
99
+ async markCRApplied(paths?: string[]): Promise<string[]> {
100
+ this.ensureInitialized();
101
+ const all = await this.changeRequests();
102
+ const marked: string[] = [];
103
+
104
+ for (const cr of all) {
105
+ if (cr.frontmatter.status === 'applied') continue;
106
+ if (paths && paths.length > 0 && !paths.includes(cr.relativePath)) continue;
107
+
108
+ const absPath = resolve(this.root, cr.relativePath);
109
+ const content = await readFile(absPath, 'utf-8');
110
+ const updated = content.replace(/^status:\s*draft/m, 'status: applied');
111
+ await writeFile(absPath, updated, 'utf-8');
112
+ marked.push(cr.relativePath);
113
+ }
114
+
115
+ return marked;
116
+ }
117
+
118
+ private ensureInitialized(): void {
119
+ if (!isSDDProject(this.root)) {
120
+ throw new ProjectNotInitializedError(this.root);
121
+ }
122
+ }
123
+ }
package/src/types.ts ADDED
@@ -0,0 +1,76 @@
1
+ export type StoryFileStatus = 'new' | 'changed' | 'deleted' | 'synced';
2
+
3
+ export interface StoryFrontmatter {
4
+ title: string;
5
+ status: StoryFileStatus;
6
+ author: string;
7
+ 'last-modified': string;
8
+ version: string;
9
+ }
10
+
11
+ export interface StoryFile {
12
+ relativePath: string;
13
+ frontmatter: StoryFrontmatter;
14
+ body: string;
15
+ pendingItems: PendingItem[];
16
+ agentNotes: string | null;
17
+ crossRefs: string[];
18
+ hash: string;
19
+ }
20
+
21
+ export interface PendingItem {
22
+ text: string;
23
+ checked: boolean;
24
+ }
25
+
26
+ export interface Delta {
27
+ hasChanges: boolean;
28
+ files: DeltaFile[];
29
+ diff: string;
30
+ }
31
+
32
+ export interface DeltaFile {
33
+ relativePath: string;
34
+ status: 'modified' | 'new' | 'deleted';
35
+ }
36
+
37
+ export interface ValidationResult {
38
+ valid: boolean;
39
+ issues: ValidationIssue[];
40
+ }
41
+
42
+ export interface ValidationIssue {
43
+ severity: 'error' | 'warning';
44
+ filePath: string;
45
+ message: string;
46
+ rule: string;
47
+ }
48
+
49
+ export interface StoryStatus {
50
+ files: Array<{
51
+ relativePath: string;
52
+ status: 'new' | 'changed' | 'deleted' | 'synced';
53
+ version: string;
54
+ lastModified: string;
55
+ }>;
56
+ }
57
+
58
+ export interface SDDConfig {
59
+ description: string;
60
+ 'last-sync-commit'?: string;
61
+ }
62
+
63
+ export type ChangeRequestStatus = 'draft' | 'applied';
64
+
65
+ export interface ChangeRequestFrontmatter {
66
+ title: string;
67
+ status: ChangeRequestStatus;
68
+ author: string;
69
+ 'created-at': string;
70
+ }
71
+
72
+ export interface ChangeRequest {
73
+ relativePath: string;
74
+ frontmatter: ChangeRequestFrontmatter;
75
+ body: string;
76
+ }
@@ -0,0 +1,46 @@
1
+ import type { StoryFile, ValidationResult, ValidationIssue } from '../types.js';
2
+
3
+ export function validate(files: StoryFile[]): ValidationResult {
4
+ const issues: ValidationIssue[] = [];
5
+
6
+ // Collect known entity names from system/entities.md
7
+ const entityNames = new Set<string>();
8
+ const entitiesFile = files.find((f) => f.relativePath.endsWith('entities.md'));
9
+ if (entitiesFile) {
10
+ // Extract ### headings as entity names
11
+ const headingRe = /^### (.+)$/gm;
12
+ let match: RegExpExecArray | null;
13
+ while ((match = headingRe.exec(entitiesFile.body)) !== null) {
14
+ entityNames.add(match[1].trim());
15
+ }
16
+ }
17
+
18
+ for (const file of files) {
19
+ // Check broken cross-references
20
+ for (const ref of file.crossRefs) {
21
+ if (entityNames.size > 0 && !entityNames.has(ref)) {
22
+ issues.push({
23
+ severity: 'warning',
24
+ filePath: file.relativePath,
25
+ message: `Broken reference [[${ref}]] — not found in system/entities.md`,
26
+ rule: 'broken-ref',
27
+ });
28
+ }
29
+ }
30
+
31
+ // Check frontmatter has required fields
32
+ if (!file.frontmatter.title) {
33
+ issues.push({
34
+ severity: 'warning',
35
+ filePath: file.relativePath,
36
+ message: 'Missing "title" in frontmatter',
37
+ rule: 'missing-frontmatter',
38
+ });
39
+ }
40
+ }
41
+
42
+ return {
43
+ valid: issues.filter((i) => i.severity === 'error').length === 0,
44
+ issues,
45
+ };
46
+ }
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { SDD } from '../src/sdd.js';
7
+ import { parseCRFile, discoverCRFiles } from '../src/parser/cr-parser.js';
8
+
9
+ const CR_DRAFT = `---
10
+ title: "Add authentication"
11
+ status: draft
12
+ author: "user"
13
+ created-at: "2025-01-01T00:00:00.000Z"
14
+ ---
15
+
16
+ ## Description
17
+
18
+ Add JWT-based authentication to the API.
19
+
20
+ ## Changes
21
+
22
+ - Create \`product/features/auth.md\` with login/logout flows
23
+ - Update \`system/entities.md\` to add User entity
24
+ `;
25
+
26
+ const CR_APPLIED = `---
27
+ title: "Fix navigation"
28
+ status: applied
29
+ author: "user"
30
+ created-at: "2025-01-02T00:00:00.000Z"
31
+ ---
32
+
33
+ ## Description
34
+
35
+ Fix the navigation bar layout.
36
+ `;
37
+
38
+ describe('CR parser', () => {
39
+ it('parses CR frontmatter correctly', () => {
40
+ const result = parseCRFile('change-requests/CR-001.md', CR_DRAFT);
41
+ expect(result.frontmatter.title).toBe('Add authentication');
42
+ expect(result.frontmatter.status).toBe('draft');
43
+ expect(result.frontmatter.author).toBe('user');
44
+ expect(result.frontmatter['created-at']).toBe('2025-01-01T00:00:00.000Z');
45
+ expect(result.body).toContain('## Description');
46
+ expect(result.body).toContain('JWT-based authentication');
47
+ });
48
+
49
+ it('provides defaults for missing fields', () => {
50
+ const content = `---
51
+ title: "Minimal CR"
52
+ ---
53
+
54
+ Some body.
55
+ `;
56
+ const result = parseCRFile('test.md', content);
57
+ expect(result.frontmatter.status).toBe('draft');
58
+ expect(result.frontmatter.author).toBe('');
59
+ expect(result.frontmatter['created-at']).toBe('');
60
+ });
61
+ });
62
+
63
+ describe('CR file discovery', () => {
64
+ let tempDir: string;
65
+
66
+ beforeEach(async () => {
67
+ tempDir = await mkdtemp(join(tmpdir(), 'sdd-cr-discovery-'));
68
+ await mkdir(join(tempDir, 'change-requests'), { recursive: true });
69
+ });
70
+
71
+ afterEach(async () => {
72
+ await rm(tempDir, { recursive: true });
73
+ });
74
+
75
+ it('discovers .md files in change-requests/', async () => {
76
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_DRAFT, 'utf-8');
77
+ await writeFile(join(tempDir, 'change-requests/CR-002.md'), CR_APPLIED, 'utf-8');
78
+
79
+ const files = await discoverCRFiles(tempDir);
80
+ expect(files).toHaveLength(2);
81
+ });
82
+
83
+ it('returns empty array when no CR files exist', async () => {
84
+ const files = await discoverCRFiles(tempDir);
85
+ expect(files).toHaveLength(0);
86
+ });
87
+ });
88
+
89
+ describe('SDD CR methods', () => {
90
+ let tempDir: string;
91
+ let sdd: SDD;
92
+
93
+ beforeEach(async () => {
94
+ tempDir = await mkdtemp(join(tmpdir(), 'sdd-cr-'));
95
+ sdd = new SDD({ root: tempDir });
96
+ await sdd.init({ description: 'test' });
97
+ });
98
+
99
+ afterEach(async () => {
100
+ await rm(tempDir, { recursive: true });
101
+ });
102
+
103
+ it('changeRequests() returns all CRs', async () => {
104
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_DRAFT, 'utf-8');
105
+ await writeFile(join(tempDir, 'change-requests/CR-002.md'), CR_APPLIED, 'utf-8');
106
+
107
+ const crs = await sdd.changeRequests();
108
+ expect(crs).toHaveLength(2);
109
+ expect(crs[0].frontmatter.title).toBe('Add authentication');
110
+ expect(crs[1].frontmatter.title).toBe('Fix navigation');
111
+ });
112
+
113
+ it('pendingChangeRequests() returns only draft CRs', async () => {
114
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_DRAFT, 'utf-8');
115
+ await writeFile(join(tempDir, 'change-requests/CR-002.md'), CR_APPLIED, 'utf-8');
116
+
117
+ const pending = await sdd.pendingChangeRequests();
118
+ expect(pending).toHaveLength(1);
119
+ expect(pending[0].frontmatter.status).toBe('draft');
120
+ expect(pending[0].frontmatter.title).toBe('Add authentication');
121
+ });
122
+
123
+ it('markCRApplied() changes draft to applied', async () => {
124
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_DRAFT, 'utf-8');
125
+
126
+ const marked = await sdd.markCRApplied(['change-requests/CR-001.md']);
127
+ expect(marked).toEqual(['change-requests/CR-001.md']);
128
+
129
+ const content = await readFile(join(tempDir, 'change-requests/CR-001.md'), 'utf-8');
130
+ expect(content).toContain('status: applied');
131
+ });
132
+
133
+ it('markCRApplied() without args marks all draft CRs', async () => {
134
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_DRAFT, 'utf-8');
135
+ const draft2 = CR_DRAFT.replace('Add authentication', 'Second CR');
136
+ await writeFile(join(tempDir, 'change-requests/CR-002.md'), draft2, 'utf-8');
137
+
138
+ const marked = await sdd.markCRApplied();
139
+ expect(marked).toHaveLength(2);
140
+ });
141
+
142
+ it('markCRApplied() skips already applied CRs', async () => {
143
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_APPLIED, 'utf-8');
144
+
145
+ const marked = await sdd.markCRApplied();
146
+ expect(marked).toHaveLength(0);
147
+ });
148
+
149
+ it('integration: create CR → pending → mark applied → no longer pending', async () => {
150
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_DRAFT, 'utf-8');
151
+
152
+ // Should be pending
153
+ let pending = await sdd.pendingChangeRequests();
154
+ expect(pending).toHaveLength(1);
155
+
156
+ // Mark as applied
157
+ await sdd.markCRApplied();
158
+
159
+ // Should no longer be pending
160
+ pending = await sdd.pendingChangeRequests();
161
+ expect(pending).toHaveLength(0);
162
+
163
+ // But still in the full list
164
+ const all = await sdd.changeRequests();
165
+ expect(all).toHaveLength(1);
166
+ expect(all[0].frontmatter.status).toBe('applied');
167
+ });
168
+
169
+ it('init creates change-requests/ directory', async () => {
170
+ expect(existsSync(join(tempDir, 'change-requests'))).toBe(true);
171
+ });
172
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+ import { computeDelta } from '../src/delta/delta-engine.js';
7
+
8
+ function git(cmd: string, cwd: string): string {
9
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
10
+ }
11
+
12
+ describe('computeDelta', () => {
13
+ let tempDir: string;
14
+
15
+ beforeEach(async () => {
16
+ tempDir = await mkdtemp(join(tmpdir(), 'sdd-delta-test-'));
17
+ git('init', tempDir);
18
+ git('config user.email "test@test.com"', tempDir);
19
+ git('config user.name "Test"', tempDir);
20
+ await mkdir(join(tempDir, 'product'), { recursive: true });
21
+ await mkdir(join(tempDir, 'system'), { recursive: true });
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await rm(tempDir, { recursive: true });
26
+ });
27
+
28
+ it('detects new files when no previous sync', async () => {
29
+ await writeFile(join(tempDir, 'product/vision.md'), '# Vision', 'utf-8');
30
+ git('add .', tempDir);
31
+ git('commit -m "init"', tempDir);
32
+
33
+ const delta = computeDelta(tempDir, null);
34
+ expect(delta.hasChanges).toBe(true);
35
+ expect(delta.files).toHaveLength(1);
36
+ expect(delta.files[0].relativePath).toBe('product/vision.md');
37
+ expect(delta.files[0].status).toBe('new');
38
+ });
39
+
40
+ it('detects modified files since last sync commit', async () => {
41
+ await writeFile(join(tempDir, 'product/vision.md'), '# Vision v1', 'utf-8');
42
+ git('add .', tempDir);
43
+ git('commit -m "init"', tempDir);
44
+ const syncCommit = git('rev-parse HEAD', tempDir);
45
+
46
+ await writeFile(join(tempDir, 'product/vision.md'), '# Vision v2', 'utf-8');
47
+ git('add .', tempDir);
48
+ git('commit -m "update"', tempDir);
49
+
50
+ const delta = computeDelta(tempDir, syncCommit);
51
+ expect(delta.hasChanges).toBe(true);
52
+ expect(delta.files[0].status).toBe('modified');
53
+ });
54
+
55
+ it('detects deleted files since last sync commit', async () => {
56
+ await writeFile(join(tempDir, 'product/old.md'), '# Old', 'utf-8');
57
+ git('add .', tempDir);
58
+ git('commit -m "init"', tempDir);
59
+ const syncCommit = git('rev-parse HEAD', tempDir);
60
+
61
+ git('rm product/old.md', tempDir);
62
+ git('commit -m "delete"', tempDir);
63
+
64
+ const delta = computeDelta(tempDir, syncCommit);
65
+ expect(delta.hasChanges).toBe(true);
66
+ expect(delta.files[0].status).toBe('deleted');
67
+ });
68
+
69
+ it('reports no changes when nothing changed since sync', async () => {
70
+ await writeFile(join(tempDir, 'product/vision.md'), '# Vision', 'utf-8');
71
+ git('add .', tempDir);
72
+ git('commit -m "init"', tempDir);
73
+ const syncCommit = git('rev-parse HEAD', tempDir);
74
+
75
+ const delta = computeDelta(tempDir, syncCommit);
76
+ expect(delta.hasChanges).toBe(false);
77
+ expect(delta.files).toHaveLength(0);
78
+ });
79
+
80
+ it('includes diff text', async () => {
81
+ await writeFile(join(tempDir, 'product/vision.md'), '# Vision v1', 'utf-8');
82
+ git('add .', tempDir);
83
+ git('commit -m "init"', tempDir);
84
+ const syncCommit = git('rev-parse HEAD', tempDir);
85
+
86
+ await writeFile(join(tempDir, 'product/vision.md'), '# Vision v2', 'utf-8');
87
+ git('add .', tempDir);
88
+ git('commit -m "update"', tempDir);
89
+
90
+ const delta = computeDelta(tempDir, syncCommit);
91
+ expect(delta.diff).toContain('Vision v1');
92
+ expect(delta.diff).toContain('Vision v2');
93
+ });
94
+ });