@fission-ai/openspec 1.3.0 → 1.3.1
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/README.md +0 -4
- package/dist/commands/workflow/instructions.js +18 -72
- package/dist/commands/workflow/shared.d.ts +1 -1
- package/dist/commands/workflow/status.js +5 -5
- package/dist/commands/workflow/templates.js +5 -4
- package/dist/core/artifact-graph/index.d.ts +1 -0
- package/dist/core/artifact-graph/index.js +1 -0
- package/dist/core/artifact-graph/instruction-loader.js +6 -4
- package/dist/core/artifact-graph/outputs.d.ts +14 -0
- package/dist/core/artifact-graph/outputs.js +39 -0
- package/dist/core/artifact-graph/state.js +2 -25
- package/dist/core/parsers/change-parser.js +7 -3
- package/dist/core/parsers/markdown-parser.d.ts +4 -0
- package/dist/core/parsers/markdown-parser.js +41 -1
- package/dist/core/parsers/spec-structure.d.ts +9 -0
- package/dist/core/parsers/spec-structure.js +88 -0
- package/dist/core/specs-apply.js +8 -0
- package/dist/core/templates/workflows/apply-change.js +4 -4
- package/dist/core/templates/workflows/verify-change.js +6 -6
- package/dist/core/validation/validator.js +9 -0
- package/dist/telemetry/config.d.ts +7 -1
- package/dist/telemetry/config.js +87 -19
- package/dist/telemetry/index.js +19 -0
- package/dist/utils/file-system.d.ts +5 -0
- package/dist/utils/file-system.js +21 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,10 +42,6 @@ Our philosophy:
|
|
|
42
42
|
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/YctCnvvshC">OpenSpec Discord</a> for help and questions.
|
|
43
43
|
</p>
|
|
44
44
|
|
|
45
|
-
### Teams
|
|
46
|
-
|
|
47
|
-
Using OpenSpec in a team? [Email here](mailto:teams@openspec.dev) for access to our Slack channel.
|
|
48
|
-
|
|
49
45
|
<!-- TODO: Add GIF demo of /opsx:propose → /opsx:archive workflow -->
|
|
50
46
|
|
|
51
47
|
## See it in action
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import ora from 'ora';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
|
-
import { loadChangeContext, generateInstructions, resolveSchema, } from '../../core/artifact-graph/index.js';
|
|
10
|
+
import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
|
|
11
11
|
import { validateChangeExists, validateSchemaExists, } from './shared.js';
|
|
12
12
|
// -----------------------------------------------------------------------------
|
|
13
13
|
// Artifact Instructions Command
|
|
14
14
|
// -----------------------------------------------------------------------------
|
|
15
15
|
export async function instructionsCommand(artifactId, options) {
|
|
16
|
-
const spinner = ora('Generating instructions...').start();
|
|
16
|
+
const spinner = options.json ? undefined : ora('Generating instructions...').start();
|
|
17
17
|
try {
|
|
18
18
|
const projectRoot = process.cwd();
|
|
19
19
|
const changeName = await validateChangeExists(options.change, projectRoot);
|
|
@@ -24,19 +24,19 @@ export async function instructionsCommand(artifactId, options) {
|
|
|
24
24
|
// loadChangeContext will auto-detect schema from metadata if not provided
|
|
25
25
|
const context = loadChangeContext(projectRoot, changeName, options.schema);
|
|
26
26
|
if (!artifactId) {
|
|
27
|
-
spinner
|
|
27
|
+
spinner?.stop();
|
|
28
28
|
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
|
|
29
29
|
throw new Error(`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`);
|
|
30
30
|
}
|
|
31
31
|
const artifact = context.graph.getArtifact(artifactId);
|
|
32
32
|
if (!artifact) {
|
|
33
|
-
spinner
|
|
33
|
+
spinner?.stop();
|
|
34
34
|
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
|
|
35
35
|
throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`);
|
|
36
36
|
}
|
|
37
37
|
const instructions = generateInstructions(context, artifactId, projectRoot);
|
|
38
38
|
const isBlocked = instructions.dependencies.some((d) => !d.done);
|
|
39
|
-
spinner
|
|
39
|
+
spinner?.stop();
|
|
40
40
|
if (options.json) {
|
|
41
41
|
console.log(JSON.stringify(instructions, null, 2));
|
|
42
42
|
return;
|
|
@@ -44,7 +44,7 @@ export async function instructionsCommand(artifactId, options) {
|
|
|
44
44
|
printInstructionsText(instructions, isBlocked);
|
|
45
45
|
}
|
|
46
46
|
catch (error) {
|
|
47
|
-
spinner
|
|
47
|
+
spinner?.stop();
|
|
48
48
|
throw error;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -161,63 +161,6 @@ function parseTasksFile(content) {
|
|
|
161
161
|
}
|
|
162
162
|
return tasks;
|
|
163
163
|
}
|
|
164
|
-
/**
|
|
165
|
-
* Checks if an artifact output exists in the change directory.
|
|
166
|
-
* Supports glob patterns (e.g., "specs/*.md") by verifying at least one matching file exists.
|
|
167
|
-
*/
|
|
168
|
-
function artifactOutputExists(changeDir, generates) {
|
|
169
|
-
// Normalize the generates path to use platform-specific separators
|
|
170
|
-
const normalizedGenerates = generates.split('/').join(path.sep);
|
|
171
|
-
const fullPath = path.join(changeDir, normalizedGenerates);
|
|
172
|
-
// If it's a glob pattern (contains ** or *), check for matching files
|
|
173
|
-
if (generates.includes('*')) {
|
|
174
|
-
// Extract the directory part before the glob pattern
|
|
175
|
-
const parts = normalizedGenerates.split(path.sep);
|
|
176
|
-
const dirParts = [];
|
|
177
|
-
let patternPart = '';
|
|
178
|
-
for (const part of parts) {
|
|
179
|
-
if (part.includes('*')) {
|
|
180
|
-
patternPart = part;
|
|
181
|
-
break;
|
|
182
|
-
}
|
|
183
|
-
dirParts.push(part);
|
|
184
|
-
}
|
|
185
|
-
const dirPath = path.join(changeDir, ...dirParts);
|
|
186
|
-
// Check if directory exists
|
|
187
|
-
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
|
188
|
-
return false;
|
|
189
|
-
}
|
|
190
|
-
// Extract expected extension from pattern (e.g., "*.md" -> ".md")
|
|
191
|
-
const extMatch = patternPart.match(/\*(\.[a-zA-Z0-9]+)$/);
|
|
192
|
-
const expectedExt = extMatch ? extMatch[1] : null;
|
|
193
|
-
// Recursively check for matching files
|
|
194
|
-
const hasMatchingFiles = (dir) => {
|
|
195
|
-
try {
|
|
196
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
197
|
-
for (const entry of entries) {
|
|
198
|
-
if (entry.isDirectory()) {
|
|
199
|
-
// For ** patterns, recurse into subdirectories
|
|
200
|
-
if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) {
|
|
201
|
-
return true;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
else if (entry.isFile()) {
|
|
205
|
-
// Check if file matches expected extension (or any file if no extension specified)
|
|
206
|
-
if (!expectedExt || entry.name.endsWith(expectedExt)) {
|
|
207
|
-
return true;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
return false;
|
|
214
|
-
}
|
|
215
|
-
return false;
|
|
216
|
-
};
|
|
217
|
-
return hasMatchingFiles(dirPath);
|
|
218
|
-
}
|
|
219
|
-
return fs.existsSync(fullPath);
|
|
220
|
-
}
|
|
221
164
|
/**
|
|
222
165
|
* Generates apply instructions for implementing tasks from a change.
|
|
223
166
|
* Schema-aware: reads apply phase configuration from schema to determine
|
|
@@ -226,7 +169,7 @@ function artifactOutputExists(changeDir, generates) {
|
|
|
226
169
|
export async function generateApplyInstructions(projectRoot, changeName, schemaName) {
|
|
227
170
|
// loadChangeContext will auto-detect schema from metadata if not provided
|
|
228
171
|
const context = loadChangeContext(projectRoot, changeName, schemaName);
|
|
229
|
-
const changeDir =
|
|
172
|
+
const changeDir = context.changeDir;
|
|
230
173
|
// Get the full schema to access the apply phase configuration
|
|
231
174
|
const schema = resolveSchema(context.schemaName, projectRoot);
|
|
232
175
|
const applyConfig = schema.apply;
|
|
@@ -239,15 +182,16 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
|
|
|
239
182
|
const missingArtifacts = [];
|
|
240
183
|
for (const artifactId of requiredArtifactIds) {
|
|
241
184
|
const artifact = schema.artifacts.find((a) => a.id === artifactId);
|
|
242
|
-
if (artifact &&
|
|
185
|
+
if (artifact && resolveArtifactOutputs(changeDir, artifact.generates).length === 0) {
|
|
243
186
|
missingArtifacts.push(artifactId);
|
|
244
187
|
}
|
|
245
188
|
}
|
|
246
189
|
// Build context files from all existing artifacts in schema
|
|
247
190
|
const contextFiles = {};
|
|
248
191
|
for (const artifact of schema.artifacts) {
|
|
249
|
-
|
|
250
|
-
|
|
192
|
+
const outputs = resolveArtifactOutputs(changeDir, artifact.generates);
|
|
193
|
+
if (outputs.length > 0) {
|
|
194
|
+
contextFiles[artifact.id] = outputs;
|
|
251
195
|
}
|
|
252
196
|
}
|
|
253
197
|
// Parse tasks if tracking file exists
|
|
@@ -310,7 +254,7 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
|
|
|
310
254
|
};
|
|
311
255
|
}
|
|
312
256
|
export async function applyInstructionsCommand(options) {
|
|
313
|
-
const spinner = ora('Generating apply instructions...').start();
|
|
257
|
+
const spinner = options.json ? undefined : ora('Generating apply instructions...').start();
|
|
314
258
|
try {
|
|
315
259
|
const projectRoot = process.cwd();
|
|
316
260
|
const changeName = await validateChangeExists(options.change, projectRoot);
|
|
@@ -320,7 +264,7 @@ export async function applyInstructionsCommand(options) {
|
|
|
320
264
|
}
|
|
321
265
|
// generateApplyInstructions uses loadChangeContext which auto-detects schema
|
|
322
266
|
const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema);
|
|
323
|
-
spinner
|
|
267
|
+
spinner?.stop();
|
|
324
268
|
if (options.json) {
|
|
325
269
|
console.log(JSON.stringify(instructions, null, 2));
|
|
326
270
|
return;
|
|
@@ -328,7 +272,7 @@ export async function applyInstructionsCommand(options) {
|
|
|
328
272
|
printApplyInstructionsText(instructions);
|
|
329
273
|
}
|
|
330
274
|
catch (error) {
|
|
331
|
-
spinner
|
|
275
|
+
spinner?.stop();
|
|
332
276
|
throw error;
|
|
333
277
|
}
|
|
334
278
|
}
|
|
@@ -349,8 +293,10 @@ export function printApplyInstructionsText(instructions) {
|
|
|
349
293
|
const contextFileEntries = Object.entries(contextFiles);
|
|
350
294
|
if (contextFileEntries.length > 0) {
|
|
351
295
|
console.log('### Context Files');
|
|
352
|
-
for (const [artifactId,
|
|
353
|
-
|
|
296
|
+
for (const [artifactId, filePaths] of contextFileEntries) {
|
|
297
|
+
for (const filePath of filePaths) {
|
|
298
|
+
console.log(`- ${artifactId}: ${filePath}`);
|
|
299
|
+
}
|
|
354
300
|
}
|
|
355
301
|
console.log();
|
|
356
302
|
}
|
|
@@ -11,7 +11,7 @@ import { validateChangeExists, validateSchemaExists, getAvailableChanges, getSta
|
|
|
11
11
|
// Command Implementation
|
|
12
12
|
// -----------------------------------------------------------------------------
|
|
13
13
|
export async function statusCommand(options) {
|
|
14
|
-
const spinner = ora('Loading change status...').start();
|
|
14
|
+
const spinner = options.json ? undefined : ora('Loading change status...').start();
|
|
15
15
|
try {
|
|
16
16
|
const projectRoot = process.cwd();
|
|
17
17
|
// Handle no-changes case gracefully — status is informational,
|
|
@@ -19,7 +19,7 @@ export async function statusCommand(options) {
|
|
|
19
19
|
if (!options.change) {
|
|
20
20
|
const available = await getAvailableChanges(projectRoot);
|
|
21
21
|
if (available.length === 0) {
|
|
22
|
-
spinner
|
|
22
|
+
spinner?.stop();
|
|
23
23
|
if (options.json) {
|
|
24
24
|
console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2));
|
|
25
25
|
return;
|
|
@@ -28,7 +28,7 @@ export async function statusCommand(options) {
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
// Changes exist but --change not provided
|
|
31
|
-
spinner
|
|
31
|
+
spinner?.stop();
|
|
32
32
|
throw new Error(`Missing required option --change. Available changes:\n ${available.join('\n ')}`);
|
|
33
33
|
}
|
|
34
34
|
const changeName = await validateChangeExists(options.change, projectRoot);
|
|
@@ -39,7 +39,7 @@ export async function statusCommand(options) {
|
|
|
39
39
|
// loadChangeContext will auto-detect schema from metadata if not provided
|
|
40
40
|
const context = loadChangeContext(projectRoot, changeName, options.schema);
|
|
41
41
|
const status = formatChangeStatus(context);
|
|
42
|
-
spinner
|
|
42
|
+
spinner?.stop();
|
|
43
43
|
if (options.json) {
|
|
44
44
|
console.log(JSON.stringify(status, null, 2));
|
|
45
45
|
return;
|
|
@@ -47,7 +47,7 @@ export async function statusCommand(options) {
|
|
|
47
47
|
printStatusText(status);
|
|
48
48
|
}
|
|
49
49
|
catch (error) {
|
|
50
|
-
spinner
|
|
50
|
+
spinner?.stop();
|
|
51
51
|
throw error;
|
|
52
52
|
}
|
|
53
53
|
}
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
import ora from 'ora';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { resolveSchema, getSchemaDir, ArtifactGraph, } from '../../core/artifact-graph/index.js';
|
|
9
|
+
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
9
10
|
import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js';
|
|
10
11
|
// -----------------------------------------------------------------------------
|
|
11
12
|
// Command Implementation
|
|
12
13
|
// -----------------------------------------------------------------------------
|
|
13
14
|
export async function templatesCommand(options) {
|
|
14
|
-
const spinner = ora('Loading templates...').start();
|
|
15
|
+
const spinner = options.json ? undefined : ora('Loading templates...').start();
|
|
15
16
|
try {
|
|
16
17
|
const projectRoot = process.cwd();
|
|
17
18
|
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);
|
|
@@ -40,10 +41,10 @@ export async function templatesCommand(options) {
|
|
|
40
41
|
}
|
|
41
42
|
const templates = graph.getAllArtifacts().map((artifact) => ({
|
|
42
43
|
artifactId: artifact.id,
|
|
43
|
-
templatePath: path.join(schemaDir, 'templates', artifact.template),
|
|
44
|
+
templatePath: FileSystemUtils.canonicalizeExistingPath(path.join(schemaDir, 'templates', artifact.template)),
|
|
44
45
|
source,
|
|
45
46
|
}));
|
|
46
|
-
spinner
|
|
47
|
+
spinner?.stop();
|
|
47
48
|
if (options.json) {
|
|
48
49
|
const output = {};
|
|
49
50
|
for (const t of templates) {
|
|
@@ -61,7 +62,7 @@ export async function templatesCommand(options) {
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
catch (error) {
|
|
64
|
-
spinner
|
|
65
|
+
spinner?.stop();
|
|
65
66
|
throw error;
|
|
66
67
|
}
|
|
67
68
|
}
|
|
@@ -2,6 +2,7 @@ export { ArtifactSchema, SchemaYamlSchema, type Artifact, type SchemaYaml, type
|
|
|
2
2
|
export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
|
|
3
3
|
export { ArtifactGraph } from './graph.js';
|
|
4
4
|
export { detectCompleted } from './state.js';
|
|
5
|
+
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
|
|
5
6
|
export { resolveSchema, listSchemas, listSchemasWithInfo, getSchemaDir, getPackageSchemasDir, getUserSchemasDir, SchemaLoadError, type SchemaInfo, } from './resolver.js';
|
|
6
7
|
export { loadTemplate, loadChangeContext, generateInstructions, formatChangeStatus, TemplateLoadError, type ChangeContext, type ArtifactInstructions, type DependencyInfo, type ArtifactStatus, type ChangeStatus, } from './instruction-loader.js';
|
|
7
8
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -6,6 +6,7 @@ export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
|
|
|
6
6
|
export { ArtifactGraph } from './graph.js';
|
|
7
7
|
// State detection
|
|
8
8
|
export { detectCompleted } from './state.js';
|
|
9
|
+
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
|
|
9
10
|
// Schema resolution
|
|
10
11
|
export { resolveSchema, listSchemas, listSchemasWithInfo, getSchemaDir, getPackageSchemasDir, getUserSchemasDir, SchemaLoadError, } from './resolver.js';
|
|
11
12
|
// Instruction loading
|
|
@@ -4,6 +4,7 @@ import { getSchemaDir, resolveSchema } from './resolver.js';
|
|
|
4
4
|
import { ArtifactGraph } from './graph.js';
|
|
5
5
|
import { detectCompleted } from './state.js';
|
|
6
6
|
import { resolveSchemaForChange } from '../../utils/change-metadata.js';
|
|
7
|
+
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
7
8
|
import { readProjectConfig, validateConfigRules } from '../project-config.js';
|
|
8
9
|
// Session-level cache for validation warnings (avoid repeating same warnings)
|
|
9
10
|
const shownWarnings = new Set();
|
|
@@ -32,10 +33,11 @@ export function loadTemplate(schemaName, templatePath, projectRoot) {
|
|
|
32
33
|
if (!schemaDir) {
|
|
33
34
|
throw new TemplateLoadError(`Schema '${schemaName}' not found`, templatePath);
|
|
34
35
|
}
|
|
35
|
-
const
|
|
36
|
-
if (!fs.existsSync(
|
|
37
|
-
throw new TemplateLoadError(`Template not found: ${
|
|
36
|
+
const templatePathOnDisk = path.join(schemaDir, 'templates', templatePath);
|
|
37
|
+
if (!fs.existsSync(templatePathOnDisk)) {
|
|
38
|
+
throw new TemplateLoadError(`Template not found: ${templatePathOnDisk}`, templatePathOnDisk);
|
|
38
39
|
}
|
|
40
|
+
const fullPath = FileSystemUtils.canonicalizeExistingPath(templatePathOnDisk);
|
|
39
41
|
try {
|
|
40
42
|
return fs.readFileSync(fullPath, 'utf-8');
|
|
41
43
|
}
|
|
@@ -58,7 +60,7 @@ export function loadTemplate(schemaName, templatePath, projectRoot) {
|
|
|
58
60
|
* @returns Change context with graph, completed set, and metadata
|
|
59
61
|
*/
|
|
60
62
|
export function loadChangeContext(projectRoot, changeName, schemaName) {
|
|
61
|
-
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
|
|
63
|
+
const changeDir = FileSystemUtils.canonicalizeExistingPath(path.join(projectRoot, 'openspec', 'changes', changeName));
|
|
62
64
|
// Resolve schema: explicit > metadata > default
|
|
63
65
|
const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName);
|
|
64
66
|
const schema = resolveSchema(resolvedSchemaName, projectRoot);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks if a path contains glob pattern characters.
|
|
3
|
+
*/
|
|
4
|
+
export declare function isGlobPattern(pattern: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Resolves an artifact's output path(s) to concrete files that currently exist.
|
|
7
|
+
* Returns absolute file paths. Glob matches are sorted for deterministic output.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveArtifactOutputs(changeDir: string, generates: string): string[];
|
|
10
|
+
/**
|
|
11
|
+
* Checks if an artifact has at least one resolved output file.
|
|
12
|
+
*/
|
|
13
|
+
export declare function artifactOutputExists(changeDir: string, generates: string): boolean;
|
|
14
|
+
//# sourceMappingURL=outputs.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
5
|
+
/**
|
|
6
|
+
* Checks if a path contains glob pattern characters.
|
|
7
|
+
*/
|
|
8
|
+
export function isGlobPattern(pattern) {
|
|
9
|
+
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolves an artifact's output path(s) to concrete files that currently exist.
|
|
13
|
+
* Returns absolute file paths. Glob matches are sorted for deterministic output.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveArtifactOutputs(changeDir, generates) {
|
|
16
|
+
if (!isGlobPattern(generates)) {
|
|
17
|
+
const fullPath = path.join(changeDir, generates);
|
|
18
|
+
try {
|
|
19
|
+
return fs.statSync(fullPath).isFile()
|
|
20
|
+
? [FileSystemUtils.canonicalizeExistingPath(fullPath)]
|
|
21
|
+
: [];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
|
28
|
+
const matches = fg
|
|
29
|
+
.sync(normalizedPattern, { cwd: changeDir, onlyFiles: true, absolute: true })
|
|
30
|
+
.map((match) => FileSystemUtils.canonicalizeExistingPath(path.normalize(match)));
|
|
31
|
+
return Array.from(new Set(matches)).sort();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Checks if an artifact has at least one resolved output file.
|
|
35
|
+
*/
|
|
36
|
+
export function artifactOutputExists(changeDir, generates) {
|
|
37
|
+
return resolveArtifactOutputs(changeDir, generates).length > 0;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=outputs.js.map
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
|
-
import
|
|
3
|
-
import fg from 'fast-glob';
|
|
4
|
-
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
2
|
+
import { artifactOutputExists } from './outputs.js';
|
|
5
3
|
/**
|
|
6
4
|
* Detects which artifacts are completed by checking file existence in the change directory.
|
|
7
5
|
* Returns a Set of completed artifact IDs.
|
|
@@ -28,27 +26,6 @@ export function detectCompleted(graph, changeDir) {
|
|
|
28
26
|
* Supports both simple paths and glob patterns.
|
|
29
27
|
*/
|
|
30
28
|
function isArtifactComplete(generates, changeDir) {
|
|
31
|
-
|
|
32
|
-
// Check if it's a glob pattern
|
|
33
|
-
if (isGlobPattern(generates)) {
|
|
34
|
-
return hasGlobMatches(fullPattern);
|
|
35
|
-
}
|
|
36
|
-
// Simple file path - check if file exists
|
|
37
|
-
return fs.existsSync(fullPattern);
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Checks if a path contains glob pattern characters.
|
|
41
|
-
*/
|
|
42
|
-
function isGlobPattern(pattern) {
|
|
43
|
-
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Checks if a glob pattern has any matches.
|
|
47
|
-
* Normalizes Windows backslashes to forward slashes for cross-platform glob compatibility.
|
|
48
|
-
*/
|
|
49
|
-
function hasGlobMatches(pattern) {
|
|
50
|
-
const normalizedPattern = FileSystemUtils.toPosixPath(pattern);
|
|
51
|
-
const matches = fg.sync(normalizedPattern, { onlyFiles: true });
|
|
52
|
-
return matches.length > 0;
|
|
29
|
+
return artifactOutputExists(changeDir, generates);
|
|
53
30
|
}
|
|
54
31
|
//# sourceMappingURL=state.js.map
|
|
@@ -148,15 +148,19 @@ export class ChangeParser extends MarkdownParser {
|
|
|
148
148
|
parseSectionsFromContent(content) {
|
|
149
149
|
const normalizedContent = ChangeParser.normalizeContent(content);
|
|
150
150
|
const lines = normalizedContent.split('\n');
|
|
151
|
+
const codeFenceLineMask = ChangeParser.buildCodeFenceMask(lines);
|
|
151
152
|
const sections = [];
|
|
152
153
|
const stack = [];
|
|
153
154
|
for (let i = 0; i < lines.length; i++) {
|
|
154
155
|
const line = lines[i];
|
|
156
|
+
if (codeFenceLineMask[i]) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
155
159
|
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
156
160
|
if (headerMatch) {
|
|
157
161
|
const level = headerMatch[1].length;
|
|
158
162
|
const title = headerMatch[2].trim();
|
|
159
|
-
const contentLines = this.getContentUntilNextHeaderFromLines(lines, i + 1, level);
|
|
163
|
+
const contentLines = this.getContentUntilNextHeaderFromLines(lines, codeFenceLineMask, i + 1, level);
|
|
160
164
|
const section = {
|
|
161
165
|
level,
|
|
162
166
|
title,
|
|
@@ -177,11 +181,11 @@ export class ChangeParser extends MarkdownParser {
|
|
|
177
181
|
}
|
|
178
182
|
return sections;
|
|
179
183
|
}
|
|
180
|
-
getContentUntilNextHeaderFromLines(lines, startLine, currentLevel) {
|
|
184
|
+
getContentUntilNextHeaderFromLines(lines, codeFenceLineMask, startLine, currentLevel) {
|
|
181
185
|
const contentLines = [];
|
|
182
186
|
for (let i = startLine; i < lines.length; i++) {
|
|
183
187
|
const line = lines[i];
|
|
184
|
-
const headerMatch = line.match(/^(#{1,6})\s+/);
|
|
188
|
+
const headerMatch = codeFenceLineMask[i] ? null : line.match(/^(#{1,6})\s+/);
|
|
185
189
|
if (headerMatch && headerMatch[1].length <= currentLevel) {
|
|
186
190
|
break;
|
|
187
191
|
}
|
|
@@ -7,9 +7,13 @@ export interface Section {
|
|
|
7
7
|
}
|
|
8
8
|
export declare class MarkdownParser {
|
|
9
9
|
private lines;
|
|
10
|
+
private codeFenceLineMask;
|
|
10
11
|
private currentLine;
|
|
11
12
|
constructor(content: string);
|
|
12
13
|
protected static normalizeContent(content: string): string;
|
|
14
|
+
protected static buildCodeFenceMask(lines: string[]): boolean[];
|
|
15
|
+
private static getFenceMarker;
|
|
16
|
+
private static isClosingFence;
|
|
13
17
|
parseSpec(name: string): Spec;
|
|
14
18
|
parseChange(name: string): Change;
|
|
15
19
|
protected parseSections(): Section[];
|
|
@@ -1,14 +1,51 @@
|
|
|
1
1
|
export class MarkdownParser {
|
|
2
2
|
lines;
|
|
3
|
+
codeFenceLineMask;
|
|
3
4
|
currentLine;
|
|
4
5
|
constructor(content) {
|
|
5
6
|
const normalized = MarkdownParser.normalizeContent(content);
|
|
6
7
|
this.lines = normalized.split('\n');
|
|
8
|
+
this.codeFenceLineMask = MarkdownParser.buildCodeFenceMask(this.lines);
|
|
7
9
|
this.currentLine = 0;
|
|
8
10
|
}
|
|
9
11
|
static normalizeContent(content) {
|
|
10
12
|
return content.replace(/\r\n?/g, '\n');
|
|
11
13
|
}
|
|
14
|
+
static buildCodeFenceMask(lines) {
|
|
15
|
+
const mask = new Array(lines.length).fill(false);
|
|
16
|
+
let activeFence = null;
|
|
17
|
+
for (let i = 0; i < lines.length; i++) {
|
|
18
|
+
const fence = MarkdownParser.getFenceMarker(lines[i]);
|
|
19
|
+
if (!activeFence) {
|
|
20
|
+
if (fence) {
|
|
21
|
+
activeFence = fence;
|
|
22
|
+
mask[i] = true;
|
|
23
|
+
}
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
mask[i] = true;
|
|
27
|
+
if (MarkdownParser.isClosingFence(lines[i], activeFence)) {
|
|
28
|
+
activeFence = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return mask;
|
|
32
|
+
}
|
|
33
|
+
static getFenceMarker(line) {
|
|
34
|
+
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
|
35
|
+
if (!fenceMatch) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
marker: fenceMatch[1][0],
|
|
40
|
+
length: fenceMatch[1].length,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
static isClosingFence(line, activeFence) {
|
|
44
|
+
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
|
|
45
|
+
return Boolean(fenceMatch &&
|
|
46
|
+
fenceMatch[1][0] === activeFence.marker &&
|
|
47
|
+
fenceMatch[1].length >= activeFence.length);
|
|
48
|
+
}
|
|
12
49
|
parseSpec(name) {
|
|
13
50
|
const sections = this.parseSections();
|
|
14
51
|
const purpose = this.findSection(sections, 'Purpose')?.content || '';
|
|
@@ -57,6 +94,9 @@ export class MarkdownParser {
|
|
|
57
94
|
const stack = [];
|
|
58
95
|
for (let i = 0; i < this.lines.length; i++) {
|
|
59
96
|
const line = this.lines[i];
|
|
97
|
+
if (this.codeFenceLineMask[i]) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
60
100
|
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
61
101
|
if (headerMatch) {
|
|
62
102
|
const level = headerMatch[1].length;
|
|
@@ -86,7 +126,7 @@ export class MarkdownParser {
|
|
|
86
126
|
const contentLines = [];
|
|
87
127
|
for (let i = startLine; i < this.lines.length; i++) {
|
|
88
128
|
const line = this.lines[i];
|
|
89
|
-
const headerMatch = line.match(/^(#{1,6})\s+/);
|
|
129
|
+
const headerMatch = this.codeFenceLineMask[i] ? null : line.match(/^(#{1,6})\s+/);
|
|
90
130
|
if (headerMatch && headerMatch[1].length <= currentLevel) {
|
|
91
131
|
break;
|
|
92
132
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface MainSpecStructureIssue {
|
|
2
|
+
kind: 'delta-header' | 'requirement-outside-requirements';
|
|
3
|
+
line: number;
|
|
4
|
+
header: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function findMainSpecStructureIssues(content: string): MainSpecStructureIssue[];
|
|
8
|
+
export declare function stripFencedCodeBlocksPreservingLines(content: string): string;
|
|
9
|
+
//# sourceMappingURL=spec-structure.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
|
|
2
|
+
const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
|
|
3
|
+
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
|
|
4
|
+
const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/;
|
|
5
|
+
export function findMainSpecStructureIssues(content) {
|
|
6
|
+
const normalized = content.replace(/\r\n?/g, '\n');
|
|
7
|
+
const stripped = stripFencedCodeBlocksPreservingLines(normalized);
|
|
8
|
+
const lines = stripped.split('\n');
|
|
9
|
+
const issues = [];
|
|
10
|
+
const requirementsHeaderIndex = lines.findIndex(line => REQUIREMENTS_SECTION_HEADER.test(line));
|
|
11
|
+
let requirementsEndIndex = lines.length;
|
|
12
|
+
if (requirementsHeaderIndex !== -1) {
|
|
13
|
+
for (let i = requirementsHeaderIndex + 1; i < lines.length; i++) {
|
|
14
|
+
if (TOP_LEVEL_SECTION_HEADER.test(lines[i])) {
|
|
15
|
+
requirementsEndIndex = i;
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
for (let i = 0; i < lines.length; i++) {
|
|
21
|
+
const line = lines[i];
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
if (!trimmed) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (DELTA_HEADER.test(line)) {
|
|
27
|
+
issues.push({
|
|
28
|
+
kind: 'delta-header',
|
|
29
|
+
line: i + 1,
|
|
30
|
+
header: trimmed,
|
|
31
|
+
message: `Main spec contains delta header "${trimmed}". ` +
|
|
32
|
+
'Delta headers are only valid inside openspec/changes/<name>/specs/<capability>/spec.md ' +
|
|
33
|
+
'and truncate the parsed ## Requirements section.',
|
|
34
|
+
});
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const requirementMatch = line.match(REQUIREMENT_HEADER);
|
|
38
|
+
if (!requirementMatch) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const insideRequirements = requirementsHeaderIndex !== -1 &&
|
|
42
|
+
i > requirementsHeaderIndex &&
|
|
43
|
+
i < requirementsEndIndex;
|
|
44
|
+
if (!insideRequirements) {
|
|
45
|
+
issues.push({
|
|
46
|
+
kind: 'requirement-outside-requirements',
|
|
47
|
+
line: i + 1,
|
|
48
|
+
header: trimmed,
|
|
49
|
+
message: `Requirement header "${trimmed}" appears outside the main ## Requirements section. ` +
|
|
50
|
+
'Main specs only parse requirements inside that section, so this requirement is currently invisible to validate, list, and archive.',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return issues;
|
|
55
|
+
}
|
|
56
|
+
export function stripFencedCodeBlocksPreservingLines(content) {
|
|
57
|
+
const lines = content.split('\n');
|
|
58
|
+
const output = [];
|
|
59
|
+
let activeFence = null;
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
|
|
62
|
+
if (!activeFence) {
|
|
63
|
+
if (fenceMatch) {
|
|
64
|
+
activeFence = {
|
|
65
|
+
marker: fenceMatch[1][0],
|
|
66
|
+
length: fenceMatch[1].length,
|
|
67
|
+
};
|
|
68
|
+
output.push('');
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
output.push(line);
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
output.push('');
|
|
76
|
+
if (isClosingFence(line, activeFence)) {
|
|
77
|
+
activeFence = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return output.join('\n');
|
|
81
|
+
}
|
|
82
|
+
function isClosingFence(line, activeFence) {
|
|
83
|
+
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
|
|
84
|
+
return Boolean(fenceMatch &&
|
|
85
|
+
fenceMatch[1][0] === activeFence.marker &&
|
|
86
|
+
fenceMatch[1].length >= activeFence.length);
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=spec-structure.js.map
|
package/dist/core/specs-apply.js
CHANGED
|
@@ -8,6 +8,7 @@ import { promises as fs } from 'fs';
|
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
|
|
11
|
+
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
|
|
11
12
|
import { Validator } from './validation/validator.js';
|
|
12
13
|
// -----------------------------------------------------------------------------
|
|
13
14
|
// Public API
|
|
@@ -153,6 +154,13 @@ export async function buildUpdatedSpec(update, changeName) {
|
|
|
153
154
|
isNewSpec = true;
|
|
154
155
|
targetContent = buildSpecSkeleton(specName, changeName);
|
|
155
156
|
}
|
|
157
|
+
const structureIssues = findMainSpecStructureIssues(targetContent);
|
|
158
|
+
if (structureIssues.length > 0) {
|
|
159
|
+
const details = structureIssues
|
|
160
|
+
.map(issue => `line ${issue.line}: ${issue.message}`)
|
|
161
|
+
.join('\n');
|
|
162
|
+
throw new Error(`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`);
|
|
163
|
+
}
|
|
156
164
|
// Extract requirements section and build name->block map
|
|
157
165
|
const parts = extractRequirementsSection(targetContent);
|
|
158
166
|
const nameToBlock = new Map();
|
|
@@ -32,7 +32,7 @@ export function getApplyChangeSkillTemplate() {
|
|
|
32
32
|
\`\`\`
|
|
33
33
|
|
|
34
34
|
This returns:
|
|
35
|
-
-
|
|
35
|
+
- \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
|
36
36
|
- Progress (total, complete, remaining)
|
|
37
37
|
- Task list with status
|
|
38
38
|
- Dynamic instruction based on current state
|
|
@@ -44,7 +44,7 @@ export function getApplyChangeSkillTemplate() {
|
|
|
44
44
|
|
|
45
45
|
4. **Read context files**
|
|
46
46
|
|
|
47
|
-
Read
|
|
47
|
+
Read every file path listed under \`contextFiles\` from the apply instructions output.
|
|
48
48
|
The files depend on the schema being used:
|
|
49
49
|
- **spec-driven**: proposal, specs, design, tasks
|
|
50
50
|
- Other schemas: follow the contextFiles from CLI output
|
|
@@ -188,7 +188,7 @@ export function getOpsxApplyCommandTemplate() {
|
|
|
188
188
|
\`\`\`
|
|
189
189
|
|
|
190
190
|
This returns:
|
|
191
|
-
-
|
|
191
|
+
- \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema)
|
|
192
192
|
- Progress (total, complete, remaining)
|
|
193
193
|
- Task list with status
|
|
194
194
|
- Dynamic instruction based on current state
|
|
@@ -200,7 +200,7 @@ export function getOpsxApplyCommandTemplate() {
|
|
|
200
200
|
|
|
201
201
|
4. **Read context files**
|
|
202
202
|
|
|
203
|
-
Read
|
|
203
|
+
Read every file path listed under \`contextFiles\` from the apply instructions output.
|
|
204
204
|
The files depend on the schema being used:
|
|
205
205
|
- **spec-driven**: proposal, specs, design, tasks
|
|
206
206
|
- Other schemas: follow the contextFiles from CLI output
|
|
@@ -32,7 +32,7 @@ export function getVerifyChangeSkillTemplate() {
|
|
|
32
32
|
openspec instructions apply --change "<name>" --json
|
|
33
33
|
\`\`\`
|
|
34
34
|
|
|
35
|
-
This returns the change directory and
|
|
35
|
+
This returns the change directory and \`contextFiles\` (artifact ID -> array of concrete file paths). Read all available artifacts from \`contextFiles\`.
|
|
36
36
|
|
|
37
37
|
4. **Initialize verification report structure**
|
|
38
38
|
|
|
@@ -46,7 +46,7 @@ export function getVerifyChangeSkillTemplate() {
|
|
|
46
46
|
5. **Verify Completeness**
|
|
47
47
|
|
|
48
48
|
**Task Completion**:
|
|
49
|
-
- If tasks
|
|
49
|
+
- If \`contextFiles.tasks\` exists, read every file path in it
|
|
50
50
|
- Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
|
|
51
51
|
- Count complete vs total tasks
|
|
52
52
|
- If incomplete tasks exist:
|
|
@@ -85,7 +85,7 @@ export function getVerifyChangeSkillTemplate() {
|
|
|
85
85
|
7. **Verify Coherence**
|
|
86
86
|
|
|
87
87
|
**Design Adherence**:
|
|
88
|
-
- If design
|
|
88
|
+
- If \`contextFiles.design\` exists:
|
|
89
89
|
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
|
90
90
|
- Verify implementation follows those decisions
|
|
91
91
|
- If contradiction detected:
|
|
@@ -200,7 +200,7 @@ export function getOpsxVerifyCommandTemplate() {
|
|
|
200
200
|
openspec instructions apply --change "<name>" --json
|
|
201
201
|
\`\`\`
|
|
202
202
|
|
|
203
|
-
This returns the change directory and
|
|
203
|
+
This returns the change directory and \`contextFiles\` (artifact ID -> array of concrete file paths). Read all available artifacts from \`contextFiles\`.
|
|
204
204
|
|
|
205
205
|
4. **Initialize verification report structure**
|
|
206
206
|
|
|
@@ -214,7 +214,7 @@ export function getOpsxVerifyCommandTemplate() {
|
|
|
214
214
|
5. **Verify Completeness**
|
|
215
215
|
|
|
216
216
|
**Task Completion**:
|
|
217
|
-
- If tasks
|
|
217
|
+
- If \`contextFiles.tasks\` exists, read every file path in it
|
|
218
218
|
- Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
|
|
219
219
|
- Count complete vs total tasks
|
|
220
220
|
- If incomplete tasks exist:
|
|
@@ -253,7 +253,7 @@ export function getOpsxVerifyCommandTemplate() {
|
|
|
253
253
|
7. **Verify Coherence**
|
|
254
254
|
|
|
255
255
|
**Design Adherence**:
|
|
256
|
-
- If design
|
|
256
|
+
- If \`contextFiles.design\` exists:
|
|
257
257
|
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
|
258
258
|
- Verify implementation follows those decisions
|
|
259
259
|
- If contradiction detected:
|
|
@@ -5,6 +5,7 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
|
|
|
5
5
|
import { ChangeParser } from '../parsers/change-parser.js';
|
|
6
6
|
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
|
|
7
7
|
import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
|
|
8
|
+
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
8
9
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
9
10
|
export class Validator {
|
|
10
11
|
strictMode;
|
|
@@ -270,6 +271,14 @@ export class Validator {
|
|
|
270
271
|
}
|
|
271
272
|
applySpecRules(spec, content) {
|
|
272
273
|
const issues = [];
|
|
274
|
+
for (const structuralIssue of findMainSpecStructureIssues(content)) {
|
|
275
|
+
issues.push({
|
|
276
|
+
level: 'ERROR',
|
|
277
|
+
path: 'file',
|
|
278
|
+
line: structuralIssue.line,
|
|
279
|
+
message: structuralIssue.message,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
273
282
|
if (spec.overview.length < MIN_PURPOSE_LENGTH) {
|
|
274
283
|
issues.push({
|
|
275
284
|
level: 'WARNING',
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare const CONFIG_DIR_NAME = "openspec";
|
|
2
|
+
export declare const CONFIG_FILE_NAME = "config.json";
|
|
1
3
|
export interface TelemetryConfig {
|
|
2
4
|
anonymousId?: string;
|
|
3
5
|
noticeSeen?: boolean;
|
|
@@ -8,7 +10,11 @@ export interface GlobalConfig {
|
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
10
12
|
* Get the path to the global config file.
|
|
11
|
-
*
|
|
13
|
+
* Follows XDG Base Directory Specification and platform conventions.
|
|
14
|
+
*
|
|
15
|
+
* - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
|
|
16
|
+
* - Unix/macOS fallback: ~/.config/openspec/
|
|
17
|
+
* - Windows fallback: %APPDATA%/openspec/
|
|
12
18
|
*/
|
|
13
19
|
export declare function getConfigPath(): string;
|
|
14
20
|
/**
|
package/dist/telemetry/config.js
CHANGED
|
@@ -1,17 +1,96 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Global configuration for telemetry state.
|
|
3
|
-
* Stores anonymous ID and notice-seen flag in
|
|
3
|
+
* Stores anonymous ID and notice-seen flag in the platform-appropriate config directory.
|
|
4
4
|
*/
|
|
5
5
|
import { promises as fs } from 'fs';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import os from 'os';
|
|
8
|
+
import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, } from '../core/global-config.js';
|
|
9
|
+
// Constants
|
|
10
|
+
export const CONFIG_DIR_NAME = GLOBAL_CONFIG_DIR_NAME;
|
|
11
|
+
export const CONFIG_FILE_NAME = GLOBAL_CONFIG_FILE_NAME;
|
|
12
|
+
function getConfigDir() {
|
|
13
|
+
return getGlobalConfigDir();
|
|
14
|
+
}
|
|
15
|
+
function getLegacyConfigPath() {
|
|
16
|
+
return path.join(os.homedir(), '.config', CONFIG_DIR_NAME, CONFIG_FILE_NAME);
|
|
17
|
+
}
|
|
18
|
+
async function readConfigFile(configPath) {
|
|
19
|
+
try {
|
|
20
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
21
|
+
return { status: 'ok', config: JSON.parse(content) };
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code === 'ENOENT') {
|
|
25
|
+
return { status: 'missing' };
|
|
26
|
+
}
|
|
27
|
+
// If parse fails or another read error occurs, ignore the file.
|
|
28
|
+
return { status: 'invalid', config: {} };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function writeConfigFile(configPath, config) {
|
|
32
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
33
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
34
|
+
}
|
|
35
|
+
function hasMissingTelemetryFields(config) {
|
|
36
|
+
const telemetry = config.telemetry;
|
|
37
|
+
return (!telemetry ||
|
|
38
|
+
telemetry.anonymousId === undefined ||
|
|
39
|
+
telemetry.noticeSeen === undefined);
|
|
40
|
+
}
|
|
41
|
+
function mergeLegacyTelemetry(config, legacyConfig) {
|
|
42
|
+
const legacyTelemetry = legacyConfig.telemetry;
|
|
43
|
+
if (!legacyTelemetry) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const currentTelemetry = config.telemetry ?? {};
|
|
47
|
+
const shouldMigrate = (currentTelemetry.anonymousId === undefined && legacyTelemetry.anonymousId !== undefined) ||
|
|
48
|
+
(currentTelemetry.noticeSeen === undefined && legacyTelemetry.noticeSeen !== undefined);
|
|
49
|
+
if (!shouldMigrate) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
...config,
|
|
54
|
+
telemetry: {
|
|
55
|
+
...legacyTelemetry,
|
|
56
|
+
...currentTelemetry,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function migrateLegacyTelemetryConfig(configPath, config, persist) {
|
|
61
|
+
const legacyConfigPath = getLegacyConfigPath();
|
|
62
|
+
if (path.resolve(configPath) === path.resolve(legacyConfigPath) || !hasMissingTelemetryFields(config)) {
|
|
63
|
+
return config;
|
|
64
|
+
}
|
|
65
|
+
const legacyRead = await readConfigFile(legacyConfigPath);
|
|
66
|
+
if (legacyRead.status !== 'ok') {
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
const migrated = mergeLegacyTelemetry(config, legacyRead.config);
|
|
70
|
+
if (!migrated) {
|
|
71
|
+
return config;
|
|
72
|
+
}
|
|
73
|
+
if (persist) {
|
|
74
|
+
try {
|
|
75
|
+
await writeConfigFile(configPath, migrated);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Preserve telemetry for this run even if the one-time migration cannot be persisted.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return migrated;
|
|
82
|
+
}
|
|
8
83
|
/**
|
|
9
84
|
* Get the path to the global config file.
|
|
10
|
-
*
|
|
85
|
+
* Follows XDG Base Directory Specification and platform conventions.
|
|
86
|
+
*
|
|
87
|
+
* - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
|
|
88
|
+
* - Unix/macOS fallback: ~/.config/openspec/
|
|
89
|
+
* - Windows fallback: %APPDATA%/openspec/
|
|
11
90
|
*/
|
|
12
91
|
export function getConfigPath() {
|
|
13
|
-
const configDir =
|
|
14
|
-
return path.join(configDir,
|
|
92
|
+
const configDir = getConfigDir();
|
|
93
|
+
return path.join(configDir, CONFIG_FILE_NAME);
|
|
15
94
|
}
|
|
16
95
|
/**
|
|
17
96
|
* Read the global config file.
|
|
@@ -19,17 +98,9 @@ export function getConfigPath() {
|
|
|
19
98
|
*/
|
|
20
99
|
export async function readConfig() {
|
|
21
100
|
const configPath = getConfigPath();
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
catch (error) {
|
|
27
|
-
if (error.code === 'ENOENT') {
|
|
28
|
-
return {};
|
|
29
|
-
}
|
|
30
|
-
// If parse fails or other error, return empty config
|
|
31
|
-
return {};
|
|
32
|
-
}
|
|
101
|
+
const read = await readConfigFile(configPath);
|
|
102
|
+
const config = read.status === 'ok' ? read.config : {};
|
|
103
|
+
return migrateLegacyTelemetryConfig(configPath, config, read.status !== 'invalid');
|
|
33
104
|
}
|
|
34
105
|
/**
|
|
35
106
|
* Write to the global config file.
|
|
@@ -37,9 +108,6 @@ export async function readConfig() {
|
|
|
37
108
|
*/
|
|
38
109
|
export async function writeConfig(updates) {
|
|
39
110
|
const configPath = getConfigPath();
|
|
40
|
-
const configDir = path.dirname(configPath);
|
|
41
|
-
// Ensure directory exists
|
|
42
|
-
await fs.mkdir(configDir, { recursive: true });
|
|
43
111
|
// Read existing config and merge
|
|
44
112
|
const existing = await readConfig();
|
|
45
113
|
const merged = { ...existing, ...updates };
|
|
@@ -47,7 +115,7 @@ export async function writeConfig(updates) {
|
|
|
47
115
|
if (updates.telemetry && existing.telemetry) {
|
|
48
116
|
merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
|
|
49
117
|
}
|
|
50
|
-
await
|
|
118
|
+
await writeConfigFile(configPath, merged);
|
|
51
119
|
}
|
|
52
120
|
/**
|
|
53
121
|
* Get the telemetry config section.
|
package/dist/telemetry/index.js
CHANGED
|
@@ -16,8 +16,21 @@ import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
|
|
|
16
16
|
const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
|
|
17
17
|
// Using reverse proxy to avoid ad blockers and keep traffic on our domain
|
|
18
18
|
const POSTHOG_HOST = 'https://edge.openspec.dev';
|
|
19
|
+
const TELEMETRY_REQUEST_TIMEOUT_MS = 1000;
|
|
19
20
|
let posthogClient = null;
|
|
20
21
|
let anonymousId = null;
|
|
22
|
+
async function safeTelemetryFetch(url, options) {
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(url, options);
|
|
25
|
+
if (response.ok) {
|
|
26
|
+
return response;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Silent failure - telemetry should never surface network noise
|
|
31
|
+
}
|
|
32
|
+
return new Response(null, { status: 204 });
|
|
33
|
+
}
|
|
21
34
|
/**
|
|
22
35
|
* Check if telemetry is enabled.
|
|
23
36
|
*
|
|
@@ -71,6 +84,12 @@ function getClient() {
|
|
|
71
84
|
host: POSTHOG_HOST,
|
|
72
85
|
flushAt: 1, // Send immediately, don't batch
|
|
73
86
|
flushInterval: 0, // No timer-based flushing
|
|
87
|
+
fetchRetryCount: 0,
|
|
88
|
+
requestTimeout: TELEMETRY_REQUEST_TIMEOUT_MS,
|
|
89
|
+
preloadFeatureFlags: false,
|
|
90
|
+
disableRemoteConfig: true,
|
|
91
|
+
disableSurveys: true,
|
|
92
|
+
fetch: safeTelemetryFetch,
|
|
74
93
|
});
|
|
75
94
|
}
|
|
76
95
|
return posthogClient;
|
|
@@ -4,6 +4,11 @@ export declare class FileSystemUtils {
|
|
|
4
4
|
* Essential for cross-platform compatibility with glob libraries like fast-glob.
|
|
5
5
|
*/
|
|
6
6
|
static toPosixPath(p: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Returns a canonical absolute path when the target exists.
|
|
9
|
+
* Falls back to path.resolve() so callers can still produce a stable absolute path.
|
|
10
|
+
*/
|
|
11
|
+
static canonicalizeExistingPath(targetPath: string): string;
|
|
7
12
|
private static isWindowsBasePath;
|
|
8
13
|
private static normalizeSegments;
|
|
9
14
|
static joinPath(basePath: string, ...segments: string[]): string;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as nodeFs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
const fs = nodeFs.promises;
|
|
4
|
+
const { constants: fsConstants } = nodeFs;
|
|
3
5
|
function isMarkerOnOwnLine(content, markerIndex, markerLength) {
|
|
4
6
|
let leftIndex = markerIndex - 1;
|
|
5
7
|
while (leftIndex >= 0 && content[leftIndex] !== '\n') {
|
|
@@ -37,6 +39,24 @@ export class FileSystemUtils {
|
|
|
37
39
|
static toPosixPath(p) {
|
|
38
40
|
return p.replace(/\\/g, '/');
|
|
39
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Returns a canonical absolute path when the target exists.
|
|
44
|
+
* Falls back to path.resolve() so callers can still produce a stable absolute path.
|
|
45
|
+
*/
|
|
46
|
+
static canonicalizeExistingPath(targetPath) {
|
|
47
|
+
try {
|
|
48
|
+
// Prefer the native resolver so Windows short-path aliases are expanded.
|
|
49
|
+
return nodeFs.realpathSync.native(targetPath);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
try {
|
|
53
|
+
return nodeFs.realpathSync(targetPath);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return path.resolve(targetPath);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
40
60
|
static isWindowsBasePath(basePath) {
|
|
41
61
|
return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
|
|
42
62
|
}
|