@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
|
@@ -101,6 +101,45 @@ export function extractRequirementText(headerTitle, bodyLines) {
|
|
|
101
101
|
export function countScenarios(bodyLines) {
|
|
102
102
|
return readScenarioBodies(bodyLines).filter(hasScenarioBody).length;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* A step keyword as it is actually written: bolded in any case, with or
|
|
106
|
+
* without a colon inside the bold (`**WHEN**`, `**Given:**`), or bare and
|
|
107
|
+
* uppercase (`WHEN`). Bold carries the step-ness, so its case can be liberal;
|
|
108
|
+
* a bare word must be uppercase to count — lowercase `when`/`then` are
|
|
109
|
+
* ordinary English prose, and the missing-SHALL/MUST check is uppercase-only
|
|
110
|
+
* for the same reason. Bold is `**` or `__`; the bare pattern cannot catch
|
|
111
|
+
* `__WHEN__`, because `_` is a word character and `\b` never fires beside it.
|
|
112
|
+
*/
|
|
113
|
+
const BOLD_TRIGGER_STEP = /(?:\*\*|__)(?:WHEN|GIVEN):?(?:\*\*|__)/i;
|
|
114
|
+
const BARE_TRIGGER_STEP = /\b(?:WHEN|GIVEN)\b/;
|
|
115
|
+
const BOLD_OUTCOME_STEP = /(?:\*\*|__)THEN:?(?:\*\*|__)/i;
|
|
116
|
+
const BARE_OUTCOME_STEP = /\bTHEN\b/;
|
|
117
|
+
/**
|
|
118
|
+
* Whether a scenario block (header line included, as `parseScenarioBlocks`
|
|
119
|
+
* returns it) carries a trigger step (**WHEN** or **GIVEN**) and an outcome
|
|
120
|
+
* step (**THEN**) outside fenced examples. `AND` only continues a step that is
|
|
121
|
+
* already there, so neither half can be carried by an `AND` line; a keyword
|
|
122
|
+
* inside a fenced block is documentation, not a step — the same masking
|
|
123
|
+
* `extractRequirementBody` applies to requirement text.
|
|
124
|
+
*/
|
|
125
|
+
export function scenarioStepCoverage(scenarioRaw) {
|
|
126
|
+
const lines = scenarioRaw.replace(/\r\n?/g, '\n').split('\n');
|
|
127
|
+
const mask = buildCodeFenceMask(lines);
|
|
128
|
+
let hasTrigger = false;
|
|
129
|
+
let hasOutcome = false;
|
|
130
|
+
// Line 0 is the scenario's own header: a keyword in the title
|
|
131
|
+
// (`#### Scenario: What happens THEN`) is a name, not a step.
|
|
132
|
+
for (let i = 1; i < lines.length; i++) {
|
|
133
|
+
if (mask[i])
|
|
134
|
+
continue;
|
|
135
|
+
const line = lines[i];
|
|
136
|
+
if (BOLD_TRIGGER_STEP.test(line) || BARE_TRIGGER_STEP.test(line))
|
|
137
|
+
hasTrigger = true;
|
|
138
|
+
if (BOLD_OUTCOME_STEP.test(line) || BARE_OUTCOME_STEP.test(line))
|
|
139
|
+
hasOutcome = true;
|
|
140
|
+
}
|
|
141
|
+
return { hasTrigger, hasOutcome };
|
|
142
|
+
}
|
|
104
143
|
/** Count the `#### ` headers in a requirement block that have no body under them. */
|
|
105
144
|
export function countEmptyScenarios(bodyLines) {
|
|
106
145
|
return readScenarioBodies(bodyLines).filter((body) => !hasScenarioBody(body)).length;
|
|
@@ -1,9 +1,8 @@
|
|
|
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;
|
|
6
|
-
const REQUIREMENT_HEADER = /^###\s
|
|
4
|
+
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements(?:[ \t]+#+)?\s*$/i;
|
|
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');
|
|
9
8
|
const stripped = stripFencedCodeBlocksPreservingLines(normalized);
|
|
@@ -70,6 +70,16 @@ export declare const MAX_CONTEXT_SIZE: number;
|
|
|
70
70
|
* @returns Parsed config or null if file doesn't exist
|
|
71
71
|
*/
|
|
72
72
|
export declare function readProjectConfig(projectRoot: string): ProjectConfig | null;
|
|
73
|
+
/**
|
|
74
|
+
* Warning-silent check for a config file that cannot be read or is not valid
|
|
75
|
+
* YAML at all. `readProjectConfig` ignores such a file with a warning, so
|
|
76
|
+
* `validate` uses this to fail instead and give CI a non-zero exit (#1892).
|
|
77
|
+
*/
|
|
78
|
+
export declare function readConfigParseError(projectRoot: string): {
|
|
79
|
+
kind: 'read' | 'parse';
|
|
80
|
+
filePath: string;
|
|
81
|
+
message: string;
|
|
82
|
+
} | null;
|
|
73
83
|
/**
|
|
74
84
|
* Validate artifact IDs in rules against the artifacts of every available
|
|
75
85
|
* schema. The `rules:` map is global, but each change can use a different
|
|
@@ -364,6 +364,32 @@ export function readProjectConfig(projectRoot) {
|
|
|
364
364
|
return null;
|
|
365
365
|
}
|
|
366
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Warning-silent check for a config file that cannot be read or is not valid
|
|
369
|
+
* YAML at all. `readProjectConfig` ignores such a file with a warning, so
|
|
370
|
+
* `validate` uses this to fail instead and give CI a non-zero exit (#1892).
|
|
371
|
+
*/
|
|
372
|
+
export function readConfigParseError(projectRoot) {
|
|
373
|
+
const filePath = resolveConfigFilePath(projectRoot);
|
|
374
|
+
if (filePath === null) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const firstLine = (error) => error instanceof Error ? error.message.split('\n')[0] : String(error);
|
|
378
|
+
let content;
|
|
379
|
+
try {
|
|
380
|
+
content = readFileSync(filePath, 'utf-8');
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
return { kind: 'read', filePath, message: firstLine(error) };
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
parseYaml(content);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
return { kind: 'parse', filePath, message: firstLine(error) };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
367
393
|
function configPathForWarnings(projectRoot) {
|
|
368
394
|
return resolveConfigFilePath(projectRoot) ?? path.join(projectRoot, 'openspec', 'config.yaml');
|
|
369
395
|
}
|
|
@@ -79,6 +79,12 @@ export type RegisteredStoreInspection = {
|
|
|
79
79
|
problems: string;
|
|
80
80
|
};
|
|
81
81
|
export declare function inspectRegisteredStore(id: string, storeRoot: string): Promise<RegisteredStoreInspection>;
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the OpenSpec root for a command, in precedence order: `--store <id>`,
|
|
84
|
+
* the nearest local `openspec/` directory, the global `defaultStore`, then the
|
|
85
|
+
* registered-store hint (which names a store matching the repo's origin, if
|
|
86
|
+
* any) or an implicit root.
|
|
87
|
+
*/
|
|
82
88
|
export declare function resolveOpenSpecRoot(options?: ResolveOpenSpecRootOptions): Promise<ResolvedOpenSpecRoot>;
|
|
83
89
|
export interface RootOutput {
|
|
84
90
|
path: string;
|
|
@@ -26,6 +26,8 @@ import * as path from 'node:path';
|
|
|
26
26
|
import { StoreError } from './store/errors.js';
|
|
27
27
|
import { getStoreMetadataPath, listStoreRegistryEntries, readStoreRegistryState, readOptionalStoreMetadataState, validateStoreId, } from './store/foundation.js';
|
|
28
28
|
import { getStoreRootForBackend } from './store/registry.js';
|
|
29
|
+
import { gitOriginUrl, repoNameFromGitUrl } from './store/git.js';
|
|
30
|
+
import { toKebabCase, isKebabId } from './id.js';
|
|
29
31
|
import { inspectOpenSpecRoot } from './openspec-root.js';
|
|
30
32
|
import { findRepoPlanningRootSync } from './planning-home.js';
|
|
31
33
|
import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
|
|
@@ -251,6 +253,32 @@ async function resolveDefaultStoreRoot(id, globalDataDir) {
|
|
|
251
253
|
throw error;
|
|
252
254
|
}
|
|
253
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Git-remote store suggestion (#2076): the registered store whose id matches
|
|
258
|
+
* this repo's origin name, or null. Only ever named in the no-root error, never
|
|
259
|
+
* selected: a basename is not enough authority to redirect writes, because
|
|
260
|
+
* `company-a/api` and `company-b/api` both match a store called `api`.
|
|
261
|
+
*/
|
|
262
|
+
async function suggestStoreFromGitRemote(startPath, registeredIds) {
|
|
263
|
+
// `git -C <path>` needs a directory; a file entry path would make the probe
|
|
264
|
+
// fail. canonicalDirectory resolves that.
|
|
265
|
+
const url = await gitOriginUrl(canonicalDirectory(startPath));
|
|
266
|
+
if (!url)
|
|
267
|
+
return null;
|
|
268
|
+
const repoName = repoNameFromGitUrl(url);
|
|
269
|
+
if (!repoName)
|
|
270
|
+
return null;
|
|
271
|
+
const candidateId = toKebabCase(repoName);
|
|
272
|
+
if (!candidateId || !isKebabId(candidateId))
|
|
273
|
+
return null;
|
|
274
|
+
return registeredIds.includes(candidateId) ? candidateId : null;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Resolve the OpenSpec root for a command, in precedence order: `--store <id>`,
|
|
278
|
+
* the nearest local `openspec/` directory, the global `defaultStore`, then the
|
|
279
|
+
* registered-store hint (which names a store matching the repo's origin, if
|
|
280
|
+
* any) or an implicit root.
|
|
281
|
+
*/
|
|
254
282
|
export async function resolveOpenSpecRoot(options = {}) {
|
|
255
283
|
if (options.storePath !== undefined) {
|
|
256
284
|
throw new RootSelectionError('--store-path is not supported. Register the path with openspec store register <path>, then select it with --store <id>.', 'store_path_not_supported', {
|
|
@@ -284,6 +312,15 @@ export async function resolveOpenSpecRoot(options = {}) {
|
|
|
284
312
|
? listStoreRegistryEntries(registry).map((entry) => entry.id)
|
|
285
313
|
: [];
|
|
286
314
|
if (registeredIds.length > 0) {
|
|
315
|
+
// Only spawn git on this failure path: a repo whose origin name matches a
|
|
316
|
+
// registered store gets that store suggested, never selected.
|
|
317
|
+
const suggested = await suggestStoreFromGitRemote(startPath, registeredIds);
|
|
318
|
+
if (suggested) {
|
|
319
|
+
throw new RootSelectionError(`No OpenSpec root found in the current directory or its ancestors. This repo's origin name matches the registered store "${suggested}". To use it, rerun with --store ${suggested}, or add "store: ${suggested}" to openspec/config.yaml so every command here uses it.`, 'no_root_with_registered_stores', {
|
|
320
|
+
target: 'openspec.root',
|
|
321
|
+
fix: `Rerun with --store ${suggested}, or add "store: ${suggested}" to openspec/config.yaml.`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
287
324
|
throw new RootSelectionError(`No OpenSpec root found in the current directory or its ancestors. Registered stores: ${registeredIds.join(', ')}. Pass --store <id> to use one, or run openspec init to create a local root.`, 'no_root_with_registered_stores', {
|
|
288
325
|
target: 'openspec.root',
|
|
289
326
|
fix: `Rerun with --store <id> (registered: ${registeredIds.join(', ')}) or run openspec init.`,
|
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;
|
package/dist/core/store/git.d.ts
CHANGED
|
@@ -47,11 +47,32 @@ export declare function isProbeResourceFailure(error: unknown): boolean;
|
|
|
47
47
|
export declare function gitHasCommits(storeRoot: string): Promise<boolean | null>;
|
|
48
48
|
export declare function gitHasUncommittedChanges(storeRoot: string): Promise<boolean | null>;
|
|
49
49
|
export declare function gitHasRemote(storeRoot: string): Promise<boolean | null>;
|
|
50
|
+
/**
|
|
51
|
+
* Env vars that point git at a repository other than the one `-C` names.
|
|
52
|
+
* Git sets some of them for hooks, so a CLI run from a hook inherits them.
|
|
53
|
+
*/
|
|
54
|
+
export declare const GIT_REPO_LOCATION_ENV_VARS: readonly ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR"];
|
|
55
|
+
/** A copy of `env` without {@link GIT_REPO_LOCATION_ENV_VARS}. */
|
|
56
|
+
export declare function envWithoutGitRepoLocation(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
50
57
|
/**
|
|
51
58
|
* The configured origin URL, read from local Git config only — never a
|
|
52
|
-
* network touch. Null when there is no repository or no origin.
|
|
59
|
+
* network touch. Null when there is no repository or no origin. Inherited
|
|
60
|
+
* repository-location vars are dropped so the answer is always about
|
|
61
|
+
* `storeRoot`: root selection runs this from wherever the CLI started,
|
|
62
|
+
* including inside another repository's git hook.
|
|
53
63
|
*/
|
|
54
64
|
export declare function gitOriginUrl(storeRoot: string): Promise<string | null>;
|
|
65
|
+
/**
|
|
66
|
+
* The repository name from an origin URL, with no host or `.git` suffix.
|
|
67
|
+
* Handles the three common remote spellings:
|
|
68
|
+
*
|
|
69
|
+
* - `git@github.com:user/repo.git`
|
|
70
|
+
* - `https://github.com/user/repo.git`
|
|
71
|
+
* - `ssh://git@github.com/user/repo.git`
|
|
72
|
+
*
|
|
73
|
+
* Returns the last path segment, or `null` when the URL has no parseable name.
|
|
74
|
+
*/
|
|
75
|
+
export declare function repoNameFromGitUrl(url: string): string | null;
|
|
55
76
|
export interface GitTrackingDrift {
|
|
56
77
|
ahead: number;
|
|
57
78
|
behind: number;
|
package/dist/core/store/git.js
CHANGED
|
@@ -137,9 +137,9 @@ export function isProbeResourceFailure(error) {
|
|
|
137
137
|
code === 'ETIMEDOUT' ||
|
|
138
138
|
(killed === true && signal === 'SIGKILL'));
|
|
139
139
|
}
|
|
140
|
-
async function gitProbe(storeRoot, args) {
|
|
140
|
+
async function gitProbe(storeRoot, args, options = {}) {
|
|
141
141
|
try {
|
|
142
|
-
const { stdout } = await execFileAsync('git', ['-C', storeRoot, ...args]);
|
|
142
|
+
const { stdout } = await execFileAsync('git', ['-C', storeRoot, ...args], options);
|
|
143
143
|
return stdout;
|
|
144
144
|
}
|
|
145
145
|
catch (error) {
|
|
@@ -175,15 +175,70 @@ export async function gitHasRemote(storeRoot) {
|
|
|
175
175
|
const stdout = await gitProbe(storeRoot, ['remote']);
|
|
176
176
|
return stdout === null ? null : stdout.trim().length > 0;
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Env vars that point git at a repository other than the one `-C` names.
|
|
180
|
+
* Git sets some of them for hooks, so a CLI run from a hook inherits them.
|
|
181
|
+
*/
|
|
182
|
+
export const GIT_REPO_LOCATION_ENV_VARS = [
|
|
183
|
+
'GIT_DIR',
|
|
184
|
+
'GIT_WORK_TREE',
|
|
185
|
+
'GIT_INDEX_FILE',
|
|
186
|
+
'GIT_OBJECT_DIRECTORY',
|
|
187
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
188
|
+
'GIT_COMMON_DIR',
|
|
189
|
+
];
|
|
190
|
+
/** A copy of `env` without {@link GIT_REPO_LOCATION_ENV_VARS}. */
|
|
191
|
+
export function envWithoutGitRepoLocation(env = process.env) {
|
|
192
|
+
const clean = { ...env };
|
|
193
|
+
for (const key of GIT_REPO_LOCATION_ENV_VARS) {
|
|
194
|
+
delete clean[key];
|
|
195
|
+
}
|
|
196
|
+
return clean;
|
|
197
|
+
}
|
|
178
198
|
/**
|
|
179
199
|
* The configured origin URL, read from local Git config only — never a
|
|
180
|
-
* network touch. Null when there is no repository or no origin.
|
|
200
|
+
* network touch. Null when there is no repository or no origin. Inherited
|
|
201
|
+
* repository-location vars are dropped so the answer is always about
|
|
202
|
+
* `storeRoot`: root selection runs this from wherever the CLI started,
|
|
203
|
+
* including inside another repository's git hook.
|
|
181
204
|
*/
|
|
182
205
|
export async function gitOriginUrl(storeRoot) {
|
|
183
|
-
const stdout = await gitProbe(storeRoot, ['remote', 'get-url', 'origin']
|
|
206
|
+
const stdout = await gitProbe(storeRoot, ['remote', 'get-url', 'origin'], {
|
|
207
|
+
env: envWithoutGitRepoLocation(),
|
|
208
|
+
});
|
|
184
209
|
const url = stdout?.trim();
|
|
185
210
|
return url ? url : null;
|
|
186
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* The repository name from an origin URL, with no host or `.git` suffix.
|
|
214
|
+
* Handles the three common remote spellings:
|
|
215
|
+
*
|
|
216
|
+
* - `git@github.com:user/repo.git`
|
|
217
|
+
* - `https://github.com/user/repo.git`
|
|
218
|
+
* - `ssh://git@github.com/user/repo.git`
|
|
219
|
+
*
|
|
220
|
+
* Returns the last path segment, or `null` when the URL has no parseable name.
|
|
221
|
+
*/
|
|
222
|
+
export function repoNameFromGitUrl(url) {
|
|
223
|
+
const cleaned = url.trim().replace(/\.git\/?$/i, '');
|
|
224
|
+
let path = null;
|
|
225
|
+
// URL-like: scheme://host[/user/repo]. The `://` is required, so scp-like
|
|
226
|
+
// `git@host:user/repo` (no scheme) never matches this branch.
|
|
227
|
+
const urlLike = cleaned.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+(?:\/(.*))?$/i);
|
|
228
|
+
if (urlLike) {
|
|
229
|
+
path = urlLike[1] ?? null;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// scp-like: git@host:user/repo
|
|
233
|
+
const scpLike = cleaned.match(/^(?:[\w.-]+@)?[\w.-]+:(.+)$/);
|
|
234
|
+
path = scpLike ? scpLike[1] : null;
|
|
235
|
+
}
|
|
236
|
+
if (!path)
|
|
237
|
+
return null;
|
|
238
|
+
const segments = path.split('/').filter(Boolean);
|
|
239
|
+
const last = segments[segments.length - 1];
|
|
240
|
+
return last && last !== '.' && last !== '..' ? last : null;
|
|
241
|
+
}
|
|
187
242
|
/**
|
|
188
243
|
* Ahead/behind counts of HEAD against its configured upstream tracking
|
|
189
244
|
* ref, read from local refs only — no fetch, no network. The comparison
|
|
@@ -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
|
|
@@ -12,8 +12,10 @@ export declare class Validator {
|
|
|
12
12
|
* Validate delta-formatted spec files under a change directory.
|
|
13
13
|
* Enforces:
|
|
14
14
|
* - At least one delta across all files
|
|
15
|
-
* - ADDED/MODIFIED: each requirement has at least one scenario;
|
|
16
|
-
*
|
|
15
|
+
* - ADDED/MODIFIED: each requirement has at least one scenario; a scenario
|
|
16
|
+
* with no WHEN/GIVEN or THEN step is guidance (WARNING) unless strict mode
|
|
17
|
+
* is enabled; missing English SHALL/MUST keywords are guidance unless
|
|
18
|
+
* strict mode is enabled
|
|
17
19
|
* - REMOVED: names only; no scenario/description required
|
|
18
20
|
* - RENAMED: pairs well-formed
|
|
19
21
|
* - No duplicates within sections; no cross-section conflicts per spec
|
|
@@ -90,6 +92,14 @@ export declare class Validator {
|
|
|
90
92
|
private findScenarioLossIssues;
|
|
91
93
|
private formatInvalidMarkerMessage;
|
|
92
94
|
private convertZodErrors;
|
|
95
|
+
/**
|
|
96
|
+
* Every guidance and error the main-spec path emits about one spec file
|
|
97
|
+
* (#1156, #243): structure, a placeholder or too-brief Purpose, requirement
|
|
98
|
+
* length, a requirement with no scenario, a missing English SHALL/MUST in the
|
|
99
|
+
* body, and a counted scenario with no trigger or outcome. The step check is
|
|
100
|
+
* the same one the delta path runs, on the same `#### ` blocks, so a scenario
|
|
101
|
+
* that asserts nothing is reported wherever it lives.
|
|
102
|
+
*/
|
|
93
103
|
private applySpecRules;
|
|
94
104
|
private applyChangeRules;
|
|
95
105
|
private enrichTopLevelError;
|
|
@@ -124,6 +134,21 @@ export declare class Validator {
|
|
|
124
134
|
* not count it. Empty when the block has no bare scenario header.
|
|
125
135
|
*/
|
|
126
136
|
private emptyScenarioHint;
|
|
137
|
+
/**
|
|
138
|
+
* A counted scenario whose body carries neither a **WHEN** (or **GIVEN**)
|
|
139
|
+
* step nor a **THEN** step can never fail: nothing triggers it and nothing
|
|
140
|
+
* is asserted (#2078). Read from the same blocks the scenario-loss comparison
|
|
141
|
+
* walks, so the two checks cannot disagree about what a scenario is.
|
|
142
|
+
* Only `Scenario:`-labeled blocks are checked: any level-4 child counts as a
|
|
143
|
+
* scenario for loss purposes, but an unlabeled `#### Notes` child is prose
|
|
144
|
+
* on purpose, and a missing WHEN/THEN there is noise, not a finding.
|
|
145
|
+
*
|
|
146
|
+
* A WARNING, like the missing SHALL/MUST keyword: plain `validate` and
|
|
147
|
+
* `archive` keep agreeing, and `--strict` — which already fails on any
|
|
148
|
+
* warning — is the bar that enforces it.
|
|
149
|
+
*/
|
|
150
|
+
private findScenarioStepIssues;
|
|
151
|
+
/** Render a list of section names as prose: `a`, `a and b`, `a, b and c`. */
|
|
127
152
|
private formatSectionList;
|
|
128
153
|
}
|
|
129
154
|
//# sourceMappingURL=validator.d.ts.map
|
|
@@ -5,8 +5,8 @@ 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, describeUnappliedContent, } from '../parsers/requirement-blocks.js';
|
|
9
|
-
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
|
|
8
|
+
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, parseScenarioBlocks, isScenarioLabeledHeader, describeUnappliedContent, isUnrecognizedDeltaSection, } from '../parsers/requirement-blocks.js';
|
|
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';
|
|
12
12
|
import { assertDiscoveredSpecFile } from '../../utils/spec-path-containment.js';
|
|
@@ -108,8 +108,10 @@ export class Validator {
|
|
|
108
108
|
* Validate delta-formatted spec files under a change directory.
|
|
109
109
|
* Enforces:
|
|
110
110
|
* - At least one delta across all files
|
|
111
|
-
* - ADDED/MODIFIED: each requirement has at least one scenario;
|
|
112
|
-
*
|
|
111
|
+
* - ADDED/MODIFIED: each requirement has at least one scenario; a scenario
|
|
112
|
+
* with no WHEN/GIVEN or THEN step is guidance (WARNING) unless strict mode
|
|
113
|
+
* is enabled; missing English SHALL/MUST keywords are guidance unless
|
|
114
|
+
* strict mode is enabled
|
|
113
115
|
* - REMOVED: names only; no scenario/description required
|
|
114
116
|
* - RENAMED: pairs well-formed
|
|
115
117
|
* - No duplicates within sections; no cross-section conflicts per spec
|
|
@@ -193,12 +195,15 @@ export class Validator {
|
|
|
193
195
|
// ignored - reported as a WARNING rather than an ERROR because a
|
|
194
196
|
// handful of pre-format archived changes still carry this shape, and
|
|
195
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).
|
|
196
201
|
for (const orphan of plan.orphanedRequirements) {
|
|
197
202
|
const where = orphan.section
|
|
198
203
|
? `under "## ${orphan.section}"`
|
|
199
204
|
: 'above the first "## " section';
|
|
200
205
|
issues.push({
|
|
201
|
-
level: 'WARNING',
|
|
206
|
+
level: isUnrecognizedDeltaSection(orphan.section) ? 'ERROR' : 'WARNING',
|
|
202
207
|
path: entryPath,
|
|
203
208
|
line: orphan.line,
|
|
204
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".`,
|
|
@@ -278,6 +283,7 @@ export class Validator {
|
|
|
278
283
|
if (scenarioCount < 1) {
|
|
279
284
|
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
|
|
280
285
|
}
|
|
286
|
+
issues.push(...this.findScenarioStepIssues(block.raw, `ADDED "${block.name}"`, entryPath));
|
|
281
287
|
}
|
|
282
288
|
// Validate MODIFIED
|
|
283
289
|
for (const block of plan.modified) {
|
|
@@ -310,6 +316,7 @@ export class Validator {
|
|
|
310
316
|
if (scenarioCount < 1) {
|
|
311
317
|
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
|
|
312
318
|
}
|
|
319
|
+
issues.push(...this.findScenarioStepIssues(block.raw, `MODIFIED "${block.name}"`, entryPath));
|
|
313
320
|
}
|
|
314
321
|
// Run archive's scenario-loss check here too, so the change fails at
|
|
315
322
|
// authoring time instead of days later at archive time (#1477).
|
|
@@ -698,6 +705,14 @@ export class Validator {
|
|
|
698
705
|
};
|
|
699
706
|
});
|
|
700
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Every guidance and error the main-spec path emits about one spec file
|
|
710
|
+
* (#1156, #243): structure, a placeholder or too-brief Purpose, requirement
|
|
711
|
+
* length, a requirement with no scenario, a missing English SHALL/MUST in the
|
|
712
|
+
* body, and a counted scenario with no trigger or outcome. The step check is
|
|
713
|
+
* the same one the delta path runs, on the same `#### ` blocks, so a scenario
|
|
714
|
+
* that asserts nothing is reported wherever it lives.
|
|
715
|
+
*/
|
|
701
716
|
applySpecRules(spec, content) {
|
|
702
717
|
const issues = [];
|
|
703
718
|
for (const structuralIssue of findMainSpecStructureIssues(content)) {
|
|
@@ -767,6 +782,9 @@ export class Validator {
|
|
|
767
782
|
message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name, true),
|
|
768
783
|
});
|
|
769
784
|
}
|
|
785
|
+
// Same step check the delta path runs, on the same blocks, so a scenario
|
|
786
|
+
// that asserts nothing is reported wherever it lives.
|
|
787
|
+
issues.push(...this.findScenarioStepIssues(block.raw, `Requirement "${block.name}"`, `requirements[${index}].scenarios`));
|
|
770
788
|
});
|
|
771
789
|
return issues;
|
|
772
790
|
}
|
|
@@ -941,6 +959,45 @@ export class Validator {
|
|
|
941
959
|
return '';
|
|
942
960
|
return ' (a scenario header with no body under it does not count; add its steps, e.g. "- **WHEN** ..." and "- **THEN** ...")';
|
|
943
961
|
}
|
|
962
|
+
/**
|
|
963
|
+
* A counted scenario whose body carries neither a **WHEN** (or **GIVEN**)
|
|
964
|
+
* step nor a **THEN** step can never fail: nothing triggers it and nothing
|
|
965
|
+
* is asserted (#2078). Read from the same blocks the scenario-loss comparison
|
|
966
|
+
* walks, so the two checks cannot disagree about what a scenario is.
|
|
967
|
+
* Only `Scenario:`-labeled blocks are checked: any level-4 child counts as a
|
|
968
|
+
* scenario for loss purposes, but an unlabeled `#### Notes` child is prose
|
|
969
|
+
* on purpose, and a missing WHEN/THEN there is noise, not a finding.
|
|
970
|
+
*
|
|
971
|
+
* A WARNING, like the missing SHALL/MUST keyword: plain `validate` and
|
|
972
|
+
* `archive` keep agreeing, and `--strict` — which already fails on any
|
|
973
|
+
* warning — is the bar that enforces it.
|
|
974
|
+
*/
|
|
975
|
+
findScenarioStepIssues(blockRaw, label, issuePath) {
|
|
976
|
+
const issues = [];
|
|
977
|
+
for (const scenario of parseScenarioBlocks(blockRaw)) {
|
|
978
|
+
// A header with no body is already an ERROR with its own hint; a step
|
|
979
|
+
// complaint on top of it would say nothing new. parseScenarioBlocks keeps
|
|
980
|
+
// the header line in `raw`, and hasScenarioBody takes the body alone.
|
|
981
|
+
const bodyLines = scenario.raw.split('\n').slice(1).join('\n');
|
|
982
|
+
if (!hasScenarioBody(bodyLines))
|
|
983
|
+
continue;
|
|
984
|
+
if (!isScenarioLabeledHeader(scenario.raw.split('\n')[0]))
|
|
985
|
+
continue;
|
|
986
|
+
const { hasTrigger, hasOutcome } = scenarioStepCoverage(scenario.raw);
|
|
987
|
+
if (hasTrigger && hasOutcome)
|
|
988
|
+
continue;
|
|
989
|
+
const missing = [!hasTrigger && 'a WHEN/GIVEN step', !hasOutcome && 'a THEN step']
|
|
990
|
+
.filter((part) => part !== false)
|
|
991
|
+
.join(' and ');
|
|
992
|
+
issues.push({
|
|
993
|
+
level: 'WARNING',
|
|
994
|
+
path: issuePath,
|
|
995
|
+
message: `${label}: scenario "${scenario.name}" is missing ${missing}. A scenario needs a trigger and an outcome, e.g. "- **WHEN** an invoice is voided" and "- **THEN** a credit note is issued".`,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
return issues;
|
|
999
|
+
}
|
|
1000
|
+
/** Render a list of section names as prose: `a`, `a and b`, `a, b and c`. */
|
|
944
1001
|
formatSectionList(sections) {
|
|
945
1002
|
if (sections.length === 0)
|
|
946
1003
|
return '';
|
|
@@ -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`.
|