@fission-ai/openspec 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +1 -1
  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 +33 -14
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.js +8 -0
  13. package/dist/commands/workflow/status.d.ts +5 -1
  14. package/dist/commands/workflow/status.js +42 -5
  15. package/dist/core/archive.js +17 -1
  16. package/dist/core/artifact-graph/schema.d.ts +14 -0
  17. package/dist/core/artifact-graph/schema.js +52 -0
  18. package/dist/core/artifact-graph/types.js +12 -1
  19. package/dist/core/change-status-policy.d.ts +14 -0
  20. package/dist/core/change-status-policy.js +14 -6
  21. package/dist/core/command-generation/generator.js +2 -0
  22. package/dist/core/completion-tip.js +7 -7
  23. package/dist/core/completions/installers/bash-installer.js +15 -7
  24. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  25. package/dist/core/completions/installers/shell-quote.js +13 -0
  26. package/dist/core/completions/installers/zsh-installer.js +3 -2
  27. package/dist/core/config-schema.d.ts +1 -1
  28. package/dist/core/config.d.ts +16 -0
  29. package/dist/core/config.js +27 -1
  30. package/dist/core/global-config.d.ts +30 -2
  31. package/dist/core/global-config.js +57 -3
  32. package/dist/core/init.js +13 -3
  33. package/dist/core/legacy-cleanup.d.ts +4 -0
  34. package/dist/core/legacy-cleanup.js +180 -12
  35. package/dist/core/list.js +55 -11
  36. package/dist/core/migration.js +6 -1
  37. package/dist/core/parsers/change-parser.d.ts +12 -1
  38. package/dist/core/parsers/change-parser.js +85 -87
  39. package/dist/core/parsers/markdown-parser.js +4 -3
  40. package/dist/core/parsers/requirement-blocks.d.ts +44 -3
  41. package/dist/core/parsers/requirement-blocks.js +90 -15
  42. package/dist/core/parsers/requirement-text.d.ts +13 -2
  43. package/dist/core/parsers/requirement-text.js +34 -7
  44. package/dist/core/parsers/spec-structure.js +4 -1
  45. package/dist/core/project-config.d.ts +9 -1
  46. package/dist/core/project-config.js +11 -2
  47. package/dist/core/references.d.ts +20 -0
  48. package/dist/core/references.js +56 -0
  49. package/dist/core/shared/skill-content-equivalence.js +5 -1
  50. package/dist/core/shared/skill-generation.js +36 -8
  51. package/dist/core/shared/tool-detection.js +9 -3
  52. package/dist/core/specs-apply.js +82 -11
  53. package/dist/core/store/git.d.ts +33 -0
  54. package/dist/core/store/git.js +60 -6
  55. package/dist/core/store/operations.d.ts +1 -1
  56. package/dist/core/store/operations.js +32 -10
  57. package/dist/core/store/registry.d.ts +2 -1
  58. package/dist/core/store/registry.js +5 -1
  59. package/dist/core/templates/optional-workflow.d.ts +69 -0
  60. package/dist/core/templates/optional-workflow.js +140 -0
  61. package/dist/core/templates/workflows/apply-change.js +18 -3
  62. package/dist/core/templates/workflows/archive-change.js +50 -15
  63. package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
  64. package/dist/core/templates/workflows/continue-change.js +14 -2
  65. package/dist/core/templates/workflows/explore.js +32 -14
  66. package/dist/core/templates/workflows/ff-change.js +19 -3
  67. package/dist/core/templates/workflows/new-change.js +15 -3
  68. package/dist/core/templates/workflows/onboard.js +67 -37
  69. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  70. package/dist/core/templates/workflows/project-root.js +38 -0
  71. package/dist/core/templates/workflows/propose.js +22 -5
  72. package/dist/core/templates/workflows/sync-specs.js +40 -1
  73. package/dist/core/templates/workflows/update-change.js +52 -25
  74. package/dist/core/templates/workflows/verify-change.js +12 -3
  75. package/dist/core/update.d.ts +12 -0
  76. package/dist/core/update.js +58 -3
  77. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  78. package/dist/core/validation/task-checkboxes.js +181 -0
  79. package/dist/core/validation/validator.d.ts +53 -1
  80. package/dist/core/validation/validator.js +154 -40
  81. package/dist/core/version-check.d.ts +14 -7
  82. package/dist/core/version-check.js +63 -17
  83. package/dist/core/worksets.js +6 -4
  84. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  85. package/dist/prompts/searchable-multi-select.js +35 -6
  86. package/dist/telemetry/config.js +14 -2
  87. package/dist/telemetry/index.d.ts +4 -3
  88. package/dist/telemetry/index.js +22 -10
  89. package/dist/telemetry/opt-out.d.ts +17 -0
  90. package/dist/telemetry/opt-out.js +27 -0
  91. package/dist/utils/nested-change.d.ts +35 -0
  92. package/dist/utils/nested-change.js +186 -0
  93. package/dist/utils/spec-discovery.d.ts +20 -0
  94. package/dist/utils/spec-discovery.js +59 -0
  95. package/dist/utils/task-progress.d.ts +1 -1
  96. package/dist/utils/task-progress.js +38 -6
  97. package/package.json +4 -4
  98. package/schemas/spec-driven/schema.yaml +10 -3
  99. package/schemas/spec-driven/templates/design.md +2 -0
  100. package/schemas/spec-driven/templates/proposal.md +2 -0
  101. package/schemas/spec-driven/templates/spec.md +2 -0
  102. package/schemas/spec-driven/templates/tasks.md +2 -0
package/dist/core/list.js CHANGED
@@ -4,12 +4,24 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre
4
4
  import { readFileSync } from 'fs';
5
5
  import { MarkdownParser } from './parsers/markdown-parser.js';
6
6
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
7
+ import { describeNestedChange, findNestedChanges, } from '../utils/nested-change.js';
7
8
  function isMissingPathError(error) {
8
9
  return (typeof error === 'object' &&
9
10
  error !== null &&
10
11
  'code' in error &&
11
12
  error.code === 'ENOENT');
12
13
  }
14
+ /**
15
+ * An entry that cannot be dated because it no longer resolves: it was removed
16
+ * after `readdir` listed it, or it is a symlink whose target is missing (an
17
+ * Emacs `.#file` lock) or that loops back on itself.
18
+ */
19
+ function isUnresolvableEntryError(error) {
20
+ if (typeof error !== 'object' || error === null || !('code' in error))
21
+ return false;
22
+ const code = error.code;
23
+ return code === 'ENOENT' || code === 'ELOOP';
24
+ }
13
25
  async function readChangeDirectoryEntries(changesDir) {
14
26
  try {
15
27
  return await fs.readdir(changesDir, { withFileTypes: true });
@@ -30,14 +42,21 @@ async function getLastModified(dirPath) {
30
42
  const entries = await fs.readdir(dir, { withFileTypes: true });
31
43
  for (const entry of entries) {
32
44
  const fullPath = path.join(dir, entry.name);
33
- if (entry.isDirectory()) {
34
- await walk(fullPath);
35
- }
36
- else {
37
- const stat = await fs.stat(fullPath);
38
- if (latest === null || stat.mtime > latest) {
39
- latest = stat.mtime;
45
+ try {
46
+ if (entry.isDirectory()) {
47
+ await walk(fullPath);
40
48
  }
49
+ else {
50
+ const stat = await fs.stat(fullPath);
51
+ if (latest === null || stat.mtime > latest) {
52
+ latest = stat.mtime;
53
+ }
54
+ }
55
+ }
56
+ catch (error) {
57
+ // Skip the one entry rather than fail the listing of every change.
58
+ if (!isUnresolvableEntryError(error))
59
+ throw error;
41
60
  }
42
61
  }
43
62
  }
@@ -96,6 +115,11 @@ export class ListCommand {
96
115
  }
97
116
  // Collect information about each change
98
117
  const changes = [];
118
+ // A directory that only wraps nested change directories is still listed -
119
+ // hiding it would hide a real change whenever the probe is wrong - but it
120
+ // is listed as what it is, so the nesting stops failing silently (#1846).
121
+ const nestedFindings = await findNestedChanges(changesDir, changeDirs);
122
+ const nestedByName = new Map(nestedFindings.map((finding) => [finding.name, finding]));
99
123
  for (const changeDir of changeDirs) {
100
124
  const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath);
101
125
  const changePath = path.join(changesDir, changeDir);
@@ -104,7 +128,8 @@ export class ListCommand {
104
128
  name: changeDir,
105
129
  completedTasks: progress.completed,
106
130
  totalTasks: progress.total,
107
- lastModified
131
+ lastModified,
132
+ ...(nestedByName.has(changeDir) ? { nested: nestedByName.get(changeDir).nested } : {})
108
133
  });
109
134
  }
110
135
  // Sort by preference (default: recent first)
@@ -121,9 +146,22 @@ export class ListCommand {
121
146
  completedTasks: c.completedTasks,
122
147
  totalTasks: c.totalTasks,
123
148
  lastModified: c.lastModified.toISOString(),
124
- status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress'
149
+ status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress',
150
+ ...(c.nested ? { nested: c.nested } : {})
125
151
  }));
126
- console.log(JSON.stringify({ changes: jsonOutput, ...(root ? { root } : {}) }, null, 2));
152
+ // Additive: the entries keep their shape so existing consumers are
153
+ // unaffected, and the nesting is reported alongside them.
154
+ const warnings = nestedFindings.map((finding) => ({
155
+ code: 'nested_change_directory',
156
+ name: finding.name,
157
+ nested: finding.nested,
158
+ message: describeNestedChange(finding)
159
+ }));
160
+ console.log(JSON.stringify({
161
+ changes: jsonOutput,
162
+ ...(warnings.length > 0 ? { warnings } : {}),
163
+ ...(root ? { root } : {})
164
+ }, null, 2));
127
165
  return;
128
166
  }
129
167
  // Display results
@@ -132,10 +170,16 @@ export class ListCommand {
132
170
  const nameWidth = Math.max(...changes.map(c => c.name.length));
133
171
  for (const change of changes) {
134
172
  const paddedName = change.name.padEnd(nameWidth);
135
- const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
173
+ const status = change.nested
174
+ ? 'not a change'
175
+ : formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
136
176
  const timeAgo = formatRelativeTime(change.lastModified);
137
177
  console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
138
178
  }
179
+ for (const finding of nestedFindings) {
180
+ console.log('');
181
+ console.log(`Warning: ${describeNestedChange(finding)}`);
182
+ }
139
183
  return;
140
184
  }
141
185
  // specs mode
@@ -5,7 +5,7 @@
5
5
  * Called by both init and update commands before profile resolution.
6
6
  */
7
7
  import { AI_TOOLS } from './config.js';
8
- import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig } from './global-config.js';
8
+ import { getGlobalConfig, getGlobalConfigPath, isGlobalConfigUnreadable, saveGlobalConfig } from './global-config.js';
9
9
  import { CommandAdapterRegistry } from './command-generation/index.js';
10
10
  import { resolveCommandInvocation, resolveCommandSurfaceCapability, shouldGenerateCommandsForTool, } from './command-surface.js';
11
11
  import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';
@@ -435,6 +435,11 @@ function inferDelivery(artifacts) {
435
435
  * - If profile field already exists: no-op.
436
436
  */
437
437
  export function migrateIfNeeded(projectPath, tools) {
438
+ // A config that cannot be parsed, or is not a JSON object, is never saved
439
+ // over; skip migration rather than fail init or update on it.
440
+ if (isGlobalConfigUnreadable()) {
441
+ return;
442
+ }
438
443
  const config = getGlobalConfig();
439
444
  // Check raw config file for profile field presence
440
445
  const configPath = getGlobalConfigPath();
@@ -20,8 +20,19 @@ export declare class ChangeParser extends MarkdownParser {
20
20
  * `view`, `list`, `spec --json`, spec validation — untouched.
21
21
  */
22
22
  protected parseRequirements(section: Section): Requirement[];
23
+ /**
24
+ * The deltas in one spec file, read by parseDeltaSpec — the reader archive
25
+ * applies — so what `show` reports is what archive will do. This used to be a
26
+ * second reader that disagreed with it: a bullet-form REMOVED was invisible,
27
+ * a repeated section header was read only once, and a RENAMED line written
28
+ * with `*` or `+` was dropped.
29
+ */
23
30
  private parseSpecDeltas;
24
- private parseRenames;
31
+ /**
32
+ * One Requirement per block, read by the same section parser (and the
33
+ * header filter above) as before, so text and scenarios are unchanged.
34
+ */
35
+ private toRequirements;
25
36
  private parseSectionsFromContent;
26
37
  private getContentUntilNextHeaderFromLines;
27
38
  }
@@ -1,8 +1,14 @@
1
1
  import { MarkdownParser } from './markdown-parser.js';
2
2
  import { buildCodeFenceMask } from './requirement-text.js';
3
+ import { parseDeltaSpec } from './requirement-blocks.js';
3
4
  import path from 'path';
4
5
  import { promises as fs } from 'fs';
5
6
  import { discoverSpecFiles } from '../../utils/spec-discovery.js';
7
+ /** A header-only block for a REMOVED entry written in the bullet form. */
8
+ function removedNameBlock(name) {
9
+ const headerLine = `### Requirement: ${name}`;
10
+ return { headerLine, name, raw: headerLine };
11
+ }
6
12
  export class ChangeParser extends MarkdownParser {
7
13
  changeDir;
8
14
  constructor(content, changeDir) {
@@ -19,13 +25,16 @@ export class ChangeParser extends MarkdownParser {
19
25
  if (!whatChanges) {
20
26
  throw new Error('Change must have a What Changes section');
21
27
  }
22
- // 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, ' ');
@@ -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,16 +362,22 @@ function parseRenamedPairs(sectionBody) {
293
362
  const fromMatch = line.match(/^\s*[-*+]?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
294
363
  const toMatch = line.match(/^\s*[-*+]?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
295
364
  if (fromMatch) {
296
- 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
  }
308
383
  /**