@fission-ai/openspec 1.15.0 → 1.16.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 +5 -7
- package/dist/cli/index.js +1 -1
- package/dist/commands/completion.js +4 -2
- package/dist/commands/validate.js +23 -0
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -1
- package/dist/core/completions/installers/zsh-installer.js +64 -19
- package/dist/core/id.d.ts +8 -0
- package/dist/core/id.js +15 -0
- package/dist/core/parsers/change-parser.js +3 -2
- package/dist/core/parsers/code-fence.js +9 -1
- package/dist/core/parsers/markdown-parser.js +3 -2
- package/dist/core/parsers/requirement-blocks.d.ts +48 -0
- package/dist/core/parsers/requirement-blocks.js +69 -26
- package/dist/core/parsers/requirement-text.d.ts +14 -0
- package/dist/core/parsers/requirement-text.js +39 -0
- package/dist/core/parsers/spec-structure.js +3 -4
- package/dist/core/project-config.d.ts +10 -0
- package/dist/core/project-config.js +26 -0
- package/dist/core/root-selection.d.ts +6 -0
- package/dist/core/root-selection.js +37 -0
- package/dist/core/specs-apply.js +11 -3
- package/dist/core/store/git.d.ts +22 -1
- package/dist/core/store/git.js +59 -4
- package/dist/core/validation/purpose-placeholder.js +1 -1
- package/dist/core/validation/validator.d.ts +27 -2
- package/dist/core/validation/validator.js +62 -5
- package/dist/core/version-check.d.ts +5 -2
- package/dist/core/version-check.js +115 -43
- package/package.json +6 -6
- package/schemas/spec-driven/schema.yaml +1 -0
|
@@ -57,15 +57,21 @@ function standaloneUpgradeCommand() {
|
|
|
57
57
|
* tests, an explicit escape hatch for anyone offline or air-gapped, and the
|
|
58
58
|
* same privacy signals telemetry already honors — a user who set DO_NOT_TRACK
|
|
59
59
|
* or telemetry.enabled false did not agree to a different outbound request.
|
|
60
|
+
*
|
|
61
|
+
* `requested` is an explicit `openspec version --check`: the user asked for
|
|
62
|
+
* this one request, so neither the telemetry signals nor CI detection apply
|
|
63
|
+
* to it. Only the update-check opt-out and the test guard still stop it.
|
|
60
64
|
*/
|
|
61
|
-
function isCheckEnabled() {
|
|
65
|
+
function isCheckEnabled(requested) {
|
|
62
66
|
if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined)
|
|
63
67
|
return false;
|
|
64
|
-
if (
|
|
68
|
+
if (process.env.NODE_ENV === 'test')
|
|
65
69
|
return false;
|
|
70
|
+
if (requested)
|
|
71
|
+
return true;
|
|
66
72
|
if (isCiEnvironment())
|
|
67
73
|
return false;
|
|
68
|
-
if (
|
|
74
|
+
if (isTelemetryOptedOutByEnv())
|
|
69
75
|
return false;
|
|
70
76
|
// Same config opt-out as telemetry (env remains the hard override above).
|
|
71
77
|
if (getGlobalConfig().telemetry?.enabled === false)
|
|
@@ -180,40 +186,28 @@ export function compareVersions(a, b) {
|
|
|
180
186
|
return comparePrerelease(left.prerelease, right.prerelease);
|
|
181
187
|
}
|
|
182
188
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
189
|
+
* One GET, finished by `deadline`. Uses node:http(s) rather than fetch so the
|
|
190
|
+
* timeout can destroy the socket. Aborting a fetch that is still completing
|
|
191
|
+
* its TCP handshake — a firewall dropping packets, a captive portal — leaves
|
|
192
|
+
* the connect handle open and the CLI cannot exit until the OS gives up, long
|
|
193
|
+
* after the hint has printed.
|
|
186
194
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* CLI cannot exit until the OS gives up, long after the hint has printed.
|
|
195
|
+
* Resolves the final status (with the body only for a 200), or null on any
|
|
196
|
+
* network failure, an oversized body, or a redirect that cannot be followed.
|
|
197
|
+
* With `followRedirects: false` a redirect is returned as-is, unread.
|
|
191
198
|
*/
|
|
192
|
-
function
|
|
199
|
+
function httpsGet(url, options) {
|
|
193
200
|
return new Promise((resolve) => {
|
|
194
201
|
let settled = false;
|
|
195
202
|
let timer;
|
|
196
|
-
const finish = (
|
|
203
|
+
const finish = (result) => {
|
|
197
204
|
if (settled)
|
|
198
205
|
return;
|
|
199
206
|
settled = true;
|
|
200
207
|
if (timer)
|
|
201
208
|
clearTimeout(timer);
|
|
202
|
-
resolve(
|
|
209
|
+
resolve(result);
|
|
203
210
|
};
|
|
204
|
-
let url;
|
|
205
|
-
try {
|
|
206
|
-
const resolved = registryUrl();
|
|
207
|
-
if (resolved === null) {
|
|
208
|
-
resolve(null);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
url = new URL(resolved);
|
|
212
|
-
}
|
|
213
|
-
catch {
|
|
214
|
-
resolve(null);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
211
|
// Mirrors and corporate front-ends redirect; without following one the
|
|
218
212
|
// check would be permanently and silently dead for them.
|
|
219
213
|
let redirectsLeft = MAX_REDIRECTS;
|
|
@@ -229,6 +223,10 @@ function fetchLatestVersion() {
|
|
|
229
223
|
if (status >= 300 && status < 400 && location) {
|
|
230
224
|
response.resume();
|
|
231
225
|
request.destroy();
|
|
226
|
+
if (!options.followRedirects) {
|
|
227
|
+
finish({ status, body: '' });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
232
230
|
if (redirectsLeft <= 0) {
|
|
233
231
|
finish(null);
|
|
234
232
|
return;
|
|
@@ -256,29 +254,20 @@ function fetchLatestVersion() {
|
|
|
256
254
|
if (status !== 200) {
|
|
257
255
|
response.resume();
|
|
258
256
|
request.destroy();
|
|
259
|
-
finish(
|
|
257
|
+
finish({ status, body: '' });
|
|
260
258
|
return;
|
|
261
259
|
}
|
|
262
260
|
let body = '';
|
|
263
261
|
response.setEncoding('utf-8');
|
|
264
262
|
response.on('data', (chunk) => {
|
|
265
263
|
body += chunk;
|
|
266
|
-
//
|
|
264
|
+
// Version documents are small; refuse to buffer a firehose.
|
|
267
265
|
if (body.length > MAX_RESPONSE_BYTES) {
|
|
268
266
|
request.destroy();
|
|
269
267
|
finish(null);
|
|
270
268
|
}
|
|
271
269
|
});
|
|
272
|
-
response.on('end', () => {
|
|
273
|
-
try {
|
|
274
|
-
const parsed = JSON.parse(body);
|
|
275
|
-
const version = parsed.version;
|
|
276
|
-
finish(typeof version === 'string' && SAFE_VERSION.test(version) ? version : null);
|
|
277
|
-
}
|
|
278
|
-
catch {
|
|
279
|
-
finish(null);
|
|
280
|
-
}
|
|
281
|
-
});
|
|
270
|
+
response.on('end', () => finish({ status, body }));
|
|
282
271
|
response.on('error', () => finish(null));
|
|
283
272
|
});
|
|
284
273
|
activeRequest = request;
|
|
@@ -292,22 +281,105 @@ function fetchLatestVersion() {
|
|
|
292
281
|
timer = setTimeout(() => {
|
|
293
282
|
activeRequest?.destroy();
|
|
294
283
|
finish(null);
|
|
295
|
-
},
|
|
284
|
+
}, Math.max(0, options.deadline - Date.now()));
|
|
296
285
|
}
|
|
297
286
|
};
|
|
298
287
|
send(url);
|
|
299
288
|
});
|
|
300
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Reads the `latest` dist-tag. Sends no custom Accept header: the registry
|
|
292
|
+
* answers `/<pkg>/latest` with 406 for npm's abbreviated-metadata type, which
|
|
293
|
+
* it only serves on the full packument.
|
|
294
|
+
*/
|
|
295
|
+
async function fetchLatestVersion() {
|
|
296
|
+
let url;
|
|
297
|
+
try {
|
|
298
|
+
const resolved = registryUrl();
|
|
299
|
+
if (resolved === null)
|
|
300
|
+
return null;
|
|
301
|
+
url = new URL(resolved);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const response = await httpsGet(url, {
|
|
307
|
+
deadline: Date.now() + REQUEST_TIMEOUT_MS,
|
|
308
|
+
followRedirects: true,
|
|
309
|
+
});
|
|
310
|
+
if (!response || response.status !== 200)
|
|
311
|
+
return null;
|
|
312
|
+
try {
|
|
313
|
+
const version = JSON.parse(response.body).version;
|
|
314
|
+
return typeof version === 'string' && SAFE_VERSION.test(version) ? version : null;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Where `openspec install` gets the standalone binary: install.sh and
|
|
322
|
+
* install.ps1 default to this releases page.
|
|
323
|
+
*/
|
|
324
|
+
const RELEASES_URL = 'https://github.com/Fission-AI/OpenSpec/releases';
|
|
325
|
+
/**
|
|
326
|
+
* The release `openspec install` would install, found the way install.sh and
|
|
327
|
+
* install.ps1 find it: stable tags from the releases feed, newest first, and
|
|
328
|
+
* the first one whose SHA256SUMS (uploaded last) exists. npm's `latest` is not
|
|
329
|
+
* the answer here: it is published minutes before the standalone files are
|
|
330
|
+
* attached to the GitHub release (and for good, if that job fails), and
|
|
331
|
+
* offering a version the installer cannot get makes `openspec install`
|
|
332
|
+
* quietly reinstall the old one.
|
|
333
|
+
*
|
|
334
|
+
* Stops at the first tag that is not newer than this CLI, since nothing from
|
|
335
|
+
* there on is an update, so an up-to-date binary costs only the feed request.
|
|
336
|
+
* Null when the answer is not known: the feed or a probe failed or ran out of
|
|
337
|
+
* time, or every listed newer release is still incomplete.
|
|
338
|
+
*/
|
|
339
|
+
async function fetchLatestStandaloneRelease() {
|
|
340
|
+
// The same total budget as the registry check, across every request.
|
|
341
|
+
const deadline = Date.now() + REQUEST_TIMEOUT_MS;
|
|
342
|
+
const feed = await httpsGet(new URL(`${RELEASES_URL}.atom`), { deadline, followRedirects: true });
|
|
343
|
+
if (!feed || feed.status !== 200)
|
|
344
|
+
return null;
|
|
345
|
+
const versions = [
|
|
346
|
+
...new Set(Array.from(feed.body.matchAll(/\/releases\/tag\/v(\d+\.\d+\.\d+)"/g), (m) => m[1])),
|
|
347
|
+
].filter((version) => SAFE_VERSION.test(version));
|
|
348
|
+
for (const version of versions) {
|
|
349
|
+
if (compareVersions(version, OPENSPEC_VERSION) <= 0)
|
|
350
|
+
return version;
|
|
351
|
+
// GitHub answers for an attached file with a redirect to its storage, so
|
|
352
|
+
// the file itself never needs downloading.
|
|
353
|
+
const probe = await httpsGet(new URL(`${RELEASES_URL}/download/v${version}/SHA256SUMS`), {
|
|
354
|
+
deadline,
|
|
355
|
+
followRedirects: false,
|
|
356
|
+
});
|
|
357
|
+
if (!probe)
|
|
358
|
+
return null;
|
|
359
|
+
if (probe.status === 200 || (probe.status >= 300 && probe.status < 400))
|
|
360
|
+
return version;
|
|
361
|
+
// Still being prepared: the installer moves on to the next release too.
|
|
362
|
+
if (probe.status === 404)
|
|
363
|
+
continue;
|
|
364
|
+
// Rate-limited or failing: not knowing beats a wrong answer.
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
301
369
|
/**
|
|
302
370
|
* Checks the registry while preserving enough detail for callers to explain
|
|
303
|
-
* why no newer version was reported. Never throws.
|
|
371
|
+
* why no newer version was reported. Never throws. Pass `requested` only when
|
|
372
|
+
* the user explicitly asked for the check.
|
|
304
373
|
*/
|
|
305
|
-
export async function checkForCliUpdate() {
|
|
306
|
-
|
|
374
|
+
export async function checkForCliUpdate(options = {}) {
|
|
375
|
+
// The standalone binary updates from GitHub releases, not npm, so the
|
|
376
|
+
// registry neither answers for it nor can disable its check.
|
|
377
|
+
const standalone = standaloneBinaryPath !== null;
|
|
378
|
+
if (!isCheckEnabled(options.requested === true) || (!standalone && registryUrl() === null)) {
|
|
307
379
|
return { status: 'disabled', latest: null };
|
|
308
380
|
}
|
|
309
381
|
try {
|
|
310
|
-
const latest = await fetchLatestVersion();
|
|
382
|
+
const latest = standalone ? await fetchLatestStandaloneRelease() : await fetchLatestVersion();
|
|
311
383
|
if (!latest)
|
|
312
384
|
return { status: 'offline', latest: null };
|
|
313
385
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fission-ai/openspec",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.1",
|
|
4
4
|
"description": "AI-native system for spec-driven development",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openspec",
|
|
@@ -38,20 +38,20 @@
|
|
|
38
38
|
"!dist/**/*.map"
|
|
39
39
|
],
|
|
40
40
|
"engines": {
|
|
41
|
-
"node": ">=
|
|
41
|
+
"node": ">=22.12.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@changesets/changelog-github": "^1.0.0",
|
|
45
45
|
"@changesets/cli": "^3.0.1",
|
|
46
|
-
"@types/node": "^
|
|
46
|
+
"@types/node": "^22.12.0",
|
|
47
47
|
"@types/picomatch": "^4.0.3",
|
|
48
|
-
"@vitest/ui": "^
|
|
48
|
+
"@vitest/ui": "^5.0.3",
|
|
49
49
|
"eslint": "^10.5.0",
|
|
50
50
|
"smol-toml": "^1.7.1",
|
|
51
51
|
"typescript": "^6.0.3",
|
|
52
52
|
"typescript-eslint": "^8.65.0",
|
|
53
|
-
"vite": "^
|
|
54
|
-
"vitest": "^
|
|
53
|
+
"vite": "^8.3.5",
|
|
54
|
+
"vitest": "^5.0.3"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@inquirer/core": "^12.0.0",
|
|
@@ -105,6 +105,7 @@ artifacts:
|
|
|
105
105
|
- Each scenario: `#### Scenario: <name>` with WHEN/THEN format
|
|
106
106
|
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
|
107
107
|
- Every requirement MUST have at least one scenario.
|
|
108
|
+
- Every scenario MUST have a WHEN/GIVEN step (its trigger) and a THEN step (its outcome). A scenario with only AND lines or plain prose still counts as a scenario, but `openspec validate` flags it as a warning: normal validation still passes, but `openspec validate --strict` fails on it.
|
|
108
109
|
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions in ADDED requirements and in the main spec. This is a warning: normal validation still passes, but `openspec validate --strict` fails on it. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length. Split an existing long requirement only when the user asks for it, in a change made for that purpose: under MODIFIED, keep its header and every scenario and cut its description down to one behavior without changing its meaning, then add each behavior you removed as its own ADDED requirement with its own scenarios.
|
|
109
110
|
|
|
110
111
|
New capabilities only: the delta spec's first section is `## Purpose` -
|