@fission-ai/openspec 1.12.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 +3 -15
- 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 +170 -16
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +8 -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 +33 -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/onboarding-commands.d.ts +11 -1
- package/dist/core/onboarding-commands.js +26 -0
- 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 +164 -43
- 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 +37 -4
- package/dist/core/specs-apply.js +260 -18
- 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 +48 -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 +52 -15
- 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 +34 -0
- package/dist/core/update.js +109 -11
- 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 +24 -5
- 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
|
@@ -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, ' ');
|
|
@@ -99,24 +104,29 @@ export function parseDeltaSpec(content) {
|
|
|
99
104
|
const lines = normalized.split('\n');
|
|
100
105
|
const fenceMask = buildCodeFenceMask(lines);
|
|
101
106
|
const sections = splitTopLevelSections(lines, fenceMask);
|
|
102
|
-
const addedLookup =
|
|
103
|
-
const modifiedLookup =
|
|
104
|
-
const removedLookup =
|
|
105
|
-
const renamedLookup =
|
|
107
|
+
const addedLookup = getSectionsCaseInsensitive(sections, 'ADDED Requirements');
|
|
108
|
+
const modifiedLookup = getSectionsCaseInsensitive(sections, 'MODIFIED Requirements');
|
|
109
|
+
const removedLookup = getSectionsCaseInsensitive(sections, 'REMOVED Requirements');
|
|
110
|
+
const renamedLookup = getSectionsCaseInsensitive(sections, 'RENAMED Requirements');
|
|
106
111
|
const skippedHeaders = [];
|
|
107
|
-
const added =
|
|
112
|
+
const added = addedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body, {
|
|
108
113
|
section: addedLookup.title,
|
|
109
|
-
bodyStartLine:
|
|
114
|
+
bodyStartLine: body.bodyStartLine,
|
|
110
115
|
sink: skippedHeaders,
|
|
111
|
-
});
|
|
112
|
-
const modified =
|
|
116
|
+
}));
|
|
117
|
+
const modified = modifiedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body, {
|
|
113
118
|
section: modifiedLookup.title,
|
|
114
|
-
bodyStartLine:
|
|
119
|
+
bodyStartLine: body.bodyStartLine,
|
|
115
120
|
sink: skippedHeaders,
|
|
116
|
-
});
|
|
117
|
-
const removedNames =
|
|
118
|
-
const removedBlocks =
|
|
119
|
-
|
|
121
|
+
}));
|
|
122
|
+
const removedNames = removedLookup.bodies.flatMap((body) => parseRemovedNames(body));
|
|
123
|
+
const removedBlocks = removedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body));
|
|
124
|
+
// Pairs are read per section, so a FROM in one copy of the header can never
|
|
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);
|
|
120
130
|
skippedHeaders.sort((a, b) => a.line - b.line);
|
|
121
131
|
return {
|
|
122
132
|
added,
|
|
@@ -124,6 +134,8 @@ export function parseDeltaSpec(content) {
|
|
|
124
134
|
removed: removedNames,
|
|
125
135
|
removedBlocks,
|
|
126
136
|
renamed: renamedPairs,
|
|
137
|
+
unpairedRenames,
|
|
138
|
+
orphanedRequirements: findOrphanedRequirements(lines, fenceMask),
|
|
127
139
|
skippedHeaders,
|
|
128
140
|
sectionPresence: {
|
|
129
141
|
added: addedLookup.found,
|
|
@@ -133,8 +145,58 @@ export function parseDeltaSpec(content) {
|
|
|
133
145
|
},
|
|
134
146
|
};
|
|
135
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
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Every `## ` section, as a LIST rather than a title-keyed record.
|
|
192
|
+
*
|
|
193
|
+
* Keying by title silently dropped a repeated header: a delta that wrote
|
|
194
|
+
* `## ADDED Requirements` twice kept only the last body, so every requirement
|
|
195
|
+
* under the first copy was discarded before any validation or merge rule could
|
|
196
|
+
* see it. A list keeps each occurrence, and the lookup below merges them.
|
|
197
|
+
*/
|
|
136
198
|
function splitTopLevelSections(lines, fenceMask) {
|
|
137
|
-
const
|
|
199
|
+
const sections = [];
|
|
138
200
|
const indices = [];
|
|
139
201
|
for (let i = 0; i < lines.length; i++) {
|
|
140
202
|
if (fenceMask[i])
|
|
@@ -148,23 +210,39 @@ function splitTopLevelSections(lines, fenceMask) {
|
|
|
148
210
|
const current = indices[i];
|
|
149
211
|
const next = indices[i + 1];
|
|
150
212
|
const end = next ? next.index : lines.length;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
213
|
+
sections.push({
|
|
214
|
+
title: current.title,
|
|
215
|
+
body: {
|
|
216
|
+
lines: lines.slice(current.index + 1, end),
|
|
217
|
+
fenceMask: fenceMask.slice(current.index + 1, end),
|
|
218
|
+
bodyStartLine: current.index + 2,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
156
221
|
}
|
|
157
|
-
return
|
|
222
|
+
return sections;
|
|
158
223
|
}
|
|
159
|
-
|
|
160
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Every section body whose title folds to `desired`, in document order.
|
|
226
|
+
*
|
|
227
|
+
* Returning all of them - rather than the first match - is what makes a
|
|
228
|
+
* repeated header (`## ADDED Requirements` twice) and a case variant
|
|
229
|
+
* (`## ADDED Requirements` + `## Added Requirements`) both apply in full. Each
|
|
230
|
+
* body keeps its own `bodyStartLine`, so reported line numbers stay correct for
|
|
231
|
+
* the copy the header actually came from.
|
|
232
|
+
*
|
|
233
|
+
* `title` is the first spelling the author used, which is what diagnostics quote.
|
|
234
|
+
*/
|
|
235
|
+
function getSectionsCaseInsensitive(sections, desired) {
|
|
161
236
|
const target = desired.toLowerCase();
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
237
|
+
const matches = sections.filter((section) => section.title.toLowerCase() === target);
|
|
238
|
+
if (matches.length === 0) {
|
|
239
|
+
return { title: desired, bodies: [], found: false };
|
|
166
240
|
}
|
|
167
|
-
return {
|
|
241
|
+
return {
|
|
242
|
+
title: matches[0].title,
|
|
243
|
+
bodies: matches.map((section) => section.body),
|
|
244
|
+
found: true,
|
|
245
|
+
};
|
|
168
246
|
}
|
|
169
247
|
function parseRequirementBlocksFromSection(sectionBody, skipped) {
|
|
170
248
|
const { lines, fenceMask } = sectionBody;
|
|
@@ -212,6 +290,13 @@ function parseRequirementBlocksFromSection(sectionBody, skipped) {
|
|
|
212
290
|
}
|
|
213
291
|
return blocks;
|
|
214
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Requirement names listed in `## REMOVED Requirements`, in document order.
|
|
295
|
+
*
|
|
296
|
+
* Two spellings are accepted: a plain `### Requirement:` header, and a bullet
|
|
297
|
+
* carrying one. Every CommonMark bullet marker counts for the second form -
|
|
298
|
+
* see the pattern below for why that matters.
|
|
299
|
+
*/
|
|
215
300
|
function parseRemovedNames(sectionBody) {
|
|
216
301
|
const { lines, fenceMask } = sectionBody;
|
|
217
302
|
if (lines.length === 0)
|
|
@@ -226,37 +311,73 @@ function parseRemovedNames(sectionBody) {
|
|
|
226
311
|
names.push(normalizeRequirementName(m[1]));
|
|
227
312
|
continue;
|
|
228
313
|
}
|
|
229
|
-
// Also support bullet list of headers
|
|
230
|
-
|
|
314
|
+
// Also support bullet list of headers. Every CommonMark bullet marker
|
|
315
|
+
// counts: `*` and `+` open a list exactly as `-` does, so accepting only
|
|
316
|
+
// `-` turned a removal written with either of them into a silent no-op -
|
|
317
|
+
// archive reported success while the requirement stayed in the spec.
|
|
318
|
+
const bullet = line.match(/^\s*[-*+]\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
|
231
319
|
if (bullet) {
|
|
232
320
|
names.push(normalizeRequirementName(bullet[1]));
|
|
233
321
|
}
|
|
234
322
|
}
|
|
235
323
|
return names;
|
|
236
324
|
}
|
|
237
|
-
|
|
238
|
-
|
|
325
|
+
/**
|
|
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.
|
|
341
|
+
*
|
|
342
|
+
* The bullet is optional, and every CommonMark bullet marker is accepted: a
|
|
343
|
+
* rename written with `*` or `+` used to match nothing at all, so the rename
|
|
344
|
+
* silently never happened while archive still reported success.
|
|
345
|
+
*/
|
|
346
|
+
function parseRenamedPairs(sectionBody, unpaired) {
|
|
347
|
+
const { lines, fenceMask, bodyStartLine } = sectionBody;
|
|
239
348
|
if (lines.length === 0)
|
|
240
349
|
return [];
|
|
241
350
|
const pairs = [];
|
|
242
|
-
let
|
|
351
|
+
let pending;
|
|
352
|
+
const drop = (side, name, line) => {
|
|
353
|
+
unpaired?.push({ side, name, line });
|
|
354
|
+
};
|
|
243
355
|
for (let i = 0; i < lines.length; i++) {
|
|
244
356
|
if (fenceMask[i])
|
|
245
357
|
continue;
|
|
246
358
|
const line = lines[i];
|
|
247
|
-
|
|
248
|
-
|
|
359
|
+
// The bullet stays optional, and any CommonMark marker is accepted: a rename
|
|
360
|
+
// written with `*` or `+` used to match nothing at all, so the rename never
|
|
361
|
+
// happened while archive still reported success.
|
|
362
|
+
const fromMatch = line.match(/^\s*[-*+]?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
|
363
|
+
const toMatch = line.match(/^\s*[-*+]?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
|
249
364
|
if (fromMatch) {
|
|
250
|
-
|
|
365
|
+
if (pending)
|
|
366
|
+
drop('FROM', pending.name, pending.line);
|
|
367
|
+
pending = { name: normalizeRequirementName(fromMatch[1]), line: bodyStartLine + i };
|
|
251
368
|
}
|
|
252
369
|
else if (toMatch) {
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
370
|
+
const to = normalizeRequirementName(toMatch[1]);
|
|
371
|
+
if (!pending) {
|
|
372
|
+
drop('TO', to, bodyStartLine + i);
|
|
373
|
+
continue;
|
|
257
374
|
}
|
|
375
|
+
pairs.push({ from: pending.name, to });
|
|
376
|
+
pending = undefined;
|
|
258
377
|
}
|
|
259
378
|
}
|
|
379
|
+
if (pending)
|
|
380
|
+
drop('FROM', pending.name, pending.line);
|
|
260
381
|
return pairs;
|
|
261
382
|
}
|
|
262
383
|
/**
|
|
@@ -15,8 +15,17 @@ export { buildCodeFenceMask } from './code-fence.js';
|
|
|
15
15
|
* as a scenario, so the delta counter must too (parity). The delta/loss path
|
|
16
16
|
* reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
|
|
17
17
|
* keep both paths on it rather than reintroducing a separate `Scenario:` regex.
|
|
18
|
+
* A header alone is not yet a scenario on either path: see hasScenarioBody.
|
|
18
19
|
*/
|
|
19
20
|
export declare const SCENARIO_HEADER: RegExp;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a scenario's body has content. The spec path
|
|
23
|
+
* (`MarkdownParser.parseScenarios`) drops a scenario whose body is empty, so
|
|
24
|
+
* the delta counter must too (parity): otherwise `validate` accepts a
|
|
25
|
+
* requirement whose only scenario is a bare header, and archive rejects it when
|
|
26
|
+
* it validates the rebuilt spec.
|
|
27
|
+
*/
|
|
28
|
+
export declare function hasScenarioBody(body: string): boolean;
|
|
20
29
|
/**
|
|
21
30
|
* The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
|
|
22
31
|
* as whole words so the change-delta reader and the schema-based reader accept
|
|
@@ -49,8 +58,10 @@ export declare function extractRequirementBody(bodyLines: string[]): string;
|
|
|
49
58
|
export declare function extractRequirementText(headerTitle: string, bodyLines: string[]): string;
|
|
50
59
|
/**
|
|
51
60
|
* Count the real scenarios in a requirement block: `#### ` headers on non-fenced
|
|
52
|
-
* lines. A `#### Scenario:` that lives inside a fenced
|
|
53
|
-
* scenario and is not counted.
|
|
61
|
+
* lines whose body has content. A `#### Scenario:` that lives inside a fenced
|
|
62
|
+
* example is not a real scenario and is not counted.
|
|
54
63
|
*/
|
|
55
64
|
export declare function countScenarios(bodyLines: string[]): number;
|
|
65
|
+
/** Count the `#### ` headers in a requirement block that have no body under them. */
|
|
66
|
+
export declare function countEmptyScenarios(bodyLines: string[]): number;
|
|
56
67
|
//# sourceMappingURL=requirement-text.d.ts.map
|