reffy-cli 1.1.1 → 1.1.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.
@@ -33,20 +33,27 @@ async function listFilesRecursive(dir) {
33
33
  }
34
34
  return files.sort();
35
35
  }
36
- function parseAddedRequirements(content, relPath) {
36
+ function parseArchiveableRequirements(content, relPath) {
37
37
  const lines = content.split(/\r?\n/);
38
38
  const sectionTypes = new Set();
39
- const blocks = [];
40
- let inAddedSection = false;
39
+ const added = [];
40
+ const modified = [];
41
+ let currentSectionType = null;
41
42
  let currentTitle = null;
42
43
  let currentLines = [];
43
44
  const flush = () => {
44
45
  if (!currentTitle)
45
46
  return;
46
- blocks.push({
47
+ const block = {
47
48
  title: currentTitle,
48
49
  content: currentLines.join("\n").trimEnd(),
49
- });
50
+ };
51
+ if (currentSectionType === "ADDED") {
52
+ added.push(block);
53
+ }
54
+ else if (currentSectionType === "MODIFIED") {
55
+ modified.push(block);
56
+ }
50
57
  currentTitle = null;
51
58
  currentLines = [];
52
59
  };
@@ -56,7 +63,7 @@ function parseAddedRequirements(content, relPath) {
56
63
  flush();
57
64
  const sectionType = sectionMatch[1] ?? "";
58
65
  sectionTypes.add(sectionType);
59
- inAddedSection = sectionType === "ADDED";
66
+ currentSectionType = sectionType === "ADDED" || sectionType === "MODIFIED" ? sectionType : null;
60
67
  continue;
61
68
  }
62
69
  const requirementMatch = line.match(REQUIREMENT_PATTERN);
@@ -71,17 +78,17 @@ function parseAddedRequirements(content, relPath) {
71
78
  }
72
79
  }
73
80
  flush();
74
- const unsupported = Array.from(sectionTypes).filter((sectionType) => sectionType !== "ADDED");
81
+ const unsupported = Array.from(sectionTypes).filter((sectionType) => sectionType !== "ADDED" && sectionType !== "MODIFIED");
75
82
  if (unsupported.length > 0) {
76
83
  throw new Error(`${relPath}: unsupported delta sections for archive: ${unsupported.join(", ")}`);
77
84
  }
78
- if (!inAddedSection && sectionTypes.size === 0) {
85
+ if (sectionTypes.size === 0) {
79
86
  throw new Error(`${relPath}: no supported archiveable requirements found`);
80
87
  }
81
- if (blocks.length === 0) {
82
- throw new Error(`${relPath}: no ADDED requirements found to archive`);
88
+ if (added.length === 0 && modified.length === 0) {
89
+ throw new Error(`${relPath}: no archiveable requirements found`);
83
90
  }
84
- return blocks;
91
+ return { added, modified };
85
92
  }
86
93
  function buildNewCurrentSpec(capability, changeId, blocks) {
87
94
  return [
@@ -95,18 +102,74 @@ function buildNewCurrentSpec(capability, changeId, blocks) {
95
102
  "",
96
103
  ].join("\n");
97
104
  }
98
- function appendRequirementsToCurrentSpec(existing, blocks, relPath) {
105
+ function parseCurrentSpecRequirementBlocks(existing, relPath) {
99
106
  if (!existing.includes(REQUIREMENTS_HEADING)) {
100
107
  throw new Error(`${relPath}: current spec is missing a "## Requirements" section`);
101
108
  }
102
- for (const block of blocks) {
103
- const escaped = block.title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
104
- const duplicatePattern = new RegExp(`^###\\s+Requirement:\\s+${escaped}\\s*$`, "m");
105
- if (duplicatePattern.test(existing)) {
109
+ const lines = existing.split(/\r?\n/);
110
+ const blocks = [];
111
+ let inRequirements = false;
112
+ let currentTitle = null;
113
+ let currentLines = [];
114
+ const flush = () => {
115
+ if (!currentTitle)
116
+ return;
117
+ blocks.push({
118
+ title: currentTitle,
119
+ content: currentLines.join("\n").trimEnd(),
120
+ });
121
+ currentTitle = null;
122
+ currentLines = [];
123
+ };
124
+ for (const line of lines) {
125
+ if (line.trim() === REQUIREMENTS_HEADING) {
126
+ inRequirements = true;
127
+ continue;
128
+ }
129
+ if (!inRequirements) {
130
+ continue;
131
+ }
132
+ if (/^##\s+/.test(line) && line.trim() !== REQUIREMENTS_HEADING) {
133
+ flush();
134
+ break;
135
+ }
136
+ const requirementMatch = line.match(REQUIREMENT_PATTERN);
137
+ if (requirementMatch) {
138
+ flush();
139
+ currentTitle = requirementMatch[1]?.trim() ?? "unknown";
140
+ currentLines = [line];
141
+ continue;
142
+ }
143
+ if (currentTitle) {
144
+ currentLines.push(line);
145
+ }
146
+ }
147
+ flush();
148
+ return blocks;
149
+ }
150
+ function mergeRequirementsIntoCurrentSpec(existing, delta, relPath) {
151
+ const currentBlocks = parseCurrentSpecRequirementBlocks(existing, relPath);
152
+ const currentTitles = new Set(currentBlocks.map((block) => block.title));
153
+ for (const block of delta.added) {
154
+ if (currentTitles.has(block.title)) {
106
155
  throw new Error(`${relPath}: current spec already contains requirement "${block.title}"`);
107
156
  }
157
+ currentBlocks.push(block);
158
+ currentTitles.add(block.title);
159
+ }
160
+ for (const block of delta.modified) {
161
+ const index = currentBlocks.findIndex((currentBlock) => currentBlock.title === block.title);
162
+ if (index < 0) {
163
+ throw new Error(`${relPath}: current spec is missing requirement "${block.title}" required for MODIFIED archive`);
164
+ }
165
+ currentBlocks[index] = block;
166
+ }
167
+ const requirementsHeadingIndex = existing.indexOf(REQUIREMENTS_HEADING);
168
+ if (requirementsHeadingIndex < 0) {
169
+ throw new Error(`${relPath}: current spec is missing a "## Requirements" section`);
108
170
  }
109
- return `${existing.trimEnd()}\n\n${blocks.map((block) => block.content).join("\n\n")}\n`;
171
+ const prefix = existing.slice(0, requirementsHeadingIndex + REQUIREMENTS_HEADING.length);
172
+ return `${prefix}\n${currentBlocks.map((block) => `\n${block.content}`).join("")}\n`;
110
173
  }
111
174
  async function buildSpecUpdates(repoRoot, changeId, changeDir) {
112
175
  const specsRoot = path.join(changeDir, "specs");
@@ -121,11 +184,17 @@ async function buildSpecUpdates(repoRoot, changeId, changeDir) {
121
184
  continue;
122
185
  const deltaContent = await fs.readFile(deltaPath, "utf8");
123
186
  const relDeltaPath = path.relative(repoRoot, deltaPath).split(path.sep).join("/");
124
- const blocks = parseAddedRequirements(deltaContent, relDeltaPath);
187
+ const delta = parseArchiveableRequirements(deltaContent, relDeltaPath);
125
188
  const currentSpecPath = path.join(repoRoot, DEFAULT_PLANNING_DIRNAME, "specs", capability, "spec.md");
126
- const nextContent = (await pathExists(currentSpecPath))
127
- ? appendRequirementsToCurrentSpec(await fs.readFile(currentSpecPath, "utf8"), blocks, path.relative(repoRoot, currentSpecPath).split(path.sep).join("/"))
128
- : buildNewCurrentSpec(capability, changeId, blocks);
189
+ const currentSpecExists = await pathExists(currentSpecPath);
190
+ const nextContent = currentSpecExists
191
+ ? mergeRequirementsIntoCurrentSpec(await fs.readFile(currentSpecPath, "utf8"), delta, path.relative(repoRoot, currentSpecPath).split(path.sep).join("/"))
192
+ : (() => {
193
+ if (delta.modified.length > 0) {
194
+ throw new Error(`${relDeltaPath}: cannot archive MODIFIED requirements without an existing current spec`);
195
+ }
196
+ return buildNewCurrentSpec(capability, changeId, delta.added);
197
+ })();
129
198
  updates.set(currentSpecPath, nextContent);
130
199
  }
131
200
  return updates;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reffy-cli",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "CLI-first, framework-agnostic references workflow for any repo (TypeScript)",
5
5
  "keywords": [
6
6
  "reffy",