@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.
Files changed (105) hide show
  1. package/README.md +3 -15
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +170 -16
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +8 -0
  13. package/dist/commands/workflow/shared.js +8 -0
  14. package/dist/commands/workflow/status.d.ts +5 -1
  15. package/dist/commands/workflow/status.js +42 -5
  16. package/dist/core/archive.js +17 -1
  17. package/dist/core/artifact-graph/schema.d.ts +14 -0
  18. package/dist/core/artifact-graph/schema.js +52 -0
  19. package/dist/core/artifact-graph/types.js +12 -1
  20. package/dist/core/change-status-policy.d.ts +14 -0
  21. package/dist/core/change-status-policy.js +14 -6
  22. package/dist/core/command-generation/generator.js +2 -0
  23. package/dist/core/completion-tip.js +7 -7
  24. package/dist/core/completions/installers/bash-installer.js +15 -7
  25. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  26. package/dist/core/completions/installers/shell-quote.js +13 -0
  27. package/dist/core/completions/installers/zsh-installer.js +3 -2
  28. package/dist/core/config-schema.d.ts +1 -1
  29. package/dist/core/config.d.ts +16 -0
  30. package/dist/core/config.js +27 -1
  31. package/dist/core/global-config.d.ts +30 -2
  32. package/dist/core/global-config.js +57 -3
  33. package/dist/core/init.js +33 -3
  34. package/dist/core/legacy-cleanup.d.ts +4 -0
  35. package/dist/core/legacy-cleanup.js +180 -12
  36. package/dist/core/list.js +55 -11
  37. package/dist/core/migration.js +6 -1
  38. package/dist/core/onboarding-commands.d.ts +11 -1
  39. package/dist/core/onboarding-commands.js +26 -0
  40. package/dist/core/parsers/change-parser.d.ts +12 -1
  41. package/dist/core/parsers/change-parser.js +85 -87
  42. package/dist/core/parsers/markdown-parser.js +4 -3
  43. package/dist/core/parsers/requirement-blocks.d.ts +44 -3
  44. package/dist/core/parsers/requirement-blocks.js +164 -43
  45. package/dist/core/parsers/requirement-text.d.ts +13 -2
  46. package/dist/core/parsers/requirement-text.js +34 -7
  47. package/dist/core/parsers/spec-structure.js +4 -1
  48. package/dist/core/project-config.d.ts +9 -1
  49. package/dist/core/project-config.js +11 -2
  50. package/dist/core/references.d.ts +20 -0
  51. package/dist/core/references.js +56 -0
  52. package/dist/core/shared/skill-content-equivalence.js +5 -1
  53. package/dist/core/shared/skill-generation.js +36 -8
  54. package/dist/core/shared/tool-detection.js +37 -4
  55. package/dist/core/specs-apply.js +260 -18
  56. package/dist/core/store/git.d.ts +33 -0
  57. package/dist/core/store/git.js +60 -6
  58. package/dist/core/store/operations.d.ts +1 -1
  59. package/dist/core/store/operations.js +32 -10
  60. package/dist/core/store/registry.d.ts +2 -1
  61. package/dist/core/store/registry.js +5 -1
  62. package/dist/core/templates/optional-workflow.d.ts +69 -0
  63. package/dist/core/templates/optional-workflow.js +140 -0
  64. package/dist/core/templates/workflows/apply-change.js +18 -3
  65. package/dist/core/templates/workflows/archive-change.js +50 -15
  66. package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
  67. package/dist/core/templates/workflows/continue-change.js +14 -2
  68. package/dist/core/templates/workflows/explore.js +48 -14
  69. package/dist/core/templates/workflows/ff-change.js +19 -3
  70. package/dist/core/templates/workflows/new-change.js +15 -3
  71. package/dist/core/templates/workflows/onboard.js +67 -37
  72. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  73. package/dist/core/templates/workflows/project-root.js +38 -0
  74. package/dist/core/templates/workflows/propose.js +52 -15
  75. package/dist/core/templates/workflows/sync-specs.js +40 -1
  76. package/dist/core/templates/workflows/update-change.js +52 -25
  77. package/dist/core/templates/workflows/verify-change.js +12 -3
  78. package/dist/core/update.d.ts +34 -0
  79. package/dist/core/update.js +109 -11
  80. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  81. package/dist/core/validation/task-checkboxes.js +181 -0
  82. package/dist/core/validation/validator.d.ts +53 -1
  83. package/dist/core/validation/validator.js +154 -40
  84. package/dist/core/version-check.d.ts +14 -7
  85. package/dist/core/version-check.js +63 -17
  86. package/dist/core/worksets.js +6 -4
  87. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  88. package/dist/prompts/searchable-multi-select.js +35 -6
  89. package/dist/telemetry/config.js +14 -2
  90. package/dist/telemetry/index.d.ts +4 -3
  91. package/dist/telemetry/index.js +22 -10
  92. package/dist/telemetry/opt-out.d.ts +17 -0
  93. package/dist/telemetry/opt-out.js +27 -0
  94. package/dist/utils/nested-change.d.ts +35 -0
  95. package/dist/utils/nested-change.js +186 -0
  96. package/dist/utils/spec-discovery.d.ts +20 -0
  97. package/dist/utils/spec-discovery.js +59 -0
  98. package/dist/utils/task-progress.d.ts +1 -1
  99. package/dist/utils/task-progress.js +38 -6
  100. package/package.json +4 -4
  101. package/schemas/spec-driven/schema.yaml +24 -5
  102. package/schemas/spec-driven/templates/design.md +2 -0
  103. package/schemas/spec-driven/templates/proposal.md +2 -0
  104. package/schemas/spec-driven/templates/spec.md +2 -0
  105. 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
- // Parse deltas from the What Changes section (simple format)
23
- const simpleDeltas = this.parseDeltas(whatChanges);
24
- // Check if there are spec files with delta format
25
- const specsDir = path.join(this.changeDir, 'specs');
26
- const deltaDeltas = await this.parseDeltaSpecs(specsDir);
27
- // Combine both types of deltas, preferring delta format if available
28
- const deltas = deltaDeltas.length > 0 ? deltaDeltas : simpleDeltas;
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
- async parseDeltaSpecs(specsDir) {
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
- // Discover delta specs recursively so nested layouts like
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 specDeltas = this.parseSpecDeltas(id, content);
49
- deltas.push(...specDeltas);
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
- parseSpecDeltas(specName, content) {
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
- const addedSection = this.findSection(sections, 'ADDED Requirements');
83
- if (addedSection) {
84
- const requirements = this.parseRequirements(addedSection);
85
- requirements.forEach(req => {
86
- deltas.push({
87
- spec: specName,
88
- operation: 'ADDED',
89
- description: `Add requirement: ${req.text}`,
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
- const modifiedSection = this.findSection(sections, 'MODIFIED Requirements');
98
- if (modifiedSection) {
99
- const requirements = this.parseRequirements(modifiedSection);
100
- requirements.forEach(req => {
101
- deltas.push({
102
- spec: specName,
103
- operation: 'MODIFIED',
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
- const removedSection = this.findSection(sections, 'REMOVED Requirements');
112
- if (removedSection) {
113
- const requirements = this.parseRequirements(removedSection);
114
- requirements.forEach(req => {
115
- deltas.push({
116
- spec: specName,
117
- operation: 'REMOVED',
118
- description: `Remove requirement: ${req.text}`,
119
- requirement: req,
120
- requirements: [req],
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
- const renamedSection = this.findSection(sections, 'RENAMED Requirements');
126
- if (renamedSection) {
127
- const renames = this.parseRenames(renamedSection.content);
128
- renames.forEach(rename => {
129
- deltas.push({
130
- spec: specName,
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
- parseRenames(content) {
140
- const renames = [];
141
- const lines = ChangeParser.normalizeContent(content).split('\n');
142
- let currentRename = {};
143
- for (const line of lines) {
144
- const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
145
- const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
146
- if (fromMatch) {
147
- currentRename.from = fromMatch[1].trim();
148
- }
149
- else if (toMatch) {
150
- currentRename.to = toMatch[1].trim();
151
- if (currentRename.from && currentRename.to) {
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
- if (scenarioSection.content.trim()) {
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 headers and the
18
- * RENAMED+REMOVED cross-section conflict - where two spellings that differ
19
- * only in case or interior whitespace mean a mistake, never two requirements.
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
- return name.trim();
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 headers and the
9
- * RENAMED+REMOVED cross-section conflict - where two spellings that differ
10
- * only in case or interior whitespace mean a mistake, never two requirements.
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 = getSectionCaseInsensitive(sections, 'ADDED Requirements');
103
- const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');
104
- const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');
105
- const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements');
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 = parseRequirementBlocksFromSection(addedLookup.body, {
112
+ const added = addedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body, {
108
113
  section: addedLookup.title,
109
- bodyStartLine: addedLookup.bodyStartLine,
114
+ bodyStartLine: body.bodyStartLine,
110
115
  sink: skippedHeaders,
111
- });
112
- const modified = parseRequirementBlocksFromSection(modifiedLookup.body, {
116
+ }));
117
+ const modified = modifiedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body, {
113
118
  section: modifiedLookup.title,
114
- bodyStartLine: modifiedLookup.bodyStartLine,
119
+ bodyStartLine: body.bodyStartLine,
115
120
  sink: skippedHeaders,
116
- });
117
- const removedNames = parseRemovedNames(removedLookup.body);
118
- const removedBlocks = parseRequirementBlocksFromSection(removedLookup.body);
119
- const renamedPairs = parseRenamedPairs(renamedLookup.body);
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 result = {};
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
- result[current.title] = {
152
- lines: lines.slice(current.index + 1, end),
153
- fenceMask: fenceMask.slice(current.index + 1, end),
154
- bodyStartLine: current.index + 2,
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 result;
222
+ return sections;
158
223
  }
159
- const EMPTY_SECTION_BODY = { lines: [], fenceMask: [], bodyStartLine: 0 };
160
- function getSectionCaseInsensitive(sections, desired) {
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
- for (const [title, body] of Object.entries(sections)) {
163
- if (title.toLowerCase() === target) {
164
- return { title, body, bodyStartLine: body.bodyStartLine, found: true };
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 { title: desired, body: EMPTY_SECTION_BODY, bodyStartLine: 0, found: false };
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
- const bullet = line.match(/^\s*-\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
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
- function parseRenamedPairs(sectionBody) {
238
- const { lines, fenceMask } = sectionBody;
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 current = {};
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
- const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
248
- const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
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
- current.from = normalizeRequirementName(fromMatch[1]);
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
- current.to = normalizeRequirementName(toMatch[1]);
254
- if (current.from && current.to) {
255
- pairs.push({ from: current.from, to: current.to });
256
- current = {};
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 example is not a real
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