@fission-ai/openspec 1.13.0 → 1.13.2
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 +16 -4
- 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 +69 -22
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +5 -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 +260 -77
- package/dist/core/artifact-graph/outputs.d.ts +1 -1
- package/dist/core/artifact-graph/outputs.js +38 -8
- 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/adapters/continue.js +4 -0
- package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
- package/dist/core/command-generation/adapters/kilocode.js +2 -2
- 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 +22 -6
- package/dist/core/legacy-cleanup.d.ts +5 -1
- package/dist/core/legacy-cleanup.js +207 -14
- 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 +78 -6
- package/dist/core/parsers/requirement-blocks.js +155 -38
- 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.d.ts +20 -0
- package/dist/core/specs-apply.js +101 -17
- 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 +70 -21
- package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
- package/dist/core/templates/workflows/continue-change.js +14 -4
- package/dist/core/templates/workflows/explore.js +34 -16
- package/dist/core/templates/workflows/ff-change.js +21 -5
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +76 -41
- 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 +66 -27
- package/dist/core/templates/workflows/verify-change.js +121 -49
- package/dist/core/update.d.ts +12 -0
- package/dist/core/update.js +78 -10
- package/dist/core/validation/purpose-placeholder.js +24 -9
- 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 +160 -45
- 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/file-system.js +20 -3
- package/dist/utils/line-endings.d.ts +34 -0
- package/dist/utils/line-endings.js +46 -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 +5 -5
- package/schemas/spec-driven/schema.yaml +18 -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
|
@@ -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;
|
|
@@ -54,14 +95,45 @@ export interface DeltaPlan {
|
|
|
54
95
|
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
|
|
55
96
|
*/
|
|
56
97
|
export declare function parseDeltaSpec(content: string): DeltaPlan;
|
|
98
|
+
/** Both directions of the scenario-name comparison, plus the two totals. */
|
|
99
|
+
export interface ScenarioNameDiff {
|
|
100
|
+
/** Names the current block has that the incoming block does not cover. */
|
|
101
|
+
missing: string[];
|
|
102
|
+
/** Names the incoming block introduces that the current block does not have. */
|
|
103
|
+
added: string[];
|
|
104
|
+
/** Level-4 headers in the current block. */
|
|
105
|
+
currentCount: number;
|
|
106
|
+
/** Level-4 headers in the incoming block. */
|
|
107
|
+
incomingCount: number;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Compare the scenario names of a current requirement block and an incoming
|
|
111
|
+
* (MODIFIED) one, in both directions.
|
|
112
|
+
*
|
|
113
|
+
* `missing` is the loss the guard exists to catch: a MODIFIED requirement
|
|
114
|
+
* replaces the whole block, so every name there would be dropped from the main
|
|
115
|
+
* spec. `added` and the two counts are reported alongside it, because they are
|
|
116
|
+
* the first thing a reader checks once it fires (#1697) - a block that omits
|
|
117
|
+
* two names and introduces two is shaped like a rename, one that omits two and
|
|
118
|
+
* introduces none is shaped like a truncation. Neither is proof, and intent is
|
|
119
|
+
* not recoverable from structure, so this decides nothing and only says what
|
|
120
|
+
* the two blocks contain.
|
|
121
|
+
*/
|
|
122
|
+
export declare function diffScenarioNames(current: RequirementBlock, incoming: RequirementBlock): ScenarioNameDiff;
|
|
57
123
|
/**
|
|
58
124
|
* Scenario names the current requirement block has and the incoming
|
|
59
125
|
* (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
|
|
60
126
|
* so every name reported here would be dropped from the main spec.
|
|
61
127
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* about what counts as a dropped
|
|
128
|
+
* The `missing` half of diffScenarioNames, which archive (refusing to apply
|
|
129
|
+
* the block) and validate (reporting the same loss at authoring time, #1477)
|
|
130
|
+
* both go through, so the two cannot disagree about what counts as a dropped
|
|
131
|
+
* scenario.
|
|
65
132
|
*/
|
|
66
133
|
export declare function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[];
|
|
134
|
+
/**
|
|
135
|
+
* The one sentence archive and validate both append when the guard fires, so
|
|
136
|
+
* the counts a reader sees cannot differ between the two commands.
|
|
137
|
+
*/
|
|
138
|
+
export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
|
|
67
139
|
//# sourceMappingURL=requirement-blocks.d.ts.map
|
|
@@ -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,49 +362,97 @@ 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
|
}
|
|
383
|
+
/**
|
|
384
|
+
* Compare the scenario names of a current requirement block and an incoming
|
|
385
|
+
* (MODIFIED) one, in both directions.
|
|
386
|
+
*
|
|
387
|
+
* `missing` is the loss the guard exists to catch: a MODIFIED requirement
|
|
388
|
+
* replaces the whole block, so every name there would be dropped from the main
|
|
389
|
+
* spec. `added` and the two counts are reported alongside it, because they are
|
|
390
|
+
* the first thing a reader checks once it fires (#1697) - a block that omits
|
|
391
|
+
* two names and introduces two is shaped like a rename, one that omits two and
|
|
392
|
+
* introduces none is shaped like a truncation. Neither is proof, and intent is
|
|
393
|
+
* not recoverable from structure, so this decides nothing and only says what
|
|
394
|
+
* the two blocks contain.
|
|
395
|
+
*/
|
|
396
|
+
export function diffScenarioNames(current, incoming) {
|
|
397
|
+
const currentNames = parseScenarioBlocks(current.raw).map((scenario) => scenario.name);
|
|
398
|
+
const incomingNames = parseScenarioBlocks(incoming.raw).map((scenario) => scenario.name);
|
|
399
|
+
// Multiplicity-aware: a name present N times on one side and M times on the
|
|
400
|
+
// other leaves max(0, N - M) instances unmatched. Set membership would treat
|
|
401
|
+
// N>M as fully covered and let archive silently drop duplicates (residual
|
|
402
|
+
// #1246 / duplicate-scenario-name blind spot).
|
|
403
|
+
const unmatched = (names, against) => {
|
|
404
|
+
const remaining = new Map();
|
|
405
|
+
for (const name of against)
|
|
406
|
+
remaining.set(name, (remaining.get(name) ?? 0) + 1);
|
|
407
|
+
const out = [];
|
|
408
|
+
for (const name of names) {
|
|
409
|
+
const left = remaining.get(name) ?? 0;
|
|
410
|
+
if (left > 0)
|
|
411
|
+
remaining.set(name, left - 1);
|
|
412
|
+
else
|
|
413
|
+
out.push(name);
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
};
|
|
417
|
+
return {
|
|
418
|
+
missing: unmatched(currentNames, incomingNames),
|
|
419
|
+
added: unmatched(incomingNames, currentNames),
|
|
420
|
+
currentCount: currentNames.length,
|
|
421
|
+
incomingCount: incomingNames.length,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
308
424
|
/**
|
|
309
425
|
* Scenario names the current requirement block has and the incoming
|
|
310
426
|
* (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
|
|
311
427
|
* so every name reported here would be dropped from the main spec.
|
|
312
428
|
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* about what counts as a dropped
|
|
429
|
+
* The `missing` half of diffScenarioNames, which archive (refusing to apply
|
|
430
|
+
* the block) and validate (reporting the same loss at authoring time, #1477)
|
|
431
|
+
* both go through, so the two cannot disagree about what counts as a dropped
|
|
432
|
+
* scenario.
|
|
316
433
|
*/
|
|
317
434
|
export function findMissingCurrentScenarios(current, incoming) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (remaining > 0) {
|
|
332
|
-
remainingIncoming.set(name, remaining - 1);
|
|
333
|
-
}
|
|
334
|
-
else {
|
|
335
|
-
missing.push(name);
|
|
336
|
-
}
|
|
435
|
+
return diffScenarioNames(current, incoming).missing;
|
|
436
|
+
}
|
|
437
|
+
/** At most this many added names are listed before the rest are counted. */
|
|
438
|
+
const MAX_LISTED_ADDED_SCENARIOS = 3;
|
|
439
|
+
/**
|
|
440
|
+
* The one sentence archive and validate both append when the guard fires, so
|
|
441
|
+
* the counts a reader sees cannot differ between the two commands.
|
|
442
|
+
*/
|
|
443
|
+
export function describeScenarioBalance(diff) {
|
|
444
|
+
const count = (value) => `${value} ${value === 1 ? 'scenario' : 'scenarios'}`;
|
|
445
|
+
const scale = `The modified block has ${count(diff.incomingCount)}; the current spec has ${count(diff.currentCount)}.`;
|
|
446
|
+
if (diff.added.length === 0) {
|
|
447
|
+
return `${scale} It adds none.`;
|
|
337
448
|
}
|
|
338
|
-
|
|
449
|
+
const listed = diff.added
|
|
450
|
+
.slice(0, MAX_LISTED_ADDED_SCENARIOS)
|
|
451
|
+
.map((name) => `"${name}"`)
|
|
452
|
+
.join(', ');
|
|
453
|
+
const rest = diff.added.length - MAX_LISTED_ADDED_SCENARIOS;
|
|
454
|
+
const names = rest > 0 ? `${listed} and ${rest} more` : listed;
|
|
455
|
+
return `${scale} It adds ${count(diff.added.length)} not in the current spec: ${names}.`;
|
|
339
456
|
}
|
|
340
457
|
/**
|
|
341
458
|
* Any non-fenced level-4 header on the given (masked) line. Reuses the spec
|
|
@@ -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
|