@fission-ai/openspec 1.16.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/dist/cli/index.js +1 -1
- package/dist/core/parsers/change-parser.js +3 -2
- package/dist/core/parsers/markdown-parser.js +3 -2
- package/dist/core/parsers/requirement-blocks.d.ts +18 -0
- package/dist/core/parsers/requirement-blocks.js +47 -17
- package/dist/core/parsers/spec-structure.js +2 -3
- package/dist/core/specs-apply.js +11 -3
- package/dist/core/validation/purpose-placeholder.js +1 -1
- package/dist/core/validation/validator.js +5 -2
- package/dist/core/version-check.d.ts +5 -2
- package/dist/core/version-check.js +115 -43
- package/package.json +4 -4
package/dist/cli/index.js
CHANGED
|
@@ -123,7 +123,7 @@ program
|
|
|
123
123
|
.action(async (options) => {
|
|
124
124
|
const { getCliInstallInfo, checkForCliUpdate, getCliUpdateCommand, canSelfUpgrade, buildVersionReportLines, } = await import('../core/version-check.js');
|
|
125
125
|
const install = getCliInstallInfo();
|
|
126
|
-
const update = options.check ? await checkForCliUpdate() : undefined;
|
|
126
|
+
const update = options.check ? await checkForCliUpdate({ requested: true }) : undefined;
|
|
127
127
|
const command = update?.status === 'available' ? getCliUpdateCommand(install) : null;
|
|
128
128
|
const output = {
|
|
129
129
|
schemaVersion: 1,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MarkdownParser } from './markdown-parser.js';
|
|
2
2
|
import { buildCodeFenceMask } from './requirement-text.js';
|
|
3
|
-
import { parseDeltaSpec } from './requirement-blocks.js';
|
|
3
|
+
import { parseDeltaSpec, stripClosingSequence } from './requirement-blocks.js';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { promises as fs } from 'fs';
|
|
6
6
|
import { discoverSpecFiles } from '../../utils/spec-discovery.js';
|
|
@@ -171,7 +171,8 @@ export class ChangeParser extends MarkdownParser {
|
|
|
171
171
|
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
172
172
|
if (headerMatch) {
|
|
173
173
|
const level = headerMatch[1].length;
|
|
174
|
-
|
|
174
|
+
// `## Purpose ##` is the same heading as `## Purpose`.
|
|
175
|
+
const title = stripClosingSequence(headerMatch[2]);
|
|
175
176
|
const contentLines = this.getContentUntilNextHeaderFromLines(lines, codeFenceLineMask, i + 1, level);
|
|
176
177
|
const section = {
|
|
177
178
|
level,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildCodeFenceMask, extractRequirementText, hasScenarioBody } from './requirement-text.js';
|
|
2
|
-
import { normalizeRequirementName, scenarioNameFromHeaderText } from './requirement-blocks.js';
|
|
2
|
+
import { normalizeRequirementName, scenarioNameFromHeaderText, stripClosingSequence } from './requirement-blocks.js';
|
|
3
3
|
export class MarkdownParser {
|
|
4
4
|
lines;
|
|
5
5
|
codeFenceLineMask;
|
|
@@ -68,7 +68,8 @@ export class MarkdownParser {
|
|
|
68
68
|
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
69
69
|
if (headerMatch) {
|
|
70
70
|
const level = headerMatch[1].length;
|
|
71
|
-
|
|
71
|
+
// `## Purpose ##` is the same heading as `## Purpose`.
|
|
72
|
+
const title = stripClosingSequence(headerMatch[2]);
|
|
72
73
|
const content = this.getContentUntilNextHeader(i + 1, level);
|
|
73
74
|
const section = {
|
|
74
75
|
level,
|
|
@@ -10,6 +10,15 @@ export interface RequirementsSectionParts {
|
|
|
10
10
|
bodyBlocks: RequirementBlock[];
|
|
11
11
|
after: string;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Drop an ATX heading's closing run of `#`s: `## Purpose ##` renders as
|
|
15
|
+
* `Purpose`, so the run is not part of the title. As for scenario names, only a
|
|
16
|
+
* run preceded by a space or tab closes the heading, so `C#` keeps its `#`, and
|
|
17
|
+
* `[ \t]` rather than `\s` keeps an NBSP-separated run in the title.
|
|
18
|
+
*/
|
|
19
|
+
export declare function stripClosingSequence(title: string): string;
|
|
20
|
+
/** The main spec's `## Requirements` header, closing run allowed (`## Requirements ##`). */
|
|
21
|
+
export declare const REQUIREMENTS_SECTION_HEADER_REGEX: RegExp;
|
|
13
22
|
export declare function normalizeRequirementName(name: string): string;
|
|
14
23
|
/**
|
|
15
24
|
* Case- and whitespace-insensitive fold of a requirement name. Requirement
|
|
@@ -105,6 +114,15 @@ export interface DeltaPlan {
|
|
|
105
114
|
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
|
|
106
115
|
*/
|
|
107
116
|
export declare function parseDeltaSpec(content: string): DeltaPlan;
|
|
117
|
+
/**
|
|
118
|
+
* True when an orphaned requirement sits under a `## ... Requirements` header
|
|
119
|
+
* that is not one of the four delta sections - `## DEPRECATED Requirements`,
|
|
120
|
+
* `## Add Requirements`, `## Requirements`. Such a header reads as a delta
|
|
121
|
+
* operation, so its requirements were meant to be applied: validate reports
|
|
122
|
+
* them as an ERROR and archive refuses (#2195). Orphans under prose sections
|
|
123
|
+
* (`## Notes`, `## Behavior`) or above the first `## ` stay warnings.
|
|
124
|
+
*/
|
|
125
|
+
export declare function isUnrecognizedDeltaSection(section: string | null): boolean;
|
|
108
126
|
export declare function describeUnappliedContent(content: UnappliedContent, sentenceStart?: boolean): string;
|
|
109
127
|
/**
|
|
110
128
|
* One `#### ` child of a requirement block: its rendered name and its lines,
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
|
|
2
|
+
/**
|
|
3
|
+
* Drop an ATX heading's closing run of `#`s: `## Purpose ##` renders as
|
|
4
|
+
* `Purpose`, so the run is not part of the title. As for scenario names, only a
|
|
5
|
+
* run preceded by a space or tab closes the heading, so `C#` keeps its `#`, and
|
|
6
|
+
* `[ \t]` rather than `\s` keeps an NBSP-separated run in the title.
|
|
7
|
+
*/
|
|
8
|
+
export function stripClosingSequence(title) {
|
|
9
|
+
return title.replace(/[ \t]+#+[ \t]*$/, '').trim();
|
|
10
|
+
}
|
|
11
|
+
/** The main spec's `## Requirements` header, closing run allowed (`## Requirements ##`). */
|
|
12
|
+
export const REQUIREMENTS_SECTION_HEADER_REGEX = /^##\s+Requirements(?:[ \t]+#+)?\s*$/i;
|
|
2
13
|
export function normalizeRequirementName(name) {
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// only a run preceded by a space or tab closes the heading, so `C#` keeps its
|
|
6
|
-
// `#`, and `[ \t]` rather than `\s` keeps an NBSP-separated run in the name.
|
|
7
|
-
return name.replace(/[ \t]+#+[ \t]*$/, '').trim();
|
|
14
|
+
// `### Requirement: Foo ###` names the requirement `Foo`.
|
|
15
|
+
return stripClosingSequence(name);
|
|
8
16
|
}
|
|
9
17
|
/**
|
|
10
18
|
* Case- and whitespace-insensitive fold of a requirement name. Requirement
|
|
@@ -26,7 +34,7 @@ export function extractRequirementsSection(content) {
|
|
|
26
34
|
const normalized = normalizeLineEndings(content);
|
|
27
35
|
const lines = normalized.split('\n');
|
|
28
36
|
const fenceMask = buildCodeFenceMask(lines);
|
|
29
|
-
const reqHeaderIndex = lines.findIndex((l, i) => !fenceMask[i] &&
|
|
37
|
+
const reqHeaderIndex = lines.findIndex((l, i) => !fenceMask[i] && REQUIREMENTS_SECTION_HEADER_REGEX.test(l));
|
|
30
38
|
if (reqHeaderIndex === -1) {
|
|
31
39
|
// No requirements section; create an empty one at the end
|
|
32
40
|
const before = content.trimEnd();
|
|
@@ -154,6 +162,19 @@ export function parseDeltaSpec(content) {
|
|
|
154
162
|
* under it would be dropped without this warning.
|
|
155
163
|
*/
|
|
156
164
|
const DELTA_SECTION_TITLES = new Set(['ADDED Requirements', 'MODIFIED Requirements', 'REMOVED Requirements', 'RENAMED Requirements'].map((title) => title.toLowerCase()));
|
|
165
|
+
/**
|
|
166
|
+
* True when an orphaned requirement sits under a `## ... Requirements` header
|
|
167
|
+
* that is not one of the four delta sections - `## DEPRECATED Requirements`,
|
|
168
|
+
* `## Add Requirements`, `## Requirements`. Such a header reads as a delta
|
|
169
|
+
* operation, so its requirements were meant to be applied: validate reports
|
|
170
|
+
* them as an ERROR and archive refuses (#2195). Orphans under prose sections
|
|
171
|
+
* (`## Notes`, `## Behavior`) or above the first `## ` stay warnings.
|
|
172
|
+
*/
|
|
173
|
+
export function isUnrecognizedDeltaSection(section) {
|
|
174
|
+
return (section !== null &&
|
|
175
|
+
/\brequirements?$/i.test(section) &&
|
|
176
|
+
!DELTA_SECTION_TITLES.has(section.toLowerCase()));
|
|
177
|
+
}
|
|
157
178
|
/**
|
|
158
179
|
* Every canonical `### Requirement:` header that is not inside a delta section,
|
|
159
180
|
* in document order.
|
|
@@ -161,10 +182,12 @@ const DELTA_SECTION_TITLES = new Set(['ADDED Requirements', 'MODIFIED Requiremen
|
|
|
161
182
|
* Walks the whole file rather than the parsed sections so a requirement written
|
|
162
183
|
* ABOVE the first `## ` header is reported too - it is dropped just as silently
|
|
163
184
|
* as one under `## Notes`. Fenced lines are skipped, so a requirement shown
|
|
164
|
-
* inside a markdown example is not mistaken for an authored one
|
|
185
|
+
* inside a markdown example is not mistaken for an authored one, and so are
|
|
186
|
+
* HTML comments, so a commented-out draft is not reported as dropped.
|
|
165
187
|
*/
|
|
166
188
|
function findOrphanedRequirements(lines, fenceMask) {
|
|
167
189
|
const orphans = [];
|
|
190
|
+
const masked = maskHtmlComments(lines, fenceMask);
|
|
168
191
|
let section = null;
|
|
169
192
|
for (let i = 0; i < lines.length; i++) {
|
|
170
193
|
if (fenceMask[i])
|
|
@@ -172,12 +195,12 @@ function findOrphanedRequirements(lines, fenceMask) {
|
|
|
172
195
|
// The same `## ` test splitTopLevelSections uses, so both agree on sections.
|
|
173
196
|
const sectionMatch = lines[i].match(/^(##)\s+(.+)$/);
|
|
174
197
|
if (sectionMatch) {
|
|
175
|
-
section = sectionMatch[2]
|
|
198
|
+
section = stripClosingSequence(sectionMatch[2]);
|
|
176
199
|
continue;
|
|
177
200
|
}
|
|
178
201
|
if (section !== null && DELTA_SECTION_TITLES.has(section.toLowerCase()))
|
|
179
202
|
continue;
|
|
180
|
-
const header =
|
|
203
|
+
const header = masked[i].match(REQUIREMENT_HEADER_REGEX);
|
|
181
204
|
if (header) {
|
|
182
205
|
orphans.push({
|
|
183
206
|
name: normalizeRequirementName(header[1]),
|
|
@@ -201,18 +224,24 @@ export function describeUnappliedContent(content, sentenceStart = false) {
|
|
|
201
224
|
* (warned about separately), REMOVED/RENAMED bodies, and stray `###` headers
|
|
202
225
|
* before the first requirement (INFO since #498) are not reported.
|
|
203
226
|
*/
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Blank out comments outside fences, keeping line count; an unterminated one
|
|
229
|
+
* runs to EOF. Fenced lines keep their text, so a `<!--` in a code sample never
|
|
230
|
+
* hides what follows the fence.
|
|
231
|
+
*/
|
|
232
|
+
function maskHtmlComments(lines, fenceMask) {
|
|
210
233
|
const comments = lines
|
|
211
234
|
.map((line, i) => (fenceMask[i] ? '' : line))
|
|
212
235
|
.join('\n')
|
|
213
236
|
.replace(/<!--[\s\S]*?(?:--!?>|$)/g, (m) => m.replace(/[^\n]/g, ' '))
|
|
214
237
|
.split('\n');
|
|
215
|
-
|
|
238
|
+
return lines.map((line, i) => (fenceMask[i] ? line : comments[i]));
|
|
239
|
+
}
|
|
240
|
+
function findUnappliedContent(lines, fenceMask) {
|
|
241
|
+
// Comments only decide whether a line holds text: sections come from the
|
|
242
|
+
// same split the merge reads, where a `## ` line inside a comment is still a
|
|
243
|
+
// boundary.
|
|
244
|
+
const masked = maskHtmlComments(lines, fenceMask);
|
|
216
245
|
const sections = splitTopLevelSections(lines, fenceMask);
|
|
217
246
|
const firstText = (from, to, skip) => {
|
|
218
247
|
for (let i = from; i < to; i++)
|
|
@@ -260,7 +289,8 @@ function splitTopLevelSections(lines, fenceMask) {
|
|
|
260
289
|
continue;
|
|
261
290
|
const m = lines[i].match(/^(##)\s+(.+)$/);
|
|
262
291
|
if (m) {
|
|
263
|
-
|
|
292
|
+
// `## ADDED Requirements ##` is the same section as `## ADDED Requirements`.
|
|
293
|
+
indices.push({ title: stripClosingSequence(m[2]), index: i });
|
|
264
294
|
}
|
|
265
295
|
}
|
|
266
296
|
for (let i = 0; i < indices.length; i++) {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { buildCodeFenceMask } from './code-fence.js';
|
|
2
|
-
import { normalizeRequirementName } from './requirement-blocks.js';
|
|
3
|
-
const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
|
|
2
|
+
import { normalizeRequirementName, REQUIREMENTS_SECTION_HEADER_REGEX as REQUIREMENTS_SECTION_HEADER, } from './requirement-blocks.js';
|
|
4
3
|
const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
|
|
5
|
-
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
|
|
4
|
+
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements(?:[ \t]+#+)?\s*$/i;
|
|
6
5
|
const REQUIREMENT_HEADER = /^###\s*Requirement:\s*(.+)\s*$/i;
|
|
7
6
|
export function findMainSpecStructureIssues(content) {
|
|
8
7
|
const normalized = content.replace(/\r\n?/g, '\n');
|
package/dist/core/specs-apply.js
CHANGED
|
@@ -8,7 +8,7 @@ import { promises as fs } from 'fs';
|
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import chalk from 'chalk';
|
|
11
|
-
import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
|
|
11
|
+
import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, foldRequirementName, isUnrecognizedDeltaSection, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
|
|
12
12
|
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
|
|
13
13
|
import { buildCodeFenceMask } from './parsers/code-fence.js';
|
|
14
14
|
import { MarkdownParser } from './parsers/markdown-parser.js';
|
|
@@ -137,7 +137,15 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
137
137
|
// A well-formed requirement written outside every delta section is not
|
|
138
138
|
// applied. Say so here as well as in validate: archive is the last point at
|
|
139
139
|
// which the author can still notice, and the block reads exactly like one
|
|
140
|
-
// that would have applied.
|
|
140
|
+
// that would have applied. Under an unrecognized `## ... Requirements`
|
|
141
|
+
// header it was meant as a delta operation, so refuse rather than drop it -
|
|
142
|
+
// the same verdict validate gives (#2195).
|
|
143
|
+
const misplaced = plan.orphanedRequirements.find((o) => isUnrecognizedDeltaSection(o.section));
|
|
144
|
+
if (misplaced) {
|
|
145
|
+
throw new Error(`${specName} validation failed - requirement "${misplaced.name}" (line ${misplaced.line}) is under ` +
|
|
146
|
+
`"## ${misplaced.section}", which is not a delta section, so it would not be applied. ` +
|
|
147
|
+
`Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`);
|
|
148
|
+
}
|
|
141
149
|
for (const orphan of plan.orphanedRequirements) {
|
|
142
150
|
const where = orphan.section
|
|
143
151
|
? `under "## ${orphan.section}"`
|
|
@@ -1141,7 +1149,7 @@ function extractPurposeSection(content) {
|
|
|
1141
1149
|
const masked = maskHtmlComments(normalized).split('\n');
|
|
1142
1150
|
const fenceMask = buildCodeFenceMask(masked);
|
|
1143
1151
|
const isStructural = (i) => !fenceMask[i];
|
|
1144
|
-
const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose\s*$/i.test(line));
|
|
1152
|
+
const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose(?:[ \t]+#+)?\s*$/i.test(line));
|
|
1145
1153
|
if (start === -1)
|
|
1146
1154
|
return undefined;
|
|
1147
1155
|
let end = masked.length;
|
|
@@ -25,7 +25,7 @@ const MARKER_PUNCTUATION = '(?=[ \\t]*(?:$|\\n|[:\\-\u2013\u2014.,;()\\[\\]{}]))
|
|
|
25
25
|
const LEADING_MARKER_SHOUTED = new RegExp(`^(?:TBD|TODO)${WORD_END}`, 'u');
|
|
26
26
|
/** Any other case: a marker only when punctuation or the line end says so. */
|
|
27
27
|
const LEADING_MARKER_PUNCTUATED = new RegExp(`^(?:TBD|TODO)${WORD_END}${MARKER_PUNCTUATION}`, 'iu');
|
|
28
|
-
const PURPOSE_HEADER = /^ {0,3}##(?!#)[ \t]+Purpose[ \t]*$/i;
|
|
28
|
+
const PURPOSE_HEADER = /^ {0,3}##(?!#)[ \t]+Purpose(?:[ \t]+#+)?[ \t]*$/i;
|
|
29
29
|
const TOP_LEVEL_HEADER = /^ {0,3}#{1,2}(?!#)[ \t]+/;
|
|
30
30
|
/**
|
|
31
31
|
* The lines of `text` that sit outside a fenced code block, with line endings
|
|
@@ -5,7 +5,7 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
|
|
|
5
5
|
import { ChangeParser } from '../parsers/change-parser.js';
|
|
6
6
|
import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
|
|
7
7
|
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
|
|
8
|
-
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, parseScenarioBlocks, isScenarioLabeledHeader, describeUnappliedContent, } from '../parsers/requirement-blocks.js';
|
|
8
|
+
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, parseScenarioBlocks, isScenarioLabeledHeader, describeUnappliedContent, isUnrecognizedDeltaSection, } from '../parsers/requirement-blocks.js';
|
|
9
9
|
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, hasScenarioBody, scenarioStepCoverage, } from '../parsers/requirement-text.js';
|
|
10
10
|
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
11
11
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
@@ -195,12 +195,15 @@ export class Validator {
|
|
|
195
195
|
// ignored - reported as a WARNING rather than an ERROR because a
|
|
196
196
|
// handful of pre-format archived changes still carry this shape, and
|
|
197
197
|
// the fix is to move the block, not to reject the change outright.
|
|
198
|
+
// Under an unrecognized `## ... Requirements` header it is an ERROR:
|
|
199
|
+
// that header reads as a delta operation, so the requirement was meant
|
|
200
|
+
// to be applied and archive refuses it too (#2195).
|
|
198
201
|
for (const orphan of plan.orphanedRequirements) {
|
|
199
202
|
const where = orphan.section
|
|
200
203
|
? `under "## ${orphan.section}"`
|
|
201
204
|
: 'above the first "## " section';
|
|
202
205
|
issues.push({
|
|
203
|
-
level: 'WARNING',
|
|
206
|
+
level: isUnrecognizedDeltaSection(orphan.section) ? 'ERROR' : 'WARNING',
|
|
204
207
|
path: entryPath,
|
|
205
208
|
line: orphan.line,
|
|
206
209
|
message: `Requirement "${orphan.name}" is ${where}, which is not a delta section, so it is ignored. Move it under "## ADDED Requirements", "## MODIFIED Requirements", "## REMOVED Requirements", or "## RENAMED Requirements".`,
|
|
@@ -28,9 +28,12 @@ export interface CliUpdateCheck {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Checks the registry while preserving enough detail for callers to explain
|
|
31
|
-
* why no newer version was reported. Never throws.
|
|
31
|
+
* why no newer version was reported. Never throws. Pass `requested` only when
|
|
32
|
+
* the user explicitly asked for the check.
|
|
32
33
|
*/
|
|
33
|
-
export declare function checkForCliUpdate(
|
|
34
|
+
export declare function checkForCliUpdate(options?: {
|
|
35
|
+
requested?: boolean;
|
|
36
|
+
}): Promise<CliUpdateCheck>;
|
|
34
37
|
/**
|
|
35
38
|
* Returns the published version when the installed CLI is behind it, otherwise
|
|
36
39
|
* null. Kept as the compatibility surface used by `openspec update`.
|
|
@@ -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.16.
|
|
3
|
+
"version": "1.16.1",
|
|
4
4
|
"description": "AI-native system for spec-driven development",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openspec",
|
|
@@ -43,15 +43,15 @@
|
|
|
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
53
|
"vite": "^8.3.5",
|
|
54
|
-
"vitest": "^
|
|
54
|
+
"vitest": "^5.0.3"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@inquirer/core": "^12.0.0",
|