@fission-ai/openspec 1.13.0 → 1.13.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 +1 -1
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +33 -14
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +17 -1
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +13 -3
- package/dist/core/legacy-cleanup.d.ts +4 -0
- package/dist/core/legacy-cleanup.js +180 -12
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +44 -3
- package/dist/core/parsers/requirement-blocks.js +90 -15
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/specs-apply.js +82 -11
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +50 -15
- package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
- package/dist/core/templates/workflows/continue-change.js +14 -2
- package/dist/core/templates/workflows/explore.js +32 -14
- package/dist/core/templates/workflows/ff-change.js +19 -3
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +67 -37
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +22 -5
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +52 -25
- package/dist/core/templates/workflows/verify-change.js +12 -3
- package/dist/core/update.d.ts +12 -0
- package/dist/core/update.js +58 -3
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +154 -40
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +4 -4
- package/schemas/spec-driven/schema.yaml +10 -3
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
package/dist/core/list.js
CHANGED
|
@@ -4,12 +4,24 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre
|
|
|
4
4
|
import { readFileSync } from 'fs';
|
|
5
5
|
import { MarkdownParser } from './parsers/markdown-parser.js';
|
|
6
6
|
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
|
7
|
+
import { describeNestedChange, findNestedChanges, } from '../utils/nested-change.js';
|
|
7
8
|
function isMissingPathError(error) {
|
|
8
9
|
return (typeof error === 'object' &&
|
|
9
10
|
error !== null &&
|
|
10
11
|
'code' in error &&
|
|
11
12
|
error.code === 'ENOENT');
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* An entry that cannot be dated because it no longer resolves: it was removed
|
|
16
|
+
* after `readdir` listed it, or it is a symlink whose target is missing (an
|
|
17
|
+
* Emacs `.#file` lock) or that loops back on itself.
|
|
18
|
+
*/
|
|
19
|
+
function isUnresolvableEntryError(error) {
|
|
20
|
+
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
21
|
+
return false;
|
|
22
|
+
const code = error.code;
|
|
23
|
+
return code === 'ENOENT' || code === 'ELOOP';
|
|
24
|
+
}
|
|
13
25
|
async function readChangeDirectoryEntries(changesDir) {
|
|
14
26
|
try {
|
|
15
27
|
return await fs.readdir(changesDir, { withFileTypes: true });
|
|
@@ -30,14 +42,21 @@ async function getLastModified(dirPath) {
|
|
|
30
42
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
31
43
|
for (const entry of entries) {
|
|
32
44
|
const fullPath = path.join(dir, entry.name);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
else {
|
|
37
|
-
const stat = await fs.stat(fullPath);
|
|
38
|
-
if (latest === null || stat.mtime > latest) {
|
|
39
|
-
latest = stat.mtime;
|
|
45
|
+
try {
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
await walk(fullPath);
|
|
40
48
|
}
|
|
49
|
+
else {
|
|
50
|
+
const stat = await fs.stat(fullPath);
|
|
51
|
+
if (latest === null || stat.mtime > latest) {
|
|
52
|
+
latest = stat.mtime;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
// Skip the one entry rather than fail the listing of every change.
|
|
58
|
+
if (!isUnresolvableEntryError(error))
|
|
59
|
+
throw error;
|
|
41
60
|
}
|
|
42
61
|
}
|
|
43
62
|
}
|
|
@@ -96,6 +115,11 @@ export class ListCommand {
|
|
|
96
115
|
}
|
|
97
116
|
// Collect information about each change
|
|
98
117
|
const changes = [];
|
|
118
|
+
// A directory that only wraps nested change directories is still listed -
|
|
119
|
+
// hiding it would hide a real change whenever the probe is wrong - but it
|
|
120
|
+
// is listed as what it is, so the nesting stops failing silently (#1846).
|
|
121
|
+
const nestedFindings = await findNestedChanges(changesDir, changeDirs);
|
|
122
|
+
const nestedByName = new Map(nestedFindings.map((finding) => [finding.name, finding]));
|
|
99
123
|
for (const changeDir of changeDirs) {
|
|
100
124
|
const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath);
|
|
101
125
|
const changePath = path.join(changesDir, changeDir);
|
|
@@ -104,7 +128,8 @@ export class ListCommand {
|
|
|
104
128
|
name: changeDir,
|
|
105
129
|
completedTasks: progress.completed,
|
|
106
130
|
totalTasks: progress.total,
|
|
107
|
-
lastModified
|
|
131
|
+
lastModified,
|
|
132
|
+
...(nestedByName.has(changeDir) ? { nested: nestedByName.get(changeDir).nested } : {})
|
|
108
133
|
});
|
|
109
134
|
}
|
|
110
135
|
// Sort by preference (default: recent first)
|
|
@@ -121,9 +146,22 @@ export class ListCommand {
|
|
|
121
146
|
completedTasks: c.completedTasks,
|
|
122
147
|
totalTasks: c.totalTasks,
|
|
123
148
|
lastModified: c.lastModified.toISOString(),
|
|
124
|
-
status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress'
|
|
149
|
+
status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress',
|
|
150
|
+
...(c.nested ? { nested: c.nested } : {})
|
|
125
151
|
}));
|
|
126
|
-
|
|
152
|
+
// Additive: the entries keep their shape so existing consumers are
|
|
153
|
+
// unaffected, and the nesting is reported alongside them.
|
|
154
|
+
const warnings = nestedFindings.map((finding) => ({
|
|
155
|
+
code: 'nested_change_directory',
|
|
156
|
+
name: finding.name,
|
|
157
|
+
nested: finding.nested,
|
|
158
|
+
message: describeNestedChange(finding)
|
|
159
|
+
}));
|
|
160
|
+
console.log(JSON.stringify({
|
|
161
|
+
changes: jsonOutput,
|
|
162
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
163
|
+
...(root ? { root } : {})
|
|
164
|
+
}, null, 2));
|
|
127
165
|
return;
|
|
128
166
|
}
|
|
129
167
|
// Display results
|
|
@@ -132,10 +170,16 @@ export class ListCommand {
|
|
|
132
170
|
const nameWidth = Math.max(...changes.map(c => c.name.length));
|
|
133
171
|
for (const change of changes) {
|
|
134
172
|
const paddedName = change.name.padEnd(nameWidth);
|
|
135
|
-
const status =
|
|
173
|
+
const status = change.nested
|
|
174
|
+
? 'not a change'
|
|
175
|
+
: formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
|
|
136
176
|
const timeAgo = formatRelativeTime(change.lastModified);
|
|
137
177
|
console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
|
|
138
178
|
}
|
|
179
|
+
for (const finding of nestedFindings) {
|
|
180
|
+
console.log('');
|
|
181
|
+
console.log(`Warning: ${describeNestedChange(finding)}`);
|
|
182
|
+
}
|
|
139
183
|
return;
|
|
140
184
|
}
|
|
141
185
|
// specs mode
|
package/dist/core/migration.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Called by both init and update commands before profile resolution.
|
|
6
6
|
*/
|
|
7
7
|
import { AI_TOOLS } from './config.js';
|
|
8
|
-
import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig } from './global-config.js';
|
|
8
|
+
import { getGlobalConfig, getGlobalConfigPath, isGlobalConfigUnreadable, saveGlobalConfig } from './global-config.js';
|
|
9
9
|
import { CommandAdapterRegistry } from './command-generation/index.js';
|
|
10
10
|
import { resolveCommandInvocation, resolveCommandSurfaceCapability, shouldGenerateCommandsForTool, } from './command-surface.js';
|
|
11
11
|
import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';
|
|
@@ -435,6 +435,11 @@ function inferDelivery(artifacts) {
|
|
|
435
435
|
* - If profile field already exists: no-op.
|
|
436
436
|
*/
|
|
437
437
|
export function migrateIfNeeded(projectPath, tools) {
|
|
438
|
+
// A config that cannot be parsed, or is not a JSON object, is never saved
|
|
439
|
+
// over; skip migration rather than fail init or update on it.
|
|
440
|
+
if (isGlobalConfigUnreadable()) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
438
443
|
const config = getGlobalConfig();
|
|
439
444
|
// Check raw config file for profile field presence
|
|
440
445
|
const configPath = getGlobalConfigPath();
|
|
@@ -20,8 +20,19 @@ export declare class ChangeParser extends MarkdownParser {
|
|
|
20
20
|
* `view`, `list`, `spec --json`, spec validation — untouched.
|
|
21
21
|
*/
|
|
22
22
|
protected parseRequirements(section: Section): Requirement[];
|
|
23
|
+
/**
|
|
24
|
+
* The deltas in one spec file, read by parseDeltaSpec — the reader archive
|
|
25
|
+
* applies — so what `show` reports is what archive will do. This used to be a
|
|
26
|
+
* second reader that disagreed with it: a bullet-form REMOVED was invisible,
|
|
27
|
+
* a repeated section header was read only once, and a RENAMED line written
|
|
28
|
+
* with `*` or `+` was dropped.
|
|
29
|
+
*/
|
|
23
30
|
private parseSpecDeltas;
|
|
24
|
-
|
|
31
|
+
/**
|
|
32
|
+
* One Requirement per block, read by the same section parser (and the
|
|
33
|
+
* header filter above) as before, so text and scenarios are unchanged.
|
|
34
|
+
*/
|
|
35
|
+
private toRequirements;
|
|
25
36
|
private parseSectionsFromContent;
|
|
26
37
|
private getContentUntilNextHeaderFromLines;
|
|
27
38
|
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { MarkdownParser } from './markdown-parser.js';
|
|
2
2
|
import { buildCodeFenceMask } from './requirement-text.js';
|
|
3
|
+
import { parseDeltaSpec } from './requirement-blocks.js';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
import { promises as fs } from 'fs';
|
|
5
6
|
import { discoverSpecFiles } from '../../utils/spec-discovery.js';
|
|
7
|
+
/** A header-only block for a REMOVED entry written in the bullet form. */
|
|
8
|
+
function removedNameBlock(name) {
|
|
9
|
+
const headerLine = `### Requirement: ${name}`;
|
|
10
|
+
return { headerLine, name, raw: headerLine };
|
|
11
|
+
}
|
|
6
12
|
export class ChangeParser extends MarkdownParser {
|
|
7
13
|
changeDir;
|
|
8
14
|
constructor(content, changeDir) {
|
|
@@ -19,13 +25,16 @@ export class ChangeParser extends MarkdownParser {
|
|
|
19
25
|
if (!whatChanges) {
|
|
20
26
|
throw new Error('Change must have a What Changes section');
|
|
21
27
|
}
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
28
|
+
// Delta spec files that carry a delta section are the only source of
|
|
29
|
+
// structured deltas, even when those sections hold no entry archive can
|
|
30
|
+
// apply. Falling back to the "What Changes" prose then reported operations
|
|
31
|
+
// that never happen: a bullet-form REMOVED showed up as an invented
|
|
32
|
+
// MODIFIED. The prose (simple format) is still read when no spec file
|
|
33
|
+
// carries a delta section at all: a change with no spec files, or a legacy
|
|
34
|
+
// change whose specs/ hold full future-state specs.
|
|
35
|
+
const specFiles = await discoverSpecFiles(path.join(this.changeDir, 'specs'));
|
|
36
|
+
const { deltas: specDeltas, hasDeltaSections } = await this.parseDeltaSpecs(specFiles);
|
|
37
|
+
const deltas = hasDeltaSections ? specDeltas : this.parseDeltas(whatChanges);
|
|
29
38
|
return {
|
|
30
39
|
name,
|
|
31
40
|
why: why.trim(),
|
|
@@ -37,23 +46,25 @@ export class ChangeParser extends MarkdownParser {
|
|
|
37
46
|
},
|
|
38
47
|
};
|
|
39
48
|
}
|
|
40
|
-
|
|
49
|
+
// The spec files come from discoverSpecFiles, which walks specs/ recursively
|
|
50
|
+
// so nested layouts like specs/<area>/<capability>/spec.md are parsed too (#1353)
|
|
51
|
+
async parseDeltaSpecs(specFiles) {
|
|
41
52
|
const deltas = [];
|
|
42
|
-
|
|
43
|
-
// specs/<area>/<capability>/spec.md are parsed too (#1353)
|
|
44
|
-
const specFiles = await discoverSpecFiles(specsDir);
|
|
53
|
+
let hasDeltaSections = false;
|
|
45
54
|
for (const { id, specFile } of specFiles) {
|
|
46
55
|
try {
|
|
47
56
|
const content = await fs.readFile(specFile, 'utf-8');
|
|
48
|
-
const
|
|
49
|
-
|
|
57
|
+
const plan = parseDeltaSpec(content);
|
|
58
|
+
if (Object.values(plan.sectionPresence).some(Boolean))
|
|
59
|
+
hasDeltaSections = true;
|
|
60
|
+
deltas.push(...this.parseSpecDeltas(id, plan));
|
|
50
61
|
}
|
|
51
62
|
catch (error) {
|
|
52
63
|
// Spec file might not be readable, which is okay
|
|
53
64
|
continue;
|
|
54
65
|
}
|
|
55
66
|
}
|
|
56
|
-
return deltas;
|
|
67
|
+
return { deltas, hasDeltaSections };
|
|
57
68
|
}
|
|
58
69
|
/**
|
|
59
70
|
* Read requirements from a delta section, ignoring headers that are not
|
|
@@ -75,89 +86,76 @@ export class ChangeParser extends MarkdownParser {
|
|
|
75
86
|
children: section.children.filter((child) => /^Requirement:\s*\S/i.test(child.title.trim())),
|
|
76
87
|
});
|
|
77
88
|
}
|
|
78
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The deltas in one spec file, read by parseDeltaSpec — the reader archive
|
|
91
|
+
* applies — so what `show` reports is what archive will do. This used to be a
|
|
92
|
+
* second reader that disagreed with it: a bullet-form REMOVED was invisible,
|
|
93
|
+
* a repeated section header was read only once, and a RENAMED line written
|
|
94
|
+
* with `*` or `+` was dropped.
|
|
95
|
+
*/
|
|
96
|
+
parseSpecDeltas(specName, plan) {
|
|
79
97
|
const deltas = [];
|
|
80
|
-
const sections = this.parseSectionsFromContent(content);
|
|
81
98
|
// Parse ADDED requirements
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// Provide both single and plural forms for compatibility
|
|
91
|
-
requirement: req,
|
|
92
|
-
requirements: [req],
|
|
93
|
-
});
|
|
99
|
+
this.toRequirements(plan.added).forEach(req => {
|
|
100
|
+
deltas.push({
|
|
101
|
+
spec: specName,
|
|
102
|
+
operation: 'ADDED',
|
|
103
|
+
description: `Add requirement: ${req.text}`,
|
|
104
|
+
// Provide both single and plural forms for compatibility
|
|
105
|
+
requirement: req,
|
|
106
|
+
requirements: [req],
|
|
94
107
|
});
|
|
95
|
-
}
|
|
108
|
+
});
|
|
96
109
|
// Parse MODIFIED requirements
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
description: `Modify requirement: ${req.text}`,
|
|
105
|
-
requirement: req,
|
|
106
|
-
requirements: [req],
|
|
107
|
-
});
|
|
110
|
+
this.toRequirements(plan.modified).forEach(req => {
|
|
111
|
+
deltas.push({
|
|
112
|
+
spec: specName,
|
|
113
|
+
operation: 'MODIFIED',
|
|
114
|
+
description: `Modify requirement: ${req.text}`,
|
|
115
|
+
requirement: req,
|
|
116
|
+
requirements: [req],
|
|
108
117
|
});
|
|
109
|
-
}
|
|
110
|
-
// Parse REMOVED requirements
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
118
|
+
});
|
|
119
|
+
// Parse REMOVED requirements, in document order. A bullet-form entry
|
|
120
|
+
// carries only a name, so it reads as a header-form removal with no body.
|
|
121
|
+
const removedBlocks = [...plan.removedBlocks];
|
|
122
|
+
const removed = plan.removed.map((name) => {
|
|
123
|
+
const index = removedBlocks.findIndex((block) => block.name === name);
|
|
124
|
+
return index === -1 ? removedNameBlock(name) : removedBlocks.splice(index, 1)[0];
|
|
125
|
+
});
|
|
126
|
+
this.toRequirements(removed).forEach(req => {
|
|
127
|
+
deltas.push({
|
|
128
|
+
spec: specName,
|
|
129
|
+
operation: 'REMOVED',
|
|
130
|
+
description: `Remove requirement: ${req.text}`,
|
|
131
|
+
requirement: req,
|
|
132
|
+
requirements: [req],
|
|
122
133
|
});
|
|
123
|
-
}
|
|
134
|
+
});
|
|
124
135
|
// Parse RENAMED requirements
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
operation: 'RENAMED',
|
|
132
|
-
description: `Rename requirement from "${rename.from}" to "${rename.to}"`,
|
|
133
|
-
rename,
|
|
134
|
-
});
|
|
136
|
+
plan.renamed.forEach(rename => {
|
|
137
|
+
deltas.push({
|
|
138
|
+
spec: specName,
|
|
139
|
+
operation: 'RENAMED',
|
|
140
|
+
description: `Rename requirement from "${rename.from}" to "${rename.to}"`,
|
|
141
|
+
rename,
|
|
135
142
|
});
|
|
136
|
-
}
|
|
143
|
+
});
|
|
137
144
|
return deltas;
|
|
138
145
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
renames.push({
|
|
153
|
-
from: currentRename.from,
|
|
154
|
-
to: currentRename.to,
|
|
155
|
-
});
|
|
156
|
-
currentRename = {};
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return renames;
|
|
146
|
+
/**
|
|
147
|
+
* One Requirement per block, read by the same section parser (and the
|
|
148
|
+
* header filter above) as before, so text and scenarios are unchanged.
|
|
149
|
+
*/
|
|
150
|
+
toRequirements(blocks) {
|
|
151
|
+
return blocks.flatMap((block) => {
|
|
152
|
+
const [headerLine, ...body] = block.raw.split('\n');
|
|
153
|
+
// Canonical header: the delta reader also accepts `###Requirement:` with
|
|
154
|
+
// no space, which the section parser would not see as a header.
|
|
155
|
+
const title = headerLine.replace(/^###\s*/, '').trim();
|
|
156
|
+
const [section] = this.parseSectionsFromContent([`### ${title}`, ...body].join('\n'));
|
|
157
|
+
return this.parseRequirements({ level: 2, title: '', content: '', children: [section] });
|
|
158
|
+
});
|
|
161
159
|
}
|
|
162
160
|
parseSectionsFromContent(content) {
|
|
163
161
|
const normalizedContent = ChangeParser.normalizeContent(content);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildCodeFenceMask, extractRequirementText } from './requirement-text.js';
|
|
1
|
+
import { buildCodeFenceMask, extractRequirementText, hasScenarioBody } from './requirement-text.js';
|
|
2
2
|
export class MarkdownParser {
|
|
3
3
|
lines;
|
|
4
4
|
codeFenceLineMask;
|
|
@@ -130,8 +130,9 @@ export class MarkdownParser {
|
|
|
130
130
|
parseScenarios(requirementSection) {
|
|
131
131
|
const scenarios = [];
|
|
132
132
|
for (const scenarioSection of requirementSection.children) {
|
|
133
|
-
// Store the raw text content of the scenario section
|
|
134
|
-
|
|
133
|
+
// Store the raw text content of the scenario section. A header with no
|
|
134
|
+
// body is not a scenario; the delta counter applies the same rule.
|
|
135
|
+
if (hasScenarioBody(scenarioSection.content)) {
|
|
135
136
|
scenarios.push({
|
|
136
137
|
rawText: scenarioSection.content
|
|
137
138
|
});
|
|
@@ -14,9 +14,10 @@ export declare function normalizeRequirementName(name: string): string;
|
|
|
14
14
|
/**
|
|
15
15
|
* Case- and whitespace-insensitive fold of a requirement name. Requirement
|
|
16
16
|
* matching itself is case-sensitive (normalizeRequirementName); this fold
|
|
17
|
-
* exists only for typo detection - near-miss REMOVED
|
|
18
|
-
* RENAMED+REMOVED cross-section conflict - where two spellings
|
|
19
|
-
* only in case or interior whitespace mean a mistake, never two
|
|
17
|
+
* exists only for typo detection - near-miss REMOVED, ADDED and RENAMED
|
|
18
|
+
* headers and the RENAMED+REMOVED cross-section conflict - where two spellings
|
|
19
|
+
* that differ only in case or interior whitespace mean a mistake, never two
|
|
20
|
+
* requirements.
|
|
20
21
|
*/
|
|
21
22
|
export declare function foldRequirementName(name: string): string;
|
|
22
23
|
/**
|
|
@@ -33,6 +34,42 @@ export interface SkippedHeader {
|
|
|
33
34
|
section: string;
|
|
34
35
|
line: number;
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* A `FROM:` or `TO:` line in `## RENAMED Requirements` that never formed a pair,
|
|
39
|
+
* recorded at the moment the reader steps over it.
|
|
40
|
+
*
|
|
41
|
+
* The pair reader used to carry one mutable `{ from, to }` and drop whatever did
|
|
42
|
+
* not fit: a second `FROM:` overwrote an unpaired first, a `TO:` with no pending
|
|
43
|
+
* `FROM:` vanished, and a trailing `FROM:` was forgotten at the end of the
|
|
44
|
+
* section. Nothing counted any of it, so a rename the author asked for could
|
|
45
|
+
* silently not happen - or, when the lines interleaved, a DIFFERENT requirement
|
|
46
|
+
* could be renamed under a name meant for another one.
|
|
47
|
+
*
|
|
48
|
+
* Recording them is what lets `validate` report the problem and `buildUpdatedSpec`
|
|
49
|
+
* refuse, rather than guess a pairing and rewrite the spec from it.
|
|
50
|
+
*/
|
|
51
|
+
export interface UnpairedRename {
|
|
52
|
+
side: 'FROM' | 'TO';
|
|
53
|
+
name: string;
|
|
54
|
+
line: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A canonical `### Requirement:` block that sits outside every delta section -
|
|
58
|
+
* under `## Notes`, under a misspelled `## Add Requirements`, or above the
|
|
59
|
+
* first `## ` header entirely.
|
|
60
|
+
*
|
|
61
|
+
* The delta reader only ever looks inside the four delta sections, so a block
|
|
62
|
+
* written anywhere else was dropped with no error, no warning and no note -
|
|
63
|
+
* even though it is well formed and reads exactly like one that would apply.
|
|
64
|
+
* That was the inconsistency worth closing: the ADJACENT mistake, a
|
|
65
|
+
* non-canonical `###` header INSIDE a delta section, has been reported as INFO
|
|
66
|
+
* since #498 (`skippedHeaders`), while the costlier one said nothing at all.
|
|
67
|
+
*/
|
|
68
|
+
export interface OrphanedRequirement {
|
|
69
|
+
name: string;
|
|
70
|
+
section: string | null;
|
|
71
|
+
line: number;
|
|
72
|
+
}
|
|
36
73
|
export interface DeltaPlan {
|
|
37
74
|
added: RequirementBlock[];
|
|
38
75
|
modified: RequirementBlock[];
|
|
@@ -42,6 +79,10 @@ export interface DeltaPlan {
|
|
|
42
79
|
from: string;
|
|
43
80
|
to: string;
|
|
44
81
|
}>;
|
|
82
|
+
/** FROM:/TO: lines in RENAMED that never formed a pair. */
|
|
83
|
+
unpairedRenames: UnpairedRename[];
|
|
84
|
+
/** Canonical requirement blocks written outside every delta section. */
|
|
85
|
+
orphanedRequirements: OrphanedRequirement[];
|
|
45
86
|
skippedHeaders: SkippedHeader[];
|
|
46
87
|
sectionPresence: {
|
|
47
88
|
added: boolean;
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
|
|
2
2
|
export function normalizeRequirementName(name) {
|
|
3
|
-
|
|
3
|
+
// An ATX heading may end in a closing run of `#`s: `### Requirement: Foo ###`
|
|
4
|
+
// renders as `Foo`, so the run is not part of the name. As for scenario names,
|
|
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();
|
|
4
8
|
}
|
|
5
9
|
/**
|
|
6
10
|
* Case- and whitespace-insensitive fold of a requirement name. Requirement
|
|
7
11
|
* matching itself is case-sensitive (normalizeRequirementName); this fold
|
|
8
|
-
* exists only for typo detection - near-miss REMOVED
|
|
9
|
-
* RENAMED+REMOVED cross-section conflict - where two spellings
|
|
10
|
-
* only in case or interior whitespace mean a mistake, never two
|
|
12
|
+
* exists only for typo detection - near-miss REMOVED, ADDED and RENAMED
|
|
13
|
+
* headers and the RENAMED+REMOVED cross-section conflict - where two spellings
|
|
14
|
+
* that differ only in case or interior whitespace mean a mistake, never two
|
|
15
|
+
* requirements.
|
|
11
16
|
*/
|
|
12
17
|
export function foldRequirementName(name) {
|
|
13
18
|
return normalizeRequirementName(name).toLowerCase().replace(/\s+/g, ' ');
|
|
@@ -117,8 +122,11 @@ export function parseDeltaSpec(content) {
|
|
|
117
122
|
const removedNames = removedLookup.bodies.flatMap((body) => parseRemovedNames(body));
|
|
118
123
|
const removedBlocks = removedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body));
|
|
119
124
|
// Pairs are read per section, so a FROM in one copy of the header can never
|
|
120
|
-
// pair with a TO in another
|
|
121
|
-
|
|
125
|
+
// pair with a TO in another: a FROM left pending at the end of one copy is
|
|
126
|
+
// reported as unpaired rather than carried into the next.
|
|
127
|
+
const unpairedRenames = [];
|
|
128
|
+
const renamedPairs = renamedLookup.bodies.flatMap((body) => parseRenamedPairs(body, unpairedRenames));
|
|
129
|
+
unpairedRenames.sort((a, b) => a.line - b.line);
|
|
122
130
|
skippedHeaders.sort((a, b) => a.line - b.line);
|
|
123
131
|
return {
|
|
124
132
|
added,
|
|
@@ -126,6 +134,8 @@ export function parseDeltaSpec(content) {
|
|
|
126
134
|
removed: removedNames,
|
|
127
135
|
removedBlocks,
|
|
128
136
|
renamed: renamedPairs,
|
|
137
|
+
unpairedRenames,
|
|
138
|
+
orphanedRequirements: findOrphanedRequirements(lines, fenceMask),
|
|
129
139
|
skippedHeaders,
|
|
130
140
|
sectionPresence: {
|
|
131
141
|
added: addedLookup.found,
|
|
@@ -135,6 +145,48 @@ export function parseDeltaSpec(content) {
|
|
|
135
145
|
},
|
|
136
146
|
};
|
|
137
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* The four section titles the delta reader acts on, folded the way
|
|
150
|
+
* `getSectionsCaseInsensitive` folds them. Matching the reader exactly matters:
|
|
151
|
+
* a looser test (say, any run of whitespace) would treat `## ADDED Requirements`
|
|
152
|
+
* as a delta section here while the reader ignores it, and the requirements
|
|
153
|
+
* under it would be dropped without this warning.
|
|
154
|
+
*/
|
|
155
|
+
const DELTA_SECTION_TITLES = new Set(['ADDED Requirements', 'MODIFIED Requirements', 'REMOVED Requirements', 'RENAMED Requirements'].map((title) => title.toLowerCase()));
|
|
156
|
+
/**
|
|
157
|
+
* Every canonical `### Requirement:` header that is not inside a delta section,
|
|
158
|
+
* in document order.
|
|
159
|
+
*
|
|
160
|
+
* Walks the whole file rather than the parsed sections so a requirement written
|
|
161
|
+
* ABOVE the first `## ` header is reported too - it is dropped just as silently
|
|
162
|
+
* as one under `## Notes`. Fenced lines are skipped, so a requirement shown
|
|
163
|
+
* inside a markdown example is not mistaken for an authored one.
|
|
164
|
+
*/
|
|
165
|
+
function findOrphanedRequirements(lines, fenceMask) {
|
|
166
|
+
const orphans = [];
|
|
167
|
+
let section = null;
|
|
168
|
+
for (let i = 0; i < lines.length; i++) {
|
|
169
|
+
if (fenceMask[i])
|
|
170
|
+
continue;
|
|
171
|
+
// The same `## ` test splitTopLevelSections uses, so both agree on sections.
|
|
172
|
+
const sectionMatch = lines[i].match(/^(##)\s+(.+)$/);
|
|
173
|
+
if (sectionMatch) {
|
|
174
|
+
section = sectionMatch[2].trim();
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (section !== null && DELTA_SECTION_TITLES.has(section.toLowerCase()))
|
|
178
|
+
continue;
|
|
179
|
+
const header = lines[i].match(REQUIREMENT_HEADER_REGEX);
|
|
180
|
+
if (header) {
|
|
181
|
+
orphans.push({
|
|
182
|
+
name: normalizeRequirementName(header[1]),
|
|
183
|
+
section,
|
|
184
|
+
line: i + 1,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return orphans;
|
|
189
|
+
}
|
|
138
190
|
/**
|
|
139
191
|
* Every `## ` section, as a LIST rather than a title-keyed record.
|
|
140
192
|
*
|
|
@@ -271,18 +323,35 @@ function parseRemovedNames(sectionBody) {
|
|
|
271
323
|
return names;
|
|
272
324
|
}
|
|
273
325
|
/**
|
|
274
|
-
* `FROM:`/`TO:` rename pairs
|
|
326
|
+
* Read `FROM:`/`TO:` entries into rename pairs, recording every line that never
|
|
327
|
+
* formed one.
|
|
328
|
+
*
|
|
329
|
+
* A pair is a `FROM:` followed by a `TO:` with no second `FROM:` in between -
|
|
330
|
+
* the shape the documented format uses. Anything else is reported through
|
|
331
|
+
* `unpaired` rather than absorbed:
|
|
332
|
+
*
|
|
333
|
+
* - a `FROM:` displaced by another `FROM:` before its `TO:` arrived
|
|
334
|
+
* - a `TO:` with no pending `FROM:`
|
|
335
|
+
* - a `FROM:` still pending when the section ends
|
|
336
|
+
*
|
|
337
|
+
* Silently dropping these is what let a requested rename not happen, and what
|
|
338
|
+
* let interleaved lines (`FROM a`, `FROM b`, `TO x`, `TO y`) pair b with x -
|
|
339
|
+
* renaming a requirement the author never named, under a name meant for a
|
|
340
|
+
* different one. Callers refuse the delta instead of guessing.
|
|
275
341
|
*
|
|
276
342
|
* The bullet is optional, and every CommonMark bullet marker is accepted: a
|
|
277
343
|
* rename written with `*` or `+` used to match nothing at all, so the rename
|
|
278
344
|
* silently never happened while archive still reported success.
|
|
279
345
|
*/
|
|
280
|
-
function parseRenamedPairs(sectionBody) {
|
|
281
|
-
const { lines, fenceMask } = sectionBody;
|
|
346
|
+
function parseRenamedPairs(sectionBody, unpaired) {
|
|
347
|
+
const { lines, fenceMask, bodyStartLine } = sectionBody;
|
|
282
348
|
if (lines.length === 0)
|
|
283
349
|
return [];
|
|
284
350
|
const pairs = [];
|
|
285
|
-
let
|
|
351
|
+
let pending;
|
|
352
|
+
const drop = (side, name, line) => {
|
|
353
|
+
unpaired?.push({ side, name, line });
|
|
354
|
+
};
|
|
286
355
|
for (let i = 0; i < lines.length; i++) {
|
|
287
356
|
if (fenceMask[i])
|
|
288
357
|
continue;
|
|
@@ -293,16 +362,22 @@ function parseRenamedPairs(sectionBody) {
|
|
|
293
362
|
const fromMatch = line.match(/^\s*[-*+]?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
|
294
363
|
const toMatch = line.match(/^\s*[-*+]?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
|
295
364
|
if (fromMatch) {
|
|
296
|
-
|
|
365
|
+
if (pending)
|
|
366
|
+
drop('FROM', pending.name, pending.line);
|
|
367
|
+
pending = { name: normalizeRequirementName(fromMatch[1]), line: bodyStartLine + i };
|
|
297
368
|
}
|
|
298
369
|
else if (toMatch) {
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
370
|
+
const to = normalizeRequirementName(toMatch[1]);
|
|
371
|
+
if (!pending) {
|
|
372
|
+
drop('TO', to, bodyStartLine + i);
|
|
373
|
+
continue;
|
|
303
374
|
}
|
|
375
|
+
pairs.push({ from: pending.name, to });
|
|
376
|
+
pending = undefined;
|
|
304
377
|
}
|
|
305
378
|
}
|
|
379
|
+
if (pending)
|
|
380
|
+
drop('FROM', pending.name, pending.line);
|
|
306
381
|
return pairs;
|
|
307
382
|
}
|
|
308
383
|
/**
|