@fission-ai/openspec 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/dist/cli/index.js +73 -7
- package/dist/commands/change.d.ts +0 -1
- package/dist/commands/change.js +90 -50
- package/dist/commands/config.js +14 -8
- package/dist/commands/doctor.js +9 -4
- package/dist/commands/feedback.js +74 -23
- package/dist/commands/schema.js +21 -11
- package/dist/commands/show.js +5 -2
- package/dist/commands/spec.js +20 -24
- package/dist/commands/validate.js +18 -3
- package/dist/commands/workflow/index.d.ts +1 -1
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/instructions.d.ts +7 -1
- package/dist/commands/workflow/instructions.js +89 -6
- package/dist/commands/workflow/shared.d.ts +13 -2
- package/dist/commands/workflow/shared.js +34 -4
- package/dist/commands/workflow/status.js +7 -2
- package/dist/core/archive.d.ts +0 -1
- package/dist/core/archive.js +96 -33
- package/dist/core/artifact-graph/graph.d.ts +16 -0
- package/dist/core/artifact-graph/graph.js +30 -6
- package/dist/core/artifact-graph/instruction-loader.d.ts +27 -2
- package/dist/core/artifact-graph/instruction-loader.js +55 -7
- package/dist/core/artifact-graph/resolver.d.ts +14 -0
- package/dist/core/artifact-graph/resolver.js +34 -6
- package/dist/core/change-metadata/schema.d.ts +1 -0
- package/dist/core/change-metadata/schema.js +6 -0
- package/dist/core/change-status-policy.d.ts +1 -1
- package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
- package/dist/core/command-generation/adapters/amazon-q.js +7 -1
- package/dist/core/command-generation/adapters/antigravity.js +2 -1
- package/dist/core/command-generation/adapters/auggie.js +2 -1
- package/dist/core/command-generation/adapters/bob.d.ts +5 -1
- package/dist/core/command-generation/adapters/bob.js +6 -5
- package/dist/core/command-generation/adapters/claude.js +1 -8
- package/dist/core/command-generation/adapters/codebuddy.js +3 -2
- package/dist/core/command-generation/adapters/continue.js +3 -2
- package/dist/core/command-generation/adapters/costrict.js +2 -1
- package/dist/core/command-generation/adapters/crush.js +5 -5
- package/dist/core/command-generation/adapters/cursor.js +2 -2
- package/dist/core/command-generation/adapters/devin.d.ts +19 -0
- package/dist/core/command-generation/adapters/devin.js +36 -0
- package/dist/core/command-generation/adapters/factory.js +2 -1
- package/dist/core/command-generation/adapters/gemini.js +37 -2
- package/dist/core/command-generation/adapters/github-copilot.js +2 -1
- package/dist/core/command-generation/adapters/iflow.js +5 -4
- package/dist/core/command-generation/adapters/index.d.ts +2 -2
- package/dist/core/command-generation/adapters/index.js +2 -2
- package/dist/core/command-generation/adapters/junie.js +2 -1
- package/dist/core/command-generation/adapters/kiro.js +2 -1
- package/dist/core/command-generation/adapters/lingma.js +5 -5
- package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
- package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
- package/dist/core/command-generation/adapters/opencode.js +3 -5
- package/dist/core/command-generation/adapters/pi.d.ts +2 -2
- package/dist/core/command-generation/adapters/pi.js +3 -6
- package/dist/core/command-generation/adapters/qoder.js +5 -5
- package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
- package/dist/core/command-generation/adapters/qwen.js +12 -7
- package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
- package/dist/core/command-generation/adapters/roocode.js +4 -4
- package/dist/core/command-generation/adapters/trae.js +1 -21
- package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
- package/dist/core/command-generation/adapters/zcode.js +33 -0
- package/dist/core/command-generation/generator.d.ts +8 -0
- package/dist/core/command-generation/generator.js +15 -1
- package/dist/core/command-generation/index.d.ts +1 -1
- package/dist/core/command-generation/index.js +1 -1
- package/dist/core/command-generation/invocation.d.ts +73 -0
- package/dist/core/command-generation/invocation.js +77 -0
- package/dist/core/command-generation/registry.js +4 -4
- package/dist/core/command-generation/types.d.ts +9 -2
- package/dist/core/command-generation/yaml.d.ts +20 -6
- package/dist/core/command-generation/yaml.js +31 -21
- package/dist/core/command-surface.d.ts +15 -0
- package/dist/core/command-surface.js +33 -0
- package/dist/core/completions/command-registry.js +8 -2
- package/dist/core/completions/generators/powershell-generator.js +15 -8
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
- package/dist/core/completions/installers/zsh-installer.js +20 -4
- package/dist/core/config-prompts.d.ts +1 -1
- package/dist/core/config-prompts.js +14 -1
- package/dist/core/config-schema.d.ts +7 -1
- package/dist/core/config-schema.js +40 -1
- package/dist/core/config.d.ts +12 -0
- package/dist/core/config.js +21 -3
- package/dist/core/global-config.d.ts +5 -0
- package/dist/core/init.d.ts +35 -0
- package/dist/core/init.js +225 -39
- package/dist/core/legacy-cleanup.d.ts +59 -0
- package/dist/core/legacy-cleanup.js +249 -22
- package/dist/core/list.js +5 -7
- package/dist/core/migration.d.ts +71 -1
- package/dist/core/migration.js +294 -1
- package/dist/core/onboarding-commands.d.ts +30 -0
- package/dist/core/onboarding-commands.js +38 -0
- package/dist/core/parsers/change-parser.d.ts +17 -2
- package/dist/core/parsers/change-parser.js +33 -20
- package/dist/core/parsers/code-fence.d.ts +15 -0
- package/dist/core/parsers/code-fence.js +49 -0
- package/dist/core/parsers/markdown-parser.js +2 -1
- package/dist/core/parsers/requirement-blocks.d.ts +8 -0
- package/dist/core/parsers/requirement-blocks.js +60 -35
- package/dist/core/parsers/requirement-text.d.ts +1 -8
- package/dist/core/parsers/requirement-text.js +4 -42
- package/dist/core/parsers/spec-structure.js +3 -29
- package/dist/core/profile-sync-drift.d.ts +0 -8
- package/dist/core/profile-sync-drift.js +9 -45
- package/dist/core/project-config.d.ts +25 -6
- package/dist/core/project-config.js +77 -7
- package/dist/core/references.js +42 -4
- package/dist/core/relationship-health.d.ts +8 -0
- package/dist/core/relationship-health.js +13 -0
- package/dist/core/root-selection.d.ts +9 -6
- package/dist/core/root-selection.js +42 -5
- package/dist/core/shared/tool-detection.d.ts +25 -5
- package/dist/core/shared/tool-detection.js +105 -6
- package/dist/core/specs-apply.d.ts +8 -33
- package/dist/core/specs-apply.js +252 -157
- package/dist/core/store/git.d.ts +13 -0
- package/dist/core/store/git.js +24 -0
- package/dist/core/templates/workflows/apply-change.js +54 -4
- package/dist/core/templates/workflows/archive-change.js +166 -42
- package/dist/core/templates/workflows/bulk-archive-change.js +240 -63
- package/dist/core/templates/workflows/continue-change.js +32 -40
- package/dist/core/templates/workflows/explore.js +12 -0
- package/dist/core/templates/workflows/ff-change.js +46 -30
- package/dist/core/templates/workflows/new-change.js +2 -2
- package/dist/core/templates/workflows/onboard.js +4 -4
- package/dist/core/templates/workflows/propose.js +50 -32
- package/dist/core/templates/workflows/store-selection.d.ts +1 -1
- package/dist/core/templates/workflows/store-selection.js +1 -1
- package/dist/core/templates/workflows/sync-specs.js +172 -22
- package/dist/core/templates/workflows/update-change.js +20 -12
- package/dist/core/templates/workflows/verify-change.js +16 -10
- package/dist/core/update.d.ts +39 -5
- package/dist/core/update.js +288 -71
- package/dist/core/validation/constants.d.ts +4 -1
- package/dist/core/validation/constants.js +4 -1
- package/dist/core/validation/validator.d.ts +1 -7
- package/dist/core/validation/validator.js +92 -36
- package/dist/core/version-check.d.ts +152 -0
- package/dist/core/version-check.js +736 -0
- package/dist/core/view.js +12 -17
- package/dist/prompts/searchable-multi-select.js +1 -1
- package/dist/telemetry/index.d.ts +1 -1
- package/dist/telemetry/index.js +55 -33
- package/dist/ui/welcome-screen.d.ts +9 -1
- package/dist/ui/welcome-screen.js +91 -38
- package/dist/utils/change-metadata.d.ts +30 -0
- package/dist/utils/change-metadata.js +101 -7
- package/dist/utils/change-utils.d.ts +10 -4
- package/dist/utils/change-utils.js +20 -13
- package/dist/utils/command-references.d.ts +80 -6
- package/dist/utils/command-references.js +138 -7
- package/dist/utils/date.d.ts +8 -0
- package/dist/utils/date.js +13 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/item-discovery.d.ts +19 -0
- package/dist/utils/item-discovery.js +30 -48
- package/dist/utils/shell-detection.d.ts +1 -1
- package/dist/utils/shell-detection.js +64 -11
- package/dist/utils/spec-discovery.d.ts +40 -0
- package/dist/utils/spec-discovery.js +102 -0
- package/package.json +14 -12
- package/schemas/spec-driven/schema.yaml +60 -5
- package/schemas/spec-driven/templates/design.md +2 -2
- package/schemas/spec-driven/templates/proposal.md +5 -1
- package/schemas/spec-driven/templates/spec.md +3 -0
- package/dist/core/command-generation/adapters/codex.d.ts +0 -16
- package/dist/core/command-generation/adapters/codex.js +0 -39
- package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
- package/dist/core/command-generation/adapters/windsurf.js +0 -38
package/dist/core/specs-apply.js
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
import { promises as fs } from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
|
-
import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
|
|
10
|
+
import { extractRequirementsSection, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
|
|
11
11
|
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
|
|
12
|
-
import {
|
|
12
|
+
import { buildCodeFenceMask } from './parsers/code-fence.js';
|
|
13
|
+
import { MarkdownParser } from './parsers/markdown-parser.js';
|
|
14
|
+
import { MIN_PURPOSE_LENGTH } from './validation/constants.js';
|
|
15
|
+
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
|
13
16
|
// -----------------------------------------------------------------------------
|
|
14
17
|
// Public API
|
|
15
18
|
// -----------------------------------------------------------------------------
|
|
@@ -19,37 +22,27 @@ import { Validator } from './validation/validator.js';
|
|
|
19
22
|
export async function findSpecUpdates(changeDir, mainSpecsDir) {
|
|
20
23
|
const updates = [];
|
|
21
24
|
const changeSpecsDir = path.join(changeDir, 'specs');
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
await fs.access(targetFile);
|
|
34
|
-
exists = true;
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
exists = false;
|
|
38
|
-
}
|
|
39
|
-
updates.push({
|
|
40
|
-
source: specFile,
|
|
41
|
-
target: targetFile,
|
|
42
|
-
exists,
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
// Source spec doesn't exist, skip
|
|
47
|
-
}
|
|
48
|
-
}
|
|
25
|
+
// Discover delta specs recursively so nested layouts like
|
|
26
|
+
// specs/<area>/<capability>/spec.md merge into the same relative path
|
|
27
|
+
// under the main specs directory (#1353)
|
|
28
|
+
const discovered = await discoverSpecFiles(changeSpecsDir);
|
|
29
|
+
for (const { id, specFile } of discovered) {
|
|
30
|
+
const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
|
|
31
|
+
// Check if target exists
|
|
32
|
+
let exists = false;
|
|
33
|
+
try {
|
|
34
|
+
await fs.access(targetFile);
|
|
35
|
+
exists = true;
|
|
49
36
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
37
|
+
catch {
|
|
38
|
+
exists = false;
|
|
39
|
+
}
|
|
40
|
+
updates.push({
|
|
41
|
+
id,
|
|
42
|
+
source: specFile,
|
|
43
|
+
target: targetFile,
|
|
44
|
+
exists,
|
|
45
|
+
});
|
|
53
46
|
}
|
|
54
47
|
return updates;
|
|
55
48
|
}
|
|
@@ -58,11 +51,20 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
|
|
|
58
51
|
* Returns the rebuilt content and counts of operations.
|
|
59
52
|
*/
|
|
60
53
|
export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
54
|
+
// Collected so silent (JSON) callers can surface them; printed live for
|
|
55
|
+
// human callers at the point they occur.
|
|
56
|
+
const warnings = [];
|
|
57
|
+
const warn = (message) => {
|
|
58
|
+
warnings.push(message);
|
|
59
|
+
if (!options.silent) {
|
|
60
|
+
console.log(chalk.yellow(`⚠️ Warning: ${message}`));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
61
63
|
// Read change spec content (delta-format expected)
|
|
62
64
|
const changeContent = await fs.readFile(update.source, 'utf-8');
|
|
63
65
|
// Parse deltas from the change spec file
|
|
64
66
|
const plan = parseDeltaSpec(changeContent);
|
|
65
|
-
const specName =
|
|
67
|
+
const specName = update.id;
|
|
66
68
|
// Pre-validate duplicates within sections
|
|
67
69
|
const addedNames = new Set();
|
|
68
70
|
for (const add of plan.added) {
|
|
@@ -118,6 +120,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
118
120
|
for (const { from, to } of plan.renamed) {
|
|
119
121
|
const fromNorm = normalizeRequirementName(from);
|
|
120
122
|
const toNorm = normalizeRequirementName(to);
|
|
123
|
+
// A REMOVED naming the FROM side contradicts the rename. This used to
|
|
124
|
+
// fail incidentally at apply time (the rename consumed the old header,
|
|
125
|
+
// so REMOVED hit "not found"); now that a missing REMOVED target is a
|
|
126
|
+
// no-op, the conflict must be rejected explicitly. Compared folded, so
|
|
127
|
+
// a case/whitespace variant cannot slip past the guard and degrade
|
|
128
|
+
// into a warned no-op.
|
|
129
|
+
const removedFoldMatch = [...removedNamesSet].find((r) => foldRequirementName(r) === foldRequirementName(fromNorm));
|
|
130
|
+
if (removedFoldMatch !== undefined) {
|
|
131
|
+
throw new Error(`${specName} validation failed - requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: ${from}"` +
|
|
132
|
+
(removedFoldMatch === fromNorm ? '' : ` (REMOVED spells it "${removedFoldMatch}")`));
|
|
133
|
+
}
|
|
121
134
|
if (modifiedNames.has(fromNorm)) {
|
|
122
135
|
throw new Error(`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`);
|
|
123
136
|
}
|
|
@@ -132,14 +145,28 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
132
145
|
}
|
|
133
146
|
const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
|
|
134
147
|
if (!hasAnyDelta) {
|
|
135
|
-
throw new Error(`Delta parsing found no operations for ${
|
|
148
|
+
throw new Error(`Delta parsing found no operations for ${update.id}. ` +
|
|
136
149
|
`Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`);
|
|
137
150
|
}
|
|
138
151
|
// Load or create base target content
|
|
152
|
+
const deltaPurpose = extractPurposeSection(changeContent);
|
|
139
153
|
let targetContent;
|
|
140
154
|
let isNewSpec = false;
|
|
141
155
|
try {
|
|
142
156
|
targetContent = await fs.readFile(update.target, 'utf-8');
|
|
157
|
+
// A delta Purpose only seeds a spec that does not exist yet. Say so rather
|
|
158
|
+
// than dropping it silently - the specs instruction tells authors to write
|
|
159
|
+
// one for new capabilities, and the delta file looks identical either way.
|
|
160
|
+
// Only when the spec really does have a different Purpose: claiming it
|
|
161
|
+
// "already has one" would be false when it has none, and saying anything at
|
|
162
|
+
// all is noise when the two bodies match.
|
|
163
|
+
if (deltaPurpose) {
|
|
164
|
+
const existingPurpose = extractPurposeSection(targetContent);
|
|
165
|
+
if (existingPurpose && existingPurpose !== deltaPurpose) {
|
|
166
|
+
warn(`${specName} - delta Purpose ignored; ${specName} already has one. ` +
|
|
167
|
+
`Edit ${update.target} directly to change it.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
143
170
|
}
|
|
144
171
|
catch {
|
|
145
172
|
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
|
|
@@ -148,11 +175,25 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
148
175
|
throw new Error(`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`);
|
|
149
176
|
}
|
|
150
177
|
// Warn about REMOVED requirements being ignored for new specs
|
|
151
|
-
if (plan.removed.length > 0
|
|
152
|
-
|
|
178
|
+
if (plan.removed.length > 0) {
|
|
179
|
+
warn(`${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`);
|
|
153
180
|
}
|
|
154
181
|
isNewSpec = true;
|
|
155
|
-
targetContent = buildSpecSkeleton(specName, changeName);
|
|
182
|
+
targetContent = buildSpecSkeleton(specName, changeName, deltaPurpose);
|
|
183
|
+
const overview = deltaPurpose ? readableOverview(targetContent, specName) : null;
|
|
184
|
+
if (deltaPurpose && !overview) {
|
|
185
|
+
// Keep the placeholder rather than turning this into a failure: these
|
|
186
|
+
// deltas archived cleanly before the Purpose carry-over existed.
|
|
187
|
+
targetContent = buildSpecSkeleton(specName, changeName);
|
|
188
|
+
warn(`${specName} - delta Purpose ignored (it would leave the new spec unreadable); wrote the placeholder Purpose instead.`);
|
|
189
|
+
}
|
|
190
|
+
else if (overview && overview.length < MIN_PURPOSE_LENGTH) {
|
|
191
|
+
// The placeholder always cleared this threshold, so a carried Purpose is
|
|
192
|
+
// the first way archive can leave a spec that `validate --strict` fails.
|
|
193
|
+
// Measured on the parsed overview, which is what the validator reads.
|
|
194
|
+
warn(`${specName} - carried Purpose is under ${MIN_PURPOSE_LENGTH} characters; ` +
|
|
195
|
+
`openspec validate --strict reports it as too brief.`);
|
|
196
|
+
}
|
|
156
197
|
}
|
|
157
198
|
const structureIssues = findMainSpecStructureIssues(targetContent);
|
|
158
199
|
if (structureIssues.length > 0) {
|
|
@@ -169,10 +210,24 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
169
210
|
}
|
|
170
211
|
// Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
|
|
171
212
|
// RENAMED
|
|
213
|
+
let renamedApplied = 0;
|
|
172
214
|
for (const r of plan.renamed) {
|
|
173
215
|
const from = normalizeRequirementName(r.from);
|
|
174
216
|
const to = normalizeRequirementName(r.to);
|
|
175
217
|
if (!nameToBlock.has(from)) {
|
|
218
|
+
// Source gone but target present means the rename was already synced
|
|
219
|
+
// to the baseline (early-sync pattern) — re-applying it is a no-op,
|
|
220
|
+
// not a failure. Only a missing source AND target is a genuine error.
|
|
221
|
+
if (nameToBlock.has(to)) {
|
|
222
|
+
// Unless a case/whitespace variant of the source still exists (and is
|
|
223
|
+
// not the target itself, as in a case-only rename): that is a typo'd
|
|
224
|
+
// header, not an early-synced rename — same guard REMOVED applies.
|
|
225
|
+
const nearMiss = [...nameToBlock.keys()].find((k) => k !== to && foldRequirementName(k) === foldRequirementName(from));
|
|
226
|
+
if (nearMiss !== undefined) {
|
|
227
|
+
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found, but "### Requirement: ${nameToBlock.get(nearMiss).name}" exists; fix the header to match it exactly`);
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
176
231
|
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`);
|
|
177
232
|
}
|
|
178
233
|
if (nameToBlock.has(to)) {
|
|
@@ -189,22 +244,33 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
189
244
|
};
|
|
190
245
|
nameToBlock.delete(from);
|
|
191
246
|
nameToBlock.set(to, renamedBlock);
|
|
247
|
+
renamedApplied++;
|
|
192
248
|
}
|
|
193
249
|
// REMOVED
|
|
250
|
+
let removedApplied = 0;
|
|
194
251
|
for (const name of plan.removed) {
|
|
195
252
|
const key = normalizeRequirementName(name);
|
|
196
253
|
if (!nameToBlock.has(key)) {
|
|
197
|
-
//
|
|
198
|
-
//
|
|
254
|
+
// Requirement gone from the baseline means the removal was already
|
|
255
|
+
// synced (early-sync pattern) — re-applying it is a no-op, not a
|
|
256
|
+
// failure. One signal does separate that from a mistyped header: a
|
|
257
|
+
// requirement that differs only in case or interior whitespace still
|
|
258
|
+
// being present. That is a typo, and stays a hard abort.
|
|
259
|
+
// For new specs the skip was already warned about above.
|
|
199
260
|
if (!isNewSpec) {
|
|
200
|
-
|
|
261
|
+
const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
|
|
262
|
+
if (nearMiss !== undefined) {
|
|
263
|
+
throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found, but "### Requirement: ${nameToBlock.get(nearMiss).name}" exists; fix the header to match it exactly`);
|
|
264
|
+
}
|
|
265
|
+
warn(`${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed.`);
|
|
201
266
|
}
|
|
202
|
-
// Skip removal for new specs (already warned above)
|
|
203
267
|
continue;
|
|
204
268
|
}
|
|
205
269
|
nameToBlock.delete(key);
|
|
270
|
+
removedApplied++;
|
|
206
271
|
}
|
|
207
272
|
// MODIFIED
|
|
273
|
+
let modifiedApplied = 0;
|
|
208
274
|
for (const mod of plan.modified) {
|
|
209
275
|
const key = normalizeRequirementName(mod.name);
|
|
210
276
|
const currentBlock = nameToBlock.get(key);
|
|
@@ -220,15 +286,31 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
220
286
|
if (missingScenarios.length > 0) {
|
|
221
287
|
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.`);
|
|
222
288
|
}
|
|
289
|
+
// Identical content means the modification was already synced to the
|
|
290
|
+
// baseline (early-sync pattern) — count only real replacements, so a
|
|
291
|
+
// fully synced change still takes the "already in sync" write skip
|
|
292
|
+
// instead of churning normalization differences into the file.
|
|
293
|
+
if (normalizeBlockRaw(currentBlock.raw) !== normalizeBlockRaw(mod.raw)) {
|
|
294
|
+
modifiedApplied++;
|
|
295
|
+
}
|
|
223
296
|
nameToBlock.set(key, mod);
|
|
224
297
|
}
|
|
225
298
|
// ADDED
|
|
299
|
+
let addedApplied = 0;
|
|
226
300
|
for (const add of plan.added) {
|
|
227
301
|
const key = normalizeRequirementName(add.name);
|
|
228
|
-
|
|
302
|
+
const existing = nameToBlock.get(key);
|
|
303
|
+
if (existing) {
|
|
304
|
+
// Identical content means the requirement was already synced to the
|
|
305
|
+
// baseline (early-sync pattern) — re-applying it is a no-op, not a
|
|
306
|
+
// conflict. Only differing content is a genuine collision.
|
|
307
|
+
if (normalizeBlockRaw(existing.raw) === normalizeBlockRaw(add.raw)) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
229
310
|
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
|
|
230
311
|
}
|
|
231
312
|
nameToBlock.set(key, add);
|
|
313
|
+
addedApplied++;
|
|
232
314
|
}
|
|
233
315
|
// Duplicates within resulting map are implicitly prevented by key uniqueness.
|
|
234
316
|
// Recompose requirements section preserving original ordering where possible
|
|
@@ -260,13 +342,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
260
342
|
return {
|
|
261
343
|
rebuilt,
|
|
262
344
|
counts: {
|
|
263
|
-
added:
|
|
264
|
-
modified:
|
|
265
|
-
removed:
|
|
266
|
-
renamed:
|
|
345
|
+
added: addedApplied,
|
|
346
|
+
modified: modifiedApplied,
|
|
347
|
+
removed: removedApplied,
|
|
348
|
+
renamed: renamedApplied,
|
|
267
349
|
},
|
|
350
|
+
warnings,
|
|
268
351
|
};
|
|
269
352
|
}
|
|
353
|
+
function normalizeBlockRaw(raw) {
|
|
354
|
+
return raw.replace(/\r\n?/g, '\n').trim();
|
|
355
|
+
}
|
|
270
356
|
/**
|
|
271
357
|
* Write an updated spec to disk.
|
|
272
358
|
*/
|
|
@@ -277,7 +363,7 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
|
|
|
277
363
|
await fs.writeFile(update.target, rebuilt);
|
|
278
364
|
if (options.silent)
|
|
279
365
|
return;
|
|
280
|
-
const specName =
|
|
366
|
+
const specName = update.id;
|
|
281
367
|
console.log(`Applying changes to ${options.displayPath ?? `openspec/specs/${specName}/spec.md`}:`);
|
|
282
368
|
if (counts.added)
|
|
283
369
|
console.log(` + ${counts.added} added`);
|
|
@@ -288,25 +374,135 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
|
|
|
288
374
|
if (counts.renamed)
|
|
289
375
|
console.log(` → ${counts.renamed} renamed`);
|
|
290
376
|
}
|
|
377
|
+
/** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
|
|
378
|
+
function maskHtmlComments(content) {
|
|
379
|
+
const blank = (text) => text.replace(/[^\n]/g, ' ');
|
|
380
|
+
// `--!>` is a comment terminator as well as `-->`.
|
|
381
|
+
const masked = content.replace(/<!--[\s\S]*?--!?>/g, blank);
|
|
382
|
+
// A comment that is never closed runs to end of file, so everything after it
|
|
383
|
+
// is commented out too. Without this an unterminated `<!--` above a
|
|
384
|
+
// `## Purpose` left the commented-out header looking real (#1413).
|
|
385
|
+
const unterminated = masked.indexOf('<!--');
|
|
386
|
+
if (unterminated === -1)
|
|
387
|
+
return masked;
|
|
388
|
+
return masked.slice(0, unterminated) + blank(masked.slice(unterminated));
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Read the body of a `## Purpose` section, ignoring markdown that only appears
|
|
392
|
+
* inside fenced code blocks or HTML comments. Returns undefined when the
|
|
393
|
+
* section is absent or its body is empty.
|
|
394
|
+
*/
|
|
395
|
+
function extractPurposeSection(content) {
|
|
396
|
+
const normalized = content.replace(/\r\n?/g, '\n');
|
|
397
|
+
const lines = normalized.split('\n');
|
|
398
|
+
// Structure is read from the masked copy so a commented-out or fenced
|
|
399
|
+
// `## Purpose` is not mistaken for the real one; the body is returned from
|
|
400
|
+
// the original lines so an author's own comments and fences survive intact.
|
|
401
|
+
const masked = maskHtmlComments(normalized).split('\n');
|
|
402
|
+
const fenceMask = buildCodeFenceMask(masked);
|
|
403
|
+
const isStructural = (i) => !fenceMask[i];
|
|
404
|
+
const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose\s*$/i.test(line));
|
|
405
|
+
if (start === -1)
|
|
406
|
+
return undefined;
|
|
407
|
+
let end = masked.length;
|
|
408
|
+
for (let i = start + 1; i < masked.length; i++) {
|
|
409
|
+
if (isStructural(i) && /^##\s+/.test(masked[i])) {
|
|
410
|
+
end = i;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Emptiness is judged with fenced blocks and HTML comments blanked out, so a
|
|
415
|
+
// Purpose that is only a code sample or only an unfilled template comment
|
|
416
|
+
// counts as absent and falls back to the TBD placeholder.
|
|
417
|
+
const hasProse = masked
|
|
418
|
+
.slice(start + 1, end)
|
|
419
|
+
.filter((_, offset) => isStructural(start + 1 + offset))
|
|
420
|
+
.join('\n')
|
|
421
|
+
.trim();
|
|
422
|
+
if (!hasProse)
|
|
423
|
+
return undefined;
|
|
424
|
+
const body = lines.slice(start + 1, end).join('\n').trim();
|
|
425
|
+
return body || undefined;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The Purpose a new main spec would end up with, or null when carrying the
|
|
429
|
+
* delta's body over would leave a spec the readers downstream cannot handle.
|
|
430
|
+
*
|
|
431
|
+
* Returns the parsed overview rather than a boolean so callers measure the same
|
|
432
|
+
* string `validate` measures, not the raw slice out of the delta.
|
|
433
|
+
*/
|
|
434
|
+
function readableOverview(skeleton, specName) {
|
|
435
|
+
// HTML comments are invisible to the spec parsers but not to the file itself:
|
|
436
|
+
// markdown hidden in one is skipped by the boundary scan yet still lands in
|
|
437
|
+
// the spec, where it can hide the headers those parsers depend on and blank
|
|
438
|
+
// the document out in any markdown renderer. Refuse rather than write a spec
|
|
439
|
+
// that reads differently depending on who is reading it (#1413).
|
|
440
|
+
//
|
|
441
|
+
// Only the opener is disqualifying, and only because `maskHtmlComments`
|
|
442
|
+
// covers unterminated comments too: a comment starting above the section
|
|
443
|
+
// header therefore always masks the header, leaving no body to carry, so a
|
|
444
|
+
// body can only hide content behind a `<!--` of its own. A bare `-->` hides
|
|
445
|
+
// nothing and renders as text - rejecting it would throw away a Purpose over
|
|
446
|
+
// prose like "ingest --> transform".
|
|
447
|
+
if (skeleton.includes('<!--'))
|
|
448
|
+
return null;
|
|
449
|
+
if (findMainSpecStructureIssues(skeleton).length > 0)
|
|
450
|
+
return null;
|
|
451
|
+
try {
|
|
452
|
+
// A heading or unterminated fence in the body truncates or swallows the
|
|
453
|
+
// sections around it, so archive would abort or write a spec its own
|
|
454
|
+
// validator rejects.
|
|
455
|
+
return new MarkdownParser(skeleton).parseSpec(specName).overview.trim() || null;
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
291
461
|
/**
|
|
292
|
-
* Build a skeleton spec for new capabilities.
|
|
462
|
+
* Build a skeleton spec for new capabilities. When the delta spec authored a
|
|
463
|
+
* `## Purpose`, carry it over instead of the TBD placeholder (#1413) - archive
|
|
464
|
+
* invents the Purpose for a brand-new main spec either way, and the author's
|
|
465
|
+
* own wording beats a placeholder they then have to hand-edit.
|
|
293
466
|
*/
|
|
294
|
-
export function buildSpecSkeleton(specFolderName, changeName) {
|
|
467
|
+
export function buildSpecSkeleton(specFolderName, changeName, purpose) {
|
|
295
468
|
const titleBase = specFolderName;
|
|
296
|
-
|
|
469
|
+
const purposeBody = purpose?.trim() || `TBD - created by archiving change ${changeName}. Update Purpose after archive.`;
|
|
470
|
+
return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`;
|
|
297
471
|
}
|
|
298
472
|
function findMissingCurrentScenarios(current, incoming) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
473
|
+
// Multiplicity-aware: a name present N times in current and M times in
|
|
474
|
+
// incoming means max(0, N - M) instances are missing. Set membership would
|
|
475
|
+
// treat N>M as fully covered and let archive silently drop duplicates
|
|
476
|
+
// (residual #1246 / duplicate-scenario-name blind spot).
|
|
477
|
+
const remainingIncoming = new Map();
|
|
478
|
+
for (const scenario of parseScenarioBlocks(incoming.raw)) {
|
|
479
|
+
const name = scenario.name;
|
|
480
|
+
remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1);
|
|
481
|
+
}
|
|
482
|
+
const missing = [];
|
|
483
|
+
for (const scenario of parseScenarioBlocks(current.raw)) {
|
|
484
|
+
const name = scenario.name;
|
|
485
|
+
const remaining = remainingIncoming.get(name) ?? 0;
|
|
486
|
+
if (remaining > 0) {
|
|
487
|
+
remainingIncoming.set(name, remaining - 1);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
missing.push(name);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return missing;
|
|
303
494
|
}
|
|
304
495
|
function parseScenarioBlocks(requirementRaw) {
|
|
305
496
|
const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
|
|
497
|
+
// A `#### Scenario:` inside a fenced example is not a real scenario. The
|
|
498
|
+
// validator's countScenarios already ignores fenced lines; the drift check
|
|
499
|
+
// must agree with it, or a fenced sample can false-abort an archive (or
|
|
500
|
+
// mask a genuinely dropped scenario).
|
|
501
|
+
const mask = buildCodeFenceMask(lines);
|
|
306
502
|
const scenarios = [];
|
|
307
503
|
let index = 0;
|
|
308
504
|
while (index < lines.length) {
|
|
309
|
-
const headerMatch = lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/);
|
|
505
|
+
const headerMatch = mask[index] ? null : lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/);
|
|
310
506
|
if (!headerMatch) {
|
|
311
507
|
index++;
|
|
312
508
|
continue;
|
|
@@ -314,7 +510,7 @@ function parseScenarioBlocks(requirementRaw) {
|
|
|
314
510
|
const start = index;
|
|
315
511
|
const name = headerMatch[1].trim();
|
|
316
512
|
index++;
|
|
317
|
-
while (index < lines.length && !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index])) {
|
|
513
|
+
while (index < lines.length && (mask[index] || !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index]))) {
|
|
318
514
|
index++;
|
|
319
515
|
}
|
|
320
516
|
scenarios.push({
|
|
@@ -324,105 +520,4 @@ function parseScenarioBlocks(requirementRaw) {
|
|
|
324
520
|
}
|
|
325
521
|
return scenarios;
|
|
326
522
|
}
|
|
327
|
-
/**
|
|
328
|
-
* Apply all delta specs from a change to main specs.
|
|
329
|
-
*
|
|
330
|
-
* @param projectRoot - The project root directory
|
|
331
|
-
* @param changeName - The name of the change to apply
|
|
332
|
-
* @param options - Options for the operation
|
|
333
|
-
* @returns Result of the operation with counts
|
|
334
|
-
*/
|
|
335
|
-
export async function applySpecs(projectRoot, changeName, options = {}) {
|
|
336
|
-
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
|
|
337
|
-
const mainSpecsDir = path.join(projectRoot, 'openspec', 'specs');
|
|
338
|
-
// Verify change exists
|
|
339
|
-
try {
|
|
340
|
-
const stat = await fs.stat(changeDir);
|
|
341
|
-
if (!stat.isDirectory()) {
|
|
342
|
-
throw new Error(`Change '${changeName}' not found.`);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
catch {
|
|
346
|
-
throw new Error(`Change '${changeName}' not found.`);
|
|
347
|
-
}
|
|
348
|
-
// Find specs to update
|
|
349
|
-
const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
|
|
350
|
-
if (specUpdates.length === 0) {
|
|
351
|
-
return {
|
|
352
|
-
changeName,
|
|
353
|
-
capabilities: [],
|
|
354
|
-
totals: { added: 0, modified: 0, removed: 0, renamed: 0 },
|
|
355
|
-
noChanges: true,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
// Prepare all updates first (validation pass, no writes)
|
|
359
|
-
const prepared = [];
|
|
360
|
-
for (const update of specUpdates) {
|
|
361
|
-
const built = await buildUpdatedSpec(update, changeName);
|
|
362
|
-
prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
|
|
363
|
-
}
|
|
364
|
-
// Validate rebuilt specs unless validation is skipped
|
|
365
|
-
if (!options.skipValidation) {
|
|
366
|
-
const validator = new Validator();
|
|
367
|
-
for (const p of prepared) {
|
|
368
|
-
const specName = path.basename(path.dirname(p.update.target));
|
|
369
|
-
const report = await validator.validateSpecContent(specName, p.rebuilt);
|
|
370
|
-
if (!report.valid) {
|
|
371
|
-
const errors = report.issues
|
|
372
|
-
.filter((i) => i.level === 'ERROR')
|
|
373
|
-
.map((i) => ` ✗ ${i.message}`)
|
|
374
|
-
.join('\n');
|
|
375
|
-
throw new Error(`Validation errors in rebuilt spec for ${specName}:\n${errors}`);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
// Build results
|
|
380
|
-
const capabilities = [];
|
|
381
|
-
const totals = { added: 0, modified: 0, removed: 0, renamed: 0 };
|
|
382
|
-
for (const p of prepared) {
|
|
383
|
-
const capability = path.basename(path.dirname(p.update.target));
|
|
384
|
-
if (!options.dryRun) {
|
|
385
|
-
// Write the updated spec
|
|
386
|
-
const targetDir = path.dirname(p.update.target);
|
|
387
|
-
await fs.mkdir(targetDir, { recursive: true });
|
|
388
|
-
await fs.writeFile(p.update.target, p.rebuilt);
|
|
389
|
-
if (!options.silent) {
|
|
390
|
-
console.log(`Applying changes to openspec/specs/${capability}/spec.md:`);
|
|
391
|
-
if (p.counts.added)
|
|
392
|
-
console.log(` + ${p.counts.added} added`);
|
|
393
|
-
if (p.counts.modified)
|
|
394
|
-
console.log(` ~ ${p.counts.modified} modified`);
|
|
395
|
-
if (p.counts.removed)
|
|
396
|
-
console.log(` - ${p.counts.removed} removed`);
|
|
397
|
-
if (p.counts.renamed)
|
|
398
|
-
console.log(` → ${p.counts.renamed} renamed`);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
else if (!options.silent) {
|
|
402
|
-
console.log(`Would apply changes to openspec/specs/${capability}/spec.md:`);
|
|
403
|
-
if (p.counts.added)
|
|
404
|
-
console.log(` + ${p.counts.added} added`);
|
|
405
|
-
if (p.counts.modified)
|
|
406
|
-
console.log(` ~ ${p.counts.modified} modified`);
|
|
407
|
-
if (p.counts.removed)
|
|
408
|
-
console.log(` - ${p.counts.removed} removed`);
|
|
409
|
-
if (p.counts.renamed)
|
|
410
|
-
console.log(` → ${p.counts.renamed} renamed`);
|
|
411
|
-
}
|
|
412
|
-
capabilities.push({
|
|
413
|
-
capability,
|
|
414
|
-
...p.counts,
|
|
415
|
-
});
|
|
416
|
-
totals.added += p.counts.added;
|
|
417
|
-
totals.modified += p.counts.modified;
|
|
418
|
-
totals.removed += p.counts.removed;
|
|
419
|
-
totals.renamed += p.counts.renamed;
|
|
420
|
-
}
|
|
421
|
-
return {
|
|
422
|
-
changeName,
|
|
423
|
-
capabilities,
|
|
424
|
-
totals,
|
|
425
|
-
noChanges: false,
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
523
|
//# sourceMappingURL=specs-apply.js.map
|
package/dist/core/store/git.d.ts
CHANGED
|
@@ -19,5 +19,18 @@ export declare function gitHasRemote(storeRoot: string): Promise<boolean | null>
|
|
|
19
19
|
* network touch. Null when there is no repository or no origin.
|
|
20
20
|
*/
|
|
21
21
|
export declare function gitOriginUrl(storeRoot: string): Promise<string | null>;
|
|
22
|
+
export interface GitTrackingDrift {
|
|
23
|
+
ahead: number;
|
|
24
|
+
behind: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Ahead/behind counts of HEAD against its configured upstream tracking
|
|
28
|
+
* ref, read from local refs only — no fetch, no network. The comparison
|
|
29
|
+
* is therefore against the current local upstream ref (typically last
|
|
30
|
+
* updated by fetch, but it may be a local branch), not the live remote.
|
|
31
|
+
* Null when there is no repository, no upstream, a detached HEAD, or Git
|
|
32
|
+
* is unavailable: the absence of a comparison is not drift.
|
|
33
|
+
*/
|
|
34
|
+
export declare function gitTrackingDrift(storeRoot: string): Promise<GitTrackingDrift | null>;
|
|
22
35
|
export declare function gitDirectoryHasTrackedFiles(storeRoot: string, relativeDir: string): Promise<boolean | null>;
|
|
23
36
|
//# sourceMappingURL=git.d.ts.map
|
package/dist/core/store/git.js
CHANGED
|
@@ -130,6 +130,30 @@ export async function gitOriginUrl(storeRoot) {
|
|
|
130
130
|
const url = stdout?.trim();
|
|
131
131
|
return url ? url : null;
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Ahead/behind counts of HEAD against its configured upstream tracking
|
|
135
|
+
* ref, read from local refs only — no fetch, no network. The comparison
|
|
136
|
+
* is therefore against the current local upstream ref (typically last
|
|
137
|
+
* updated by fetch, but it may be a local branch), not the live remote.
|
|
138
|
+
* Null when there is no repository, no upstream, a detached HEAD, or Git
|
|
139
|
+
* is unavailable: the absence of a comparison is not drift.
|
|
140
|
+
*/
|
|
141
|
+
export async function gitTrackingDrift(storeRoot) {
|
|
142
|
+
const stdout = await gitProbe(storeRoot, [
|
|
143
|
+
'rev-list',
|
|
144
|
+
'--left-right',
|
|
145
|
+
'--count',
|
|
146
|
+
'@{upstream}...HEAD',
|
|
147
|
+
]);
|
|
148
|
+
if (stdout === null)
|
|
149
|
+
return null;
|
|
150
|
+
const match = stdout.trim().match(/^(\d+)\s+(\d+)$/);
|
|
151
|
+
if (!match)
|
|
152
|
+
return null;
|
|
153
|
+
// `--left-right` orders the counts by side of the `...`: left is @{upstream}
|
|
154
|
+
// (commits we lack = behind), right is HEAD (commits upstream lacks = ahead).
|
|
155
|
+
return { behind: Number(match[1]), ahead: Number(match[2]) };
|
|
156
|
+
}
|
|
133
157
|
export async function gitDirectoryHasTrackedFiles(storeRoot, relativeDir) {
|
|
134
158
|
const stdout = await gitProbe(storeRoot, ['ls-files', '--', relativeDir]);
|
|
135
159
|
return stdout === null ? null : stdout.trim().length > 0;
|