@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.
- package/dist/config/config-manager.d.ts +9 -0
- package/dist/config/config-manager.d.ts.map +1 -0
- package/dist/config/config-manager.js +44 -0
- package/dist/config/config-manager.js.map +1 -0
- package/dist/delta/delta-engine.d.ts +3 -0
- package/dist/delta/delta-engine.d.ts.map +1 -0
- package/dist/delta/delta-engine.js +18 -0
- package/dist/delta/delta-engine.js.map +1 -0
- package/dist/delta/hasher.d.ts +2 -0
- package/dist/delta/hasher.d.ts.map +1 -0
- package/dist/delta/hasher.js +8 -0
- package/dist/delta/hasher.js.map +1 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +32 -0
- package/dist/errors.js.map +1 -0
- package/dist/git/git.d.ts +12 -0
- package/dist/git/git.d.ts.map +1 -0
- package/dist/git/git.js +125 -0
- package/dist/git/git.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/lock/lock-manager.d.ts +6 -0
- package/dist/lock/lock-manager.d.ts.map +1 -0
- package/dist/lock/lock-manager.js +39 -0
- package/dist/lock/lock-manager.js.map +1 -0
- package/dist/parser/cr-parser.d.ts +8 -0
- package/dist/parser/cr-parser.d.ts.map +1 -0
- package/dist/parser/cr-parser.js +45 -0
- package/dist/parser/cr-parser.js.map +1 -0
- package/dist/parser/frontmatter.d.ts +7 -0
- package/dist/parser/frontmatter.d.ts.map +1 -0
- package/dist/parser/frontmatter.js +25 -0
- package/dist/parser/frontmatter.js.map +1 -0
- package/dist/parser/ref-extractor.d.ts +2 -0
- package/dist/parser/ref-extractor.d.ts.map +1 -0
- package/dist/parser/ref-extractor.js +17 -0
- package/dist/parser/ref-extractor.js.map +1 -0
- package/dist/parser/section-extractor.d.ts +4 -0
- package/dist/parser/section-extractor.d.ts.map +1 -0
- package/dist/parser/section-extractor.js +37 -0
- package/dist/parser/section-extractor.js.map +1 -0
- package/dist/parser/story-parser.d.ts +5 -0
- package/dist/parser/story-parser.d.ts.map +1 -0
- package/dist/parser/story-parser.js +41 -0
- package/dist/parser/story-parser.js.map +1 -0
- package/dist/prompt/prompt-generator.d.ts +3 -0
- package/dist/prompt/prompt-generator.d.ts.map +1 -0
- package/dist/prompt/prompt-generator.js +40 -0
- package/dist/prompt/prompt-generator.js.map +1 -0
- package/dist/scaffold/init.d.ts +3 -0
- package/dist/scaffold/init.d.ts.map +1 -0
- package/dist/scaffold/init.js +64 -0
- package/dist/scaffold/init.js.map +1 -0
- package/dist/scaffold/templates.d.ts +6 -0
- package/dist/scaffold/templates.d.ts.map +1 -0
- package/dist/scaffold/templates.js +164 -0
- package/dist/scaffold/templates.js.map +1 -0
- package/dist/sdd.d.ts +20 -0
- package/dist/sdd.d.ts.map +1 -0
- package/dist/sdd.js +110 -0
- package/dist/sdd.js.map +1 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validate/validator.d.ts +3 -0
- package/dist/validate/validator.d.ts.map +1 -0
- package/dist/validate/validator.js +44 -0
- package/dist/validate/validator.js.map +1 -0
- package/package.json +18 -0
- package/src/config/config-manager.ts +39 -0
- package/src/delta/delta-engine.ts +18 -0
- package/src/errors.ts +27 -0
- package/src/git/git.ts +113 -0
- package/src/index.ts +19 -0
- package/src/parser/cr-parser.ts +41 -0
- package/src/parser/frontmatter.ts +24 -0
- package/src/parser/ref-extractor.ts +14 -0
- package/src/parser/section-extractor.ts +38 -0
- package/src/parser/story-parser.ts +40 -0
- package/src/prompt/prompt-generator.ts +49 -0
- package/src/scaffold/init.ts +71 -0
- package/src/scaffold/templates.ts +166 -0
- package/src/sdd.ts +123 -0
- package/src/types.ts +76 -0
- package/src/validate/validator.ts +46 -0
- package/tests/cr.test.ts +172 -0
- package/tests/delta.test.ts +94 -0
- package/tests/integration.test.ts +132 -0
- package/tests/parser.test.ts +92 -0
- package/tests/prompt.test.ts +57 -0
- package/tests/validator.test.ts +54 -0
- 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
|
+

|
|
150
|
+

|
|
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
|
+
}
|
package/tests/cr.test.ts
ADDED
|
@@ -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
|
+
});
|