opensdd 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/LICENSE +21 -0
- package/bin/opensdd.js +2 -0
- package/opensdd/cli.md +633 -0
- package/opensdd/sdd-generate.md +209 -0
- package/opensdd/sdd-manager.md +134 -0
- package/opensdd/spec-format.md +494 -0
- package/package.json +31 -0
- package/src/commands/init.js +184 -0
- package/src/commands/install.js +161 -0
- package/src/commands/list.js +40 -0
- package/src/commands/publish.js +235 -0
- package/src/commands/status.js +91 -0
- package/src/commands/update.js +227 -0
- package/src/commands/updateApply.js +149 -0
- package/src/commands/validate.js +95 -0
- package/src/index.js +126 -0
- package/src/lib/manifest.js +38 -0
- package/src/lib/registry.js +176 -0
- package/src/lib/skills.js +204 -0
- package/src/lib/validation.js +122 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_REGISTRY = 'https://github.com/deepagents-ai/opensdd';
|
|
5
|
+
|
|
6
|
+
export function resolveRegistry(options, manifest) {
|
|
7
|
+
if (options?.registry) return options.registry;
|
|
8
|
+
if (manifest?.registry) return manifest.registry;
|
|
9
|
+
return DEFAULT_REGISTRY;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isLocalPath(source) {
|
|
13
|
+
return !source.startsWith('http://') && !source.startsWith('https://');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isGitHubUrl(source) {
|
|
17
|
+
return /github\.com/.test(source);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseGitHubUrl(url) {
|
|
21
|
+
const match = url.match(/github\.com\/([^/]+)\/([^/.]+)/);
|
|
22
|
+
if (!match) throw new Error(`Invalid GitHub URL: ${url}`);
|
|
23
|
+
return { owner: match[1], repo: match[2] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function githubApiFetch(urlPath, owner, repo) {
|
|
27
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/contents/${urlPath}`;
|
|
28
|
+
const headers = {
|
|
29
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
30
|
+
'User-Agent': 'opensdd-cli',
|
|
31
|
+
};
|
|
32
|
+
if (process.env.GITHUB_TOKEN) {
|
|
33
|
+
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
|
|
34
|
+
}
|
|
35
|
+
const response = await fetch(url, { headers });
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
if (response.status === 404) return null;
|
|
38
|
+
throw new Error(`GitHub API error: ${response.status} ${response.statusText} for ${url}`);
|
|
39
|
+
}
|
|
40
|
+
return response.json();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function githubRawFetch(filePath, owner, repo) {
|
|
44
|
+
const data = await githubApiFetch(filePath, owner, repo);
|
|
45
|
+
if (!data) return null;
|
|
46
|
+
if (data.content) {
|
|
47
|
+
return Buffer.from(data.content, 'base64').toString('utf-8');
|
|
48
|
+
}
|
|
49
|
+
// For larger files, use download_url
|
|
50
|
+
if (data.download_url) {
|
|
51
|
+
const response = await fetch(data.download_url, {
|
|
52
|
+
headers: { 'User-Agent': 'opensdd-cli' },
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok) throw new Error(`Failed to download ${filePath}`);
|
|
55
|
+
return response.text();
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Could not fetch content for ${filePath}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* List all specs available in the registry.
|
|
62
|
+
* Returns an array of index.json objects.
|
|
63
|
+
*/
|
|
64
|
+
export async function listRegistrySpecs(registrySource) {
|
|
65
|
+
if (isLocalPath(registrySource)) {
|
|
66
|
+
const registryDir = path.join(registrySource, 'registry');
|
|
67
|
+
if (!fs.existsSync(registryDir)) return [];
|
|
68
|
+
const entries = fs.readdirSync(registryDir, { withFileTypes: true });
|
|
69
|
+
const specs = [];
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
const indexPath = path.join(registryDir, entry.name, 'index.json');
|
|
73
|
+
if (fs.existsSync(indexPath)) {
|
|
74
|
+
try {
|
|
75
|
+
const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
76
|
+
specs.push(index);
|
|
77
|
+
} catch {
|
|
78
|
+
console.warn(`Warning: Malformed index.json for ${entry.name}, skipping.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return specs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// GitHub registry
|
|
87
|
+
const { owner, repo } = parseGitHubUrl(registrySource);
|
|
88
|
+
const contents = await githubApiFetch('registry', owner, repo);
|
|
89
|
+
if (!contents || !Array.isArray(contents)) return [];
|
|
90
|
+
|
|
91
|
+
const specs = [];
|
|
92
|
+
for (const item of contents) {
|
|
93
|
+
if (item.type === 'dir') {
|
|
94
|
+
try {
|
|
95
|
+
const indexContent = await githubRawFetch(`registry/${item.name}/index.json`, owner, repo);
|
|
96
|
+
if (indexContent) {
|
|
97
|
+
specs.push(JSON.parse(indexContent));
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
console.warn(`Warning: Could not read index.json for ${item.name}, skipping.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return specs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fetch index.json for a specific spec from the registry.
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchSpecIndex(registrySource, specName) {
|
|
111
|
+
if (isLocalPath(registrySource)) {
|
|
112
|
+
const indexPath = path.join(registrySource, 'registry', specName, 'index.json');
|
|
113
|
+
if (!fs.existsSync(indexPath)) return null;
|
|
114
|
+
return JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { owner, repo } = parseGitHubUrl(registrySource);
|
|
118
|
+
const content = await githubRawFetch(`registry/${specName}/index.json`, owner, repo);
|
|
119
|
+
if (!content) return null;
|
|
120
|
+
return JSON.parse(content);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Fetch manifest.json for a specific spec version from the registry.
|
|
125
|
+
*/
|
|
126
|
+
export async function fetchSpecManifest(registrySource, specName, version) {
|
|
127
|
+
if (isLocalPath(registrySource)) {
|
|
128
|
+
const manifestPath = path.join(registrySource, 'registry', specName, version, 'manifest.json');
|
|
129
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
130
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const { owner, repo } = parseGitHubUrl(registrySource);
|
|
134
|
+
const content = await githubRawFetch(`registry/${specName}/${version}/manifest.json`, owner, repo);
|
|
135
|
+
if (!content) return null;
|
|
136
|
+
return JSON.parse(content);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetch all files for a specific spec version from the registry.
|
|
141
|
+
* Returns an object mapping filename -> content string.
|
|
142
|
+
*/
|
|
143
|
+
export async function fetchSpecFiles(registrySource, specName, version) {
|
|
144
|
+
const files = {};
|
|
145
|
+
|
|
146
|
+
if (isLocalPath(registrySource)) {
|
|
147
|
+
const versionDir = path.join(registrySource, 'registry', specName, version);
|
|
148
|
+
if (!fs.existsSync(versionDir)) return null;
|
|
149
|
+
const entries = fs.readdirSync(versionDir, { withFileTypes: true });
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if (entry.isFile()) {
|
|
152
|
+
files[entry.name] = fs.readFileSync(path.join(versionDir, entry.name), 'utf-8');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return files;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// GitHub registry
|
|
159
|
+
const { owner, repo } = parseGitHubUrl(registrySource);
|
|
160
|
+
const contents = await githubApiFetch(`registry/${specName}/${version}`, owner, repo);
|
|
161
|
+
if (!contents || !Array.isArray(contents)) return null;
|
|
162
|
+
|
|
163
|
+
for (const item of contents) {
|
|
164
|
+
if (item.type === 'file') {
|
|
165
|
+
const content = await githubRawFetch(
|
|
166
|
+
`registry/${specName}/${version}/${item.name}`,
|
|
167
|
+
owner,
|
|
168
|
+
repo
|
|
169
|
+
);
|
|
170
|
+
if (content !== null) {
|
|
171
|
+
files[item.name] = content;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return files;
|
|
176
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
const OPENSDD_SECTION_START = '<!-- OpenSDD Skills (managed by opensdd init \u2014 do not edit this section) -->';
|
|
9
|
+
const OPENSDD_SECTION_END = '<!-- /OpenSDD Skills -->';
|
|
10
|
+
|
|
11
|
+
function getSkillContent() {
|
|
12
|
+
const opensddDir = path.resolve(__dirname, '../../opensdd');
|
|
13
|
+
return {
|
|
14
|
+
sddManager: fs.readFileSync(path.join(opensddDir, 'sdd-manager.md'), 'utf-8'),
|
|
15
|
+
sddGenerate: fs.readFileSync(path.join(opensddDir, 'sdd-generate.md'), 'utf-8'),
|
|
16
|
+
specFormat: fs.readFileSync(path.join(opensddDir, 'spec-format.md'), 'utf-8'),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ensureDir(dir) {
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeFileSync(filePath, content) {
|
|
25
|
+
ensureDir(path.dirname(filePath));
|
|
26
|
+
fs.writeFileSync(filePath, content);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Update a managed section in a file (GEMINI.md or AGENTS.md).
|
|
31
|
+
* Creates the file if it doesn't exist.
|
|
32
|
+
* Only modifies the clearly delimited OpenSDD section.
|
|
33
|
+
*/
|
|
34
|
+
function updateManagedSection(filePath, sectionBody) {
|
|
35
|
+
let content = '';
|
|
36
|
+
if (fs.existsSync(filePath)) {
|
|
37
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sectionContent = `${OPENSDD_SECTION_START}\n${sectionBody}\n${OPENSDD_SECTION_END}`;
|
|
41
|
+
|
|
42
|
+
const startIdx = content.indexOf(OPENSDD_SECTION_START);
|
|
43
|
+
const endIdx = content.indexOf(OPENSDD_SECTION_END);
|
|
44
|
+
|
|
45
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
46
|
+
// Replace existing section
|
|
47
|
+
content =
|
|
48
|
+
content.substring(0, startIdx) +
|
|
49
|
+
sectionContent +
|
|
50
|
+
content.substring(endIdx + OPENSDD_SECTION_END.length);
|
|
51
|
+
} else if (startIdx !== -1) {
|
|
52
|
+
// Start marker exists but no end marker — replace from start to end
|
|
53
|
+
content = content.substring(0, startIdx) + sectionContent;
|
|
54
|
+
} else {
|
|
55
|
+
// No existing section — append
|
|
56
|
+
if (content.length > 0 && !content.endsWith('\n')) {
|
|
57
|
+
content += '\n';
|
|
58
|
+
}
|
|
59
|
+
if (content.length > 0) {
|
|
60
|
+
content += '\n';
|
|
61
|
+
}
|
|
62
|
+
content += sectionContent + '\n';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fs.writeFileSync(filePath, content);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Install both skills (sdd-manager and sdd-generate) into all 6 supported agent formats.
|
|
70
|
+
* Returns an array of warnings for non-critical failures.
|
|
71
|
+
* Throws on critical failures (e.g., Claude Code installation fails).
|
|
72
|
+
*/
|
|
73
|
+
export function installSkills(projectRoot) {
|
|
74
|
+
const skills = getSkillContent();
|
|
75
|
+
const warnings = [];
|
|
76
|
+
|
|
77
|
+
// 1. Claude Code (critical — Gemini and Amp depend on this)
|
|
78
|
+
const claudeBase = path.join(projectRoot, '.claude', 'skills');
|
|
79
|
+
writeFileSync(
|
|
80
|
+
path.join(claudeBase, 'sdd-manager', 'SKILL.md'),
|
|
81
|
+
skills.sddManager
|
|
82
|
+
);
|
|
83
|
+
writeFileSync(
|
|
84
|
+
path.join(claudeBase, 'sdd-manager', 'references', 'spec-format.md'),
|
|
85
|
+
skills.specFormat
|
|
86
|
+
);
|
|
87
|
+
writeFileSync(
|
|
88
|
+
path.join(claudeBase, 'sdd-generate', 'SKILL.md'),
|
|
89
|
+
skills.sddGenerate
|
|
90
|
+
);
|
|
91
|
+
writeFileSync(
|
|
92
|
+
path.join(claudeBase, 'sdd-generate', 'references', 'spec-format.md'),
|
|
93
|
+
skills.specFormat
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// 2. Codex CLI
|
|
97
|
+
try {
|
|
98
|
+
const codexBase = path.join(projectRoot, '.agents', 'skills');
|
|
99
|
+
writeFileSync(
|
|
100
|
+
path.join(codexBase, 'sdd-manager', 'SKILL.md'),
|
|
101
|
+
skills.sddManager
|
|
102
|
+
);
|
|
103
|
+
writeFileSync(
|
|
104
|
+
path.join(codexBase, 'sdd-manager', 'references', 'spec-format.md'),
|
|
105
|
+
skills.specFormat
|
|
106
|
+
);
|
|
107
|
+
writeFileSync(
|
|
108
|
+
path.join(codexBase, 'sdd-generate', 'SKILL.md'),
|
|
109
|
+
skills.sddGenerate
|
|
110
|
+
);
|
|
111
|
+
writeFileSync(
|
|
112
|
+
path.join(codexBase, 'sdd-generate', 'references', 'spec-format.md'),
|
|
113
|
+
skills.specFormat
|
|
114
|
+
);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
warnings.push(`Could not install Codex CLI skills: ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 3. Cursor
|
|
120
|
+
try {
|
|
121
|
+
const cursorBase = path.join(projectRoot, '.cursor', 'rules');
|
|
122
|
+
ensureDir(cursorBase);
|
|
123
|
+
|
|
124
|
+
const sddManagerCursor = `---
|
|
125
|
+
description: "Implement, update, and verify installed OpenSDD dependency specs. Use when the user asks to implement a spec, process a spec update, check conformance, or create a deviation."
|
|
126
|
+
alwaysApply: false
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
${skills.sddManager}`;
|
|
130
|
+
|
|
131
|
+
const sddGenerateCursor = `---
|
|
132
|
+
description: "Generate an OpenSDD behavioral spec from existing code. Use when the user asks to generate, create, or extract a spec from a repository or codebase."
|
|
133
|
+
alwaysApply: false
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
${skills.sddGenerate}`;
|
|
137
|
+
|
|
138
|
+
const specFormatCursor = `---
|
|
139
|
+
description: "OpenSDD spec format reference. Defines the structure and rules for behavioral specifications. Referenced by sdd-manager and sdd-generate skills."
|
|
140
|
+
alwaysApply: false
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
${skills.specFormat}`;
|
|
144
|
+
|
|
145
|
+
writeFileSync(path.join(cursorBase, 'sdd-manager.md'), sddManagerCursor);
|
|
146
|
+
writeFileSync(path.join(cursorBase, 'sdd-generate.md'), sddGenerateCursor);
|
|
147
|
+
writeFileSync(path.join(cursorBase, 'opensdd-spec-format.md'), specFormatCursor);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
warnings.push(`Could not install Cursor skills: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4. GitHub Copilot
|
|
153
|
+
try {
|
|
154
|
+
const copilotBase = path.join(projectRoot, '.github', 'instructions');
|
|
155
|
+
ensureDir(copilotBase);
|
|
156
|
+
|
|
157
|
+
const copilotFrontmatter = `---
|
|
158
|
+
applyTo: "**"
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
`;
|
|
162
|
+
|
|
163
|
+
writeFileSync(
|
|
164
|
+
path.join(copilotBase, 'sdd-manager.instructions.md'),
|
|
165
|
+
copilotFrontmatter + skills.sddManager
|
|
166
|
+
);
|
|
167
|
+
writeFileSync(
|
|
168
|
+
path.join(copilotBase, 'sdd-generate.instructions.md'),
|
|
169
|
+
copilotFrontmatter + skills.sddGenerate
|
|
170
|
+
);
|
|
171
|
+
writeFileSync(
|
|
172
|
+
path.join(copilotBase, 'opensdd-spec-format.instructions.md'),
|
|
173
|
+
copilotFrontmatter + skills.specFormat
|
|
174
|
+
);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
warnings.push(`Could not install GitHub Copilot skills: ${err.message}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 5. Gemini CLI
|
|
180
|
+
try {
|
|
181
|
+
const geminiPath = path.join(projectRoot, 'GEMINI.md');
|
|
182
|
+
const geminiBody = `@.claude/skills/sdd-manager/SKILL.md
|
|
183
|
+
@.claude/skills/sdd-manager/references/spec-format.md
|
|
184
|
+
@.claude/skills/sdd-generate/SKILL.md
|
|
185
|
+
@.claude/skills/sdd-generate/references/spec-format.md`;
|
|
186
|
+
updateManagedSection(geminiPath, geminiBody);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
warnings.push(`Could not install Gemini CLI skills: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 6. Amp
|
|
192
|
+
try {
|
|
193
|
+
const ampPath = path.join(projectRoot, 'AGENTS.md');
|
|
194
|
+
const ampBody = `@.claude/skills/sdd-manager/SKILL.md
|
|
195
|
+
@.claude/skills/sdd-manager/references/spec-format.md
|
|
196
|
+
@.claude/skills/sdd-generate/SKILL.md
|
|
197
|
+
@.claude/skills/sdd-generate/references/spec-format.md`;
|
|
198
|
+
updateManagedSection(ampPath, ampBody);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
warnings.push(`Could not install Amp skills: ${err.message}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return warnings;
|
|
204
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate a spec directory for conformance to the OpenSDD spec-format.
|
|
6
|
+
* Returns an object with errors, warnings, and detailed results per file.
|
|
7
|
+
*/
|
|
8
|
+
export function validateSpec(specDir) {
|
|
9
|
+
const result = {
|
|
10
|
+
errors: [],
|
|
11
|
+
warnings: [],
|
|
12
|
+
specErrors: [],
|
|
13
|
+
specWarnings: [],
|
|
14
|
+
manifestErrors: [],
|
|
15
|
+
manifestExists: false,
|
|
16
|
+
hasDeviations: false,
|
|
17
|
+
manifest: null,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Check for spec.md
|
|
21
|
+
const specMdPath = path.join(specDir, 'spec.md');
|
|
22
|
+
if (!fs.existsSync(specMdPath)) {
|
|
23
|
+
result.specErrors.push('Missing required file: spec.md');
|
|
24
|
+
result.errors.push('Missing required file: spec.md');
|
|
25
|
+
} else {
|
|
26
|
+
const content = fs.readFileSync(specMdPath, 'utf-8');
|
|
27
|
+
validateSpecMd(content, result);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check for manifest.json (optional, but validate if present)
|
|
31
|
+
const manifestPath = path.join(specDir, 'manifest.json');
|
|
32
|
+
if (fs.existsSync(manifestPath)) {
|
|
33
|
+
result.manifestExists = true;
|
|
34
|
+
try {
|
|
35
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
36
|
+
result.manifest = manifest;
|
|
37
|
+
validateManifest(manifest, result);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
result.manifestErrors.push(`Malformed JSON: ${err.message}`);
|
|
40
|
+
result.errors.push('manifest.json: Malformed JSON');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check for deviations.md (specs for registry MUST NOT contain deviations)
|
|
45
|
+
if (fs.existsSync(path.join(specDir, 'deviations.md'))) {
|
|
46
|
+
result.hasDeviations = true;
|
|
47
|
+
result.errors.push('deviations.md found (specs for the registry must not contain deviations)');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateSpecMd(content, result) {
|
|
54
|
+
const lines = content.split('\n');
|
|
55
|
+
|
|
56
|
+
// Check for H1 header — spec says "MUST start with an H1 header"
|
|
57
|
+
// Find the first non-empty line
|
|
58
|
+
const firstContentIndex = lines.findIndex((l) => l.trim().length > 0);
|
|
59
|
+
const firstContentLine = firstContentIndex !== -1 ? lines[firstContentIndex] : '';
|
|
60
|
+
|
|
61
|
+
if (!/^# .+/.test(firstContentLine)) {
|
|
62
|
+
result.specErrors.push('Missing required: H1 header with blockquote summary');
|
|
63
|
+
result.errors.push('spec.md: Missing H1 header with blockquote summary');
|
|
64
|
+
} else {
|
|
65
|
+
// Check for blockquote summary after H1
|
|
66
|
+
let foundBlockquote = false;
|
|
67
|
+
for (let i = firstContentIndex + 1; i < Math.min(firstContentIndex + 5, lines.length); i++) {
|
|
68
|
+
if (lines[i].startsWith('> ')) {
|
|
69
|
+
foundBlockquote = true;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!foundBlockquote) {
|
|
74
|
+
result.specErrors.push('Missing required: blockquote summary after H1 header');
|
|
75
|
+
result.errors.push('spec.md: Missing blockquote summary after H1');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check for Behavioral Contract section
|
|
80
|
+
if (!content.includes('## Behavioral Contract')) {
|
|
81
|
+
result.specErrors.push('Missing required: ## Behavioral Contract section');
|
|
82
|
+
result.errors.push('spec.md: Missing Behavioral Contract section');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// SHOULD warnings for recommended sections
|
|
86
|
+
if (!content.includes('## NOT Specified')) {
|
|
87
|
+
result.specWarnings.push('Missing ## NOT Specified section (recommended)');
|
|
88
|
+
result.warnings.push('spec.md: Missing NOT Specified section');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!content.includes('## Invariants')) {
|
|
92
|
+
result.specWarnings.push('Missing ## Invariants section (recommended)');
|
|
93
|
+
result.warnings.push('spec.md: Missing Invariants section');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!content.includes('## Edge Cases')) {
|
|
97
|
+
result.specWarnings.push('Missing ## Edge Cases section (recommended)');
|
|
98
|
+
result.warnings.push('spec.md: Missing Edge Cases section');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateManifest(manifest, result) {
|
|
103
|
+
if (!manifest.name) {
|
|
104
|
+
result.manifestErrors.push('Missing required field: name');
|
|
105
|
+
result.errors.push('manifest.json: Missing name');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!manifest.spec_format) {
|
|
109
|
+
result.manifestErrors.push('Missing required field: spec_format');
|
|
110
|
+
result.errors.push('manifest.json: Missing spec_format');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!manifest.version) {
|
|
114
|
+
result.manifestErrors.push('Missing required field: version');
|
|
115
|
+
result.errors.push('manifest.json: Missing version');
|
|
116
|
+
} else if (!/^\d+\.\d+\.\d+/.test(manifest.version)) {
|
|
117
|
+
result.manifestErrors.push(
|
|
118
|
+
`Invalid version format: ${manifest.version} (must be valid semver)`
|
|
119
|
+
);
|
|
120
|
+
result.errors.push('manifest.json: Invalid version format');
|
|
121
|
+
}
|
|
122
|
+
}
|