@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.
Files changed (113) hide show
  1. package/README.md +16 -4
  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 +69 -22
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +5 -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 +260 -77
  17. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  18. package/dist/core/artifact-graph/outputs.js +38 -8
  19. package/dist/core/artifact-graph/schema.d.ts +14 -0
  20. package/dist/core/artifact-graph/schema.js +52 -0
  21. package/dist/core/artifact-graph/types.js +12 -1
  22. package/dist/core/change-status-policy.d.ts +14 -0
  23. package/dist/core/change-status-policy.js +14 -6
  24. package/dist/core/command-generation/adapters/continue.js +4 -0
  25. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  26. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  27. package/dist/core/command-generation/generator.js +2 -0
  28. package/dist/core/completion-tip.js +7 -7
  29. package/dist/core/completions/installers/bash-installer.js +15 -7
  30. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  31. package/dist/core/completions/installers/shell-quote.js +13 -0
  32. package/dist/core/completions/installers/zsh-installer.js +3 -2
  33. package/dist/core/config-schema.d.ts +1 -1
  34. package/dist/core/config.d.ts +16 -0
  35. package/dist/core/config.js +27 -1
  36. package/dist/core/global-config.d.ts +30 -2
  37. package/dist/core/global-config.js +57 -3
  38. package/dist/core/init.js +22 -6
  39. package/dist/core/legacy-cleanup.d.ts +5 -1
  40. package/dist/core/legacy-cleanup.js +207 -14
  41. package/dist/core/list.js +55 -11
  42. package/dist/core/migration.js +6 -1
  43. package/dist/core/parsers/change-parser.d.ts +12 -1
  44. package/dist/core/parsers/change-parser.js +85 -87
  45. package/dist/core/parsers/markdown-parser.js +4 -3
  46. package/dist/core/parsers/requirement-blocks.d.ts +78 -6
  47. package/dist/core/parsers/requirement-blocks.js +155 -38
  48. package/dist/core/parsers/requirement-text.d.ts +13 -2
  49. package/dist/core/parsers/requirement-text.js +34 -7
  50. package/dist/core/parsers/spec-structure.js +4 -1
  51. package/dist/core/project-config.d.ts +9 -1
  52. package/dist/core/project-config.js +11 -2
  53. package/dist/core/references.d.ts +20 -0
  54. package/dist/core/references.js +56 -0
  55. package/dist/core/shared/skill-content-equivalence.js +5 -1
  56. package/dist/core/shared/skill-generation.js +36 -8
  57. package/dist/core/shared/tool-detection.js +9 -3
  58. package/dist/core/specs-apply.d.ts +20 -0
  59. package/dist/core/specs-apply.js +101 -17
  60. package/dist/core/store/git.d.ts +33 -0
  61. package/dist/core/store/git.js +60 -6
  62. package/dist/core/store/operations.d.ts +1 -1
  63. package/dist/core/store/operations.js +32 -10
  64. package/dist/core/store/registry.d.ts +2 -1
  65. package/dist/core/store/registry.js +5 -1
  66. package/dist/core/templates/optional-workflow.d.ts +69 -0
  67. package/dist/core/templates/optional-workflow.js +140 -0
  68. package/dist/core/templates/workflows/apply-change.js +18 -3
  69. package/dist/core/templates/workflows/archive-change.js +70 -21
  70. package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
  71. package/dist/core/templates/workflows/continue-change.js +14 -4
  72. package/dist/core/templates/workflows/explore.js +34 -16
  73. package/dist/core/templates/workflows/ff-change.js +21 -5
  74. package/dist/core/templates/workflows/new-change.js +15 -3
  75. package/dist/core/templates/workflows/onboard.js +76 -41
  76. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  77. package/dist/core/templates/workflows/project-root.js +38 -0
  78. package/dist/core/templates/workflows/propose.js +22 -5
  79. package/dist/core/templates/workflows/sync-specs.js +40 -1
  80. package/dist/core/templates/workflows/update-change.js +66 -27
  81. package/dist/core/templates/workflows/verify-change.js +121 -49
  82. package/dist/core/update.d.ts +12 -0
  83. package/dist/core/update.js +78 -10
  84. package/dist/core/validation/purpose-placeholder.js +24 -9
  85. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  86. package/dist/core/validation/task-checkboxes.js +181 -0
  87. package/dist/core/validation/validator.d.ts +53 -1
  88. package/dist/core/validation/validator.js +160 -45
  89. package/dist/core/version-check.d.ts +14 -7
  90. package/dist/core/version-check.js +63 -17
  91. package/dist/core/worksets.js +6 -4
  92. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  93. package/dist/prompts/searchable-multi-select.js +35 -6
  94. package/dist/telemetry/config.js +14 -2
  95. package/dist/telemetry/index.d.ts +4 -3
  96. package/dist/telemetry/index.js +22 -10
  97. package/dist/telemetry/opt-out.d.ts +17 -0
  98. package/dist/telemetry/opt-out.js +27 -0
  99. package/dist/utils/file-system.js +20 -3
  100. package/dist/utils/line-endings.d.ts +34 -0
  101. package/dist/utils/line-endings.js +46 -0
  102. package/dist/utils/nested-change.d.ts +35 -0
  103. package/dist/utils/nested-change.js +186 -0
  104. package/dist/utils/spec-discovery.d.ts +20 -0
  105. package/dist/utils/spec-discovery.js +59 -0
  106. package/dist/utils/task-progress.d.ts +1 -1
  107. package/dist/utils/task-progress.js +38 -6
  108. package/package.json +5 -5
  109. package/schemas/spec-driven/schema.yaml +18 -3
  110. package/schemas/spec-driven/templates/design.md +2 -0
  111. package/schemas/spec-driven/templates/proposal.md +2 -0
  112. package/schemas/spec-driven/templates/spec.md +2 -0
  113. 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;
@@ -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
- * Shared by archive (which refuses to apply the block) and validate (which
63
- * reports the same loss at authoring time, #1477), so the two cannot disagree
64
- * about what counts as a dropped scenario.
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
- 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, ' ');
@@ -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
- const renamedPairs = renamedLookup.bodies.flatMap((body) => parseRenamedPairs(body));
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 from `## RENAMED Requirements`, in document order.
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 current = {};
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
- current.from = normalizeRequirementName(fromMatch[1]);
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
- current.to = normalizeRequirementName(toMatch[1]);
300
- if (current.from && current.to) {
301
- pairs.push({ from: current.from, to: current.to });
302
- current = {};
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
- * Shared by archive (which refuses to apply the block) and validate (which
314
- * reports the same loss at authoring time, #1477), so the two cannot disagree
315
- * about what counts as a dropped scenario.
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
- // Multiplicity-aware: a name present N times in current and M times in
319
- // incoming means max(0, N - M) instances are missing. Set membership would
320
- // treat N>M as fully covered and let archive silently drop duplicates
321
- // (residual #1246 / duplicate-scenario-name blind spot).
322
- const remainingIncoming = new Map();
323
- for (const scenario of parseScenarioBlocks(incoming.raw)) {
324
- const name = scenario.name;
325
- remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1);
326
- }
327
- const missing = [];
328
- for (const scenario of parseScenarioBlocks(current.raw)) {
329
- const name = scenario.name;
330
- const remaining = remainingIncoming.get(name) ?? 0;
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
- return missing;
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 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