@fission-ai/openspec 1.10.0 → 1.12.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.
Files changed (59) hide show
  1. package/README.md +1 -0
  2. package/dist/cli/index.js +12 -2
  3. package/dist/commands/change.d.ts +24 -0
  4. package/dist/commands/change.js +229 -5
  5. package/dist/commands/schema.d.ts +5 -0
  6. package/dist/commands/schema.js +201 -35
  7. package/dist/commands/show.js +1 -1
  8. package/dist/commands/validate.d.ts +57 -0
  9. package/dist/commands/validate.js +99 -13
  10. package/dist/commands/workflow/index.d.ts +1 -1
  11. package/dist/commands/workflow/index.js +1 -1
  12. package/dist/commands/workflow/status.d.ts +2 -0
  13. package/dist/commands/workflow/status.js +76 -12
  14. package/dist/core/artifact-graph/resolver.js +15 -9
  15. package/dist/core/available-tools.js +16 -1
  16. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  17. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  18. package/dist/core/command-generation/adapters/codeassistant.d.ts +15 -0
  19. package/dist/core/command-generation/adapters/codeassistant.js +29 -0
  20. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  21. package/dist/core/command-generation/adapters/index.js +1 -0
  22. package/dist/core/command-generation/registry.js +2 -0
  23. package/dist/core/completions/command-registry.js +21 -0
  24. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  25. package/dist/core/completions/generators/fish-generator.js +79 -47
  26. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  27. package/dist/core/completions/templates/fish-templates.js +107 -10
  28. package/dist/core/completions/types.d.ts +4 -0
  29. package/dist/core/config.js +7 -1
  30. package/dist/core/init.d.ts +1 -0
  31. package/dist/core/init.js +71 -65
  32. package/dist/core/legacy-cleanup.js +6 -0
  33. package/dist/core/migration.js +42 -12
  34. package/dist/core/openspec-root.d.ts +1 -0
  35. package/dist/core/openspec-root.js +10 -2
  36. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  37. package/dist/core/parsers/requirement-blocks.js +2 -0
  38. package/dist/core/shared/ide-restart.d.ts +26 -0
  39. package/dist/core/shared/ide-restart.js +42 -0
  40. package/dist/core/shared/index.d.ts +1 -0
  41. package/dist/core/shared/index.js +1 -0
  42. package/dist/core/shared/tool-detection.js +9 -3
  43. package/dist/core/shared-skill-target.d.ts +13 -0
  44. package/dist/core/shared-skill-target.js +43 -1
  45. package/dist/core/specs-apply.js +22 -9
  46. package/dist/core/templates/workflows/explore.js +108 -73
  47. package/dist/core/templates/workflows/ff-change.js +8 -0
  48. package/dist/core/templates/workflows/propose.js +8 -0
  49. package/dist/core/update.js +28 -31
  50. package/dist/core/validation/constants.d.ts +3 -0
  51. package/dist/core/validation/constants.js +12 -0
  52. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  53. package/dist/core/validation/purpose-placeholder.js +117 -0
  54. package/dist/core/validation/validator.d.ts +12 -3
  55. package/dist/core/validation/validator.js +90 -4
  56. package/dist/utils/command-references.js +7 -8
  57. package/dist/utils/requirement-diff.d.ts +42 -0
  58. package/dist/utils/requirement-diff.js +61 -0
  59. package/package.json +4 -3
@@ -0,0 +1,117 @@
1
+ import { buildCodeFenceMask } from '../parsers/code-fence.js';
2
+ import { PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX } from './constants.js';
3
+ /**
4
+ * A `TBD` or `TODO` opening the Purpose. The lookahead keeps it off a longer
5
+ * word that merely begins with those letters, like "TBDs" or "TODOs", while
6
+ * still allowing the punctuation a marker is usually written with: `TODO:`,
7
+ * `TBD -`. It rejects any letter, digit or combining mark rather than only the
8
+ * ASCII ones `\b` knows about, because a Purpose is prose and prose is not
9
+ * always written in Latin script - `TBD` followed by an Arabic-Indic digit is
10
+ * as much a longer word as `TBDs` is.
11
+ */
12
+ const LEADING_MARKER = /^(?:TBD|TODO)(?![\p{L}\p{N}\p{M}_])/iu;
13
+ const PURPOSE_HEADER = /^ {0,3}##(?!#)[ \t]+Purpose[ \t]*$/i;
14
+ const TOP_LEVEL_HEADER = /^ {0,3}#{1,2}(?!#)[ \t]+/;
15
+ /**
16
+ * The lines of `text` that sit outside a fenced code block, with line endings
17
+ * normalised first.
18
+ *
19
+ * `buildCodeFenceMask` is the masker the requirement and structure parsers
20
+ * already share, and its own reason for existing is that a second, private
21
+ * notion of what a fence is drifts from the first. This check reads Markdown
22
+ * for the same purpose they do, so it reads fences the same way they do.
23
+ */
24
+ function unfencedLines(text) {
25
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
26
+ const fenced = buildCodeFenceMask(lines);
27
+ return lines.filter((_, index) => !fenced[index]);
28
+ }
29
+ /**
30
+ * True when the text carries the sentence archive writes. Matched as its two
31
+ * fixed halves in order, because the change name between them varies - so the
32
+ * check follows the writer's own definition instead of a second copy of it.
33
+ */
34
+ function generatedPlaceholderPrefixIndex(text) {
35
+ let suffixAt = text.indexOf(PURPOSE_PLACEHOLDER_SUFFIX);
36
+ while (suffixAt !== -1) {
37
+ // Use the closest prefix before this suffix. An authored explanation can
38
+ // mention the prefix above the real placeholder; choosing the first prefix
39
+ // would then point the diagnostic at the explanation instead of the text
40
+ // the user needs to replace.
41
+ const prefixAt = text.lastIndexOf(PURPOSE_PLACEHOLDER_PREFIX, suffixAt);
42
+ if (prefixAt !== -1)
43
+ return prefixAt;
44
+ suffixAt = text.indexOf(PURPOSE_PLACEHOLDER_SUFFIX, suffixAt + 1);
45
+ }
46
+ return undefined;
47
+ }
48
+ /**
49
+ * Reports the Purpose of a main spec as an unwritten placeholder, or null when
50
+ * it reads as authored content.
51
+ *
52
+ * An empty Purpose is not reported here - `SPEC_PURPOSE_EMPTY` already covers
53
+ * it, and reporting both would put two findings on one line. That falls out of
54
+ * the two rules rather than needing a case of its own.
55
+ */
56
+ export function findPurposePlaceholderIssue(overview, content) {
57
+ // An empty Purpose needs no branch of its own: neither rule matches empty
58
+ // text, so it falls through to null on the line below. An early return for it
59
+ // would be a guard no test could hold, which is worse than none. A Purpose
60
+ // that is nothing but a fenced block reduces to the same empty text here, and
61
+ // is left to the brevity and empty-Purpose rules for the same reason.
62
+ const prose = unfencedLines(overview).join('\n').trim();
63
+ const leading = LEADING_MARKER.test(prose);
64
+ if (!leading && generatedPlaceholderPrefixIndex(prose) === undefined)
65
+ return null;
66
+ // Which rule matched decides where the placeholder is, so the locator is told.
67
+ // When both match the leading marker wins: it sits at or above the generated
68
+ // sentence, and the earliest marker is the one a reader scanning down meets.
69
+ return { line: content === undefined ? undefined : findPlaceholderLine(content, leading) };
70
+ }
71
+ /**
72
+ * The line inside the `## Purpose` section carrying the placeholder, so the
73
+ * warning points at the text to replace rather than at the file.
74
+ *
75
+ * Which line that is depends on the rule that matched. A leading marker is the
76
+ * section's first non-blank line by definition. The generated sentence is not:
77
+ * it can follow prose somebody wrote, and naming the first non-blank line then
78
+ * points at that prose - a line the reader can see is fine, which reads as the
79
+ * check being wrong rather than the Purpose being unwritten.
80
+ *
81
+ * Fenced lines are skipped on the way, for the reason detection skips them, and
82
+ * so a `## Requirements` quoted inside a fence cannot end the section early.
83
+ *
84
+ * Undefined when the placeholder cannot be located - no section header, or a
85
+ * generated sentence no single line carries. The caller then reports the finding
86
+ * without a line rather than with a guessed one, since a wrong line number is
87
+ * worse than none.
88
+ *
89
+ * Line endings are normalised first, so the same spec reports the same line
90
+ * whether it was saved on Windows or on macOS/Linux.
91
+ */
92
+ function findPlaceholderLine(content, leading) {
93
+ const lines = content.replace(/\r\n?/g, '\n').split('\n');
94
+ const fenced = buildCodeFenceMask(lines);
95
+ const headerIndex = lines.findIndex((line, index) => !fenced[index] && PURPOSE_HEADER.test(line));
96
+ if (headerIndex === -1)
97
+ return undefined;
98
+ const purposeLines = [];
99
+ for (let i = headerIndex + 1; i < lines.length; i++) {
100
+ if (fenced[i])
101
+ continue;
102
+ if (TOP_LEVEL_HEADER.test(lines[i]))
103
+ break;
104
+ if (leading && lines[i].trim())
105
+ return i + 1;
106
+ purposeLines.push({ line: i + 1, text: lines[i] });
107
+ }
108
+ if (leading)
109
+ return undefined;
110
+ const purpose = purposeLines.map(({ text }) => text).join('\n');
111
+ const prefixAt = generatedPlaceholderPrefixIndex(purpose);
112
+ if (prefixAt === undefined)
113
+ return undefined;
114
+ const lineOffset = purpose.slice(0, prefixAt).split('\n').length - 1;
115
+ return purposeLines[lineOffset]?.line;
116
+ }
117
+ //# sourceMappingURL=purpose-placeholder.js.map
@@ -19,9 +19,10 @@ export declare class Validator {
19
19
  * - No duplicates within sections; no cross-section conflicts per spec
20
20
  *
21
21
  * When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
22
- * against the current main specs for the scenario loss archive refuses to
23
- * apply (#1477). When `options.projectRoot` is given, the schema's tracked
24
- * task files are checked for ambiguous numbering (#1520). Omitting either
22
+ * against the current main specs for scenario loss (#1477), and merge
23
+ * conflicts are reported as INFO without changing the verdict (#1112).
24
+ * When `options.projectRoot` is given, the schema's tracked task files are
25
+ * checked for ambiguous numbering (#1520). Omitting either
25
26
  * option keeps existing library and archive callers behaving as before.
26
27
  */
27
28
  validateChangeDeltaSpecs(changeDir: string, options?: {
@@ -47,6 +48,14 @@ export declare class Validator {
47
48
  private applyChangeRules;
48
49
  private enrichTopLevelError;
49
50
  private extractNameFromPath;
51
+ /**
52
+ * Dry-run archive's merge builder without writing its result. Reusing the
53
+ * builder preserves its already-synced delta rules instead of duplicating them.
54
+ * INFO leaves the verdict unchanged: a missing target can be a typo or a
55
+ * requirement introduced by a sibling change that has not archived yet.
56
+ * This does not run archive's later merged-spec validation or retirement checks.
57
+ */
58
+ private findArchiveBlockers;
50
59
  private createReport;
51
60
  isValid(report: ValidationReport): boolean;
52
61
  private extractRequirementText;
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { SpecSchema, ChangeSchema } from '../schemas/index.js';
4
4
  import { MarkdownParser } from '../parsers/markdown-parser.js';
5
5
  import { ChangeParser } from '../parsers/change-parser.js';
6
+ import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
6
7
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
7
8
  import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, findMissingCurrentScenarios, } from '../parsers/requirement-blocks.js';
8
9
  import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, } from '../parsers/requirement-text.js';
@@ -12,6 +13,7 @@ import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.j
12
13
  import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
13
14
  import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
14
15
  import { findTaskNumberingIssues } from './task-numbering.js';
16
+ import { findPurposePlaceholderIssue } from './purpose-placeholder.js';
15
17
  import { getPackageSchemasDir, getSchemaDir } from '../artifact-graph/index.js';
16
18
  export class Validator {
17
19
  strictMode;
@@ -111,9 +113,10 @@ export class Validator {
111
113
  * - No duplicates within sections; no cross-section conflicts per spec
112
114
  *
113
115
  * When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
114
- * against the current main specs for the scenario loss archive refuses to
115
- * apply (#1477). When `options.projectRoot` is given, the schema's tracked
116
- * task files are checked for ambiguous numbering (#1520). Omitting either
116
+ * against the current main specs for scenario loss (#1477), and merge
117
+ * conflicts are reported as INFO without changing the verdict (#1112).
118
+ * When `options.projectRoot` is given, the schema's tracked task files are
119
+ * checked for ambiguous numbering (#1520). Omitting either
117
120
  * option keeps existing library and archive callers behaving as before.
118
121
  */
119
122
  async validateChangeDeltaSpecs(changeDir, options = {}) {
@@ -329,6 +332,20 @@ export class Validator {
329
332
  }
330
333
  }
331
334
  }
335
+ // Reuse archive's merge builder to report conflicts with the main specs.
336
+ // Keep structural errors and scenario loss in their existing diagnostics.
337
+ if (options.mainSpecsDir) {
338
+ issues.push(...(await this.findArchiveBlockers(changeDir, options.mainSpecsDir, [
339
+ ...issues.filter((issue) => issue.level === 'ERROR').map((issue) => issue.path),
340
+ // Collected in the loop above but not turned into issues until
341
+ // after this try block, so they are invisible to the filter. A
342
+ // delta with no parsed sections has nothing for the merge to
343
+ // apply, which it reports as a failure of its own - on top of the
344
+ // error that actually names the mistake.
345
+ ...missingHeaderSpecs,
346
+ ...emptySectionSpecs.map((spec) => spec.path),
347
+ ])));
348
+ }
332
349
  }
333
350
  catch (error) {
334
351
  // A missing specs dir (or a stray `specs` file) means no deltas;
@@ -557,7 +574,21 @@ export class Validator {
557
574
  message: structuralIssue.message,
558
575
  });
559
576
  }
560
- if (spec.overview.length < MIN_PURPOSE_LENGTH) {
577
+ // The placeholder is longer than MIN_PURPOSE_LENGTH, so the brevity check
578
+ // below cannot reach it; it is reported on its own terms instead. Checked
579
+ // first because a hand-written "TBD" is both a placeholder and too brief,
580
+ // and only one of those two tells the author what to do. (A "TODO" opening
581
+ // the Purpose reads the same way, so it is the same finding.)
582
+ const placeholder = findPurposePlaceholderIssue(spec.overview, content);
583
+ if (placeholder) {
584
+ issues.push({
585
+ level: 'WARNING',
586
+ path: 'overview',
587
+ line: placeholder.line,
588
+ message: VALIDATION_MESSAGES.PURPOSE_IS_PLACEHOLDER,
589
+ });
590
+ }
591
+ else if (spec.overview.length < MIN_PURPOSE_LENGTH) {
561
592
  issues.push({
562
593
  level: 'WARNING',
563
594
  path: 'overview',
@@ -656,6 +687,61 @@ export class Validator {
656
687
  const dotIndex = fileName.lastIndexOf('.');
657
688
  return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
658
689
  }
690
+ /**
691
+ * Dry-run archive's merge builder without writing its result. Reusing the
692
+ * builder preserves its already-synced delta rules instead of duplicating them.
693
+ * INFO leaves the verdict unchanged: a missing target can be a typo or a
694
+ * requirement introduced by a sibling change that has not archived yet.
695
+ * This does not run archive's later merged-spec validation or retirement checks.
696
+ */
697
+ async findArchiveBlockers(changeDir, mainSpecsDir, alreadyReportedPaths) {
698
+ const alreadyReported = new Set(alreadyReportedPaths);
699
+ // Only ever reaches a generated skeleton's placeholder Purpose, which this
700
+ // dry run discards.
701
+ const changeName = path.basename(changeDir);
702
+ const issues = [];
703
+ let updates;
704
+ try {
705
+ updates = await findSpecUpdates(changeDir, mainSpecsDir);
706
+ }
707
+ catch (error) {
708
+ // An incomplete advisory check must not discard the validation report.
709
+ // Source discovery already ran above; archive retains its own path guards.
710
+ return [{
711
+ level: 'INFO',
712
+ path: 'specs',
713
+ message: `Could not check archive merge conflicts: ${error instanceof Error ? error.message : String(error)}`,
714
+ }];
715
+ }
716
+ for (const update of updates) {
717
+ // discoverSpecFiles builds both this id and the entryPath the checks
718
+ // above report under, from the same walk.
719
+ const entryPath = FileSystemUtils.toPosixPath(`${update.id}/spec.md`);
720
+ // A delta those checks already rejected would be reported twice, the
721
+ // second time in archive's wording rather than the wording that names
722
+ // the actual mistake.
723
+ if (alreadyReported.has(entryPath))
724
+ continue;
725
+ try {
726
+ await buildUpdatedSpec(update, changeName, { silent: true });
727
+ }
728
+ catch (error) {
729
+ // Only the thrown preconditions, which carry no errno. A filesystem
730
+ // error says nothing about whether the delta applies, and `validate
731
+ // --all` reads six changes at once, so a transient EMFILE would report
732
+ // a collision that is not there - the same reason the scenario-loss
733
+ // check above reads only the codes that mean the file is unusable.
734
+ if (error?.code !== undefined)
735
+ continue;
736
+ issues.push({
737
+ level: 'INFO',
738
+ path: entryPath,
739
+ message: `Archive would refuse this delta: ${error instanceof Error ? error.message : String(error)}`,
740
+ });
741
+ }
742
+ }
743
+ return issues;
744
+ }
659
745
  createReport(issues) {
660
746
  const errors = issues.filter(i => i.level === 'ERROR').length;
661
747
  const warnings = issues.filter(i => i.level === 'WARNING').length;
@@ -60,15 +60,14 @@ const SKILL_INVOCATION_PREFIX = {
60
60
  codex: '$',
61
61
  };
62
62
  /**
63
- * Tools that have no slash-command surface at all: skills are matched
64
- * automatically or invoked by natural-language prompts, never by typing a
65
- * `/<name>` command. Rovo Dev CLI is such a tool — `/skills` only manages
66
- * skills, and any `/openspec-*` form would be a dead command (see
67
- * docs/supported-tools.md). References for these tools are spelled as prose
68
- * ("the openspec-propose skill") so generated content never tells the user to
69
- * type a command their CLI does not register.
63
+ * Tools with no documented slash invocation for skills: use automatic
64
+ * matching or natural-language prompts instead. SourceCraft Code Assistant
65
+ * supports separate command files, but its skills use description matching.
66
+ * Rovo Dev's `/skills` only manages skills (see docs/supported-tools.md).
67
+ * Skill references for these tools are spelled as prose ("the openspec-propose
68
+ * skill") so skills-only delivery does not advertise unregistered commands.
70
69
  */
71
- const NATURAL_LANGUAGE_SKILL_TOOLS = new Set(['rovodev']);
70
+ const NATURAL_LANGUAGE_SKILL_TOOLS = new Set(['rovodev', 'codeassistant']);
72
71
  /**
73
72
  * Whether a tool references skills by natural language rather than a slash
74
73
  * command (see NATURAL_LANGUAGE_SKILL_TOOLS).
@@ -0,0 +1,42 @@
1
+ /** A main-spec requirement block, and how its header matched the delta's. */
2
+ export interface MatchedRequirementBlock {
3
+ raw: string;
4
+ /** The header name as the main spec spells it. */
5
+ name: string;
6
+ /**
7
+ * False when the two headers differ only in case or interior spacing. Archive
8
+ * matches names exactly, so an inexact match is a real problem to report even
9
+ * though the diff below it is still the one the author meant.
10
+ */
11
+ exact: boolean;
12
+ }
13
+ /**
14
+ * Find the raw markdown block for a requirement by name in a spec file.
15
+ *
16
+ * Delegates to extractRequirementsSection() which already handles code fences,
17
+ * section boundaries, and requirement header parsing. We just look up by name:
18
+ * exactly first, then falling back to the shared case/whitespace fold so a
19
+ * header that differs only in spelling still shows its diff, flagged inexact.
20
+ *
21
+ * Returns null if no requirement header matches either way.
22
+ */
23
+ export declare function extractRequirementBlock(specContent: string, requirementName: string): MatchedRequirementBlock | null;
24
+ /**
25
+ * Compute a unified diff between a main-spec requirement block and the delta
26
+ * block that replaces it. A null main block (new capability) diffs against the
27
+ * empty string, so every line reads as an addition.
28
+ *
29
+ * Uses structuredPatch to retain unified-diff hunk ranges while omitting the
30
+ * synthetic file headers, since the caller provides its own labeling.
31
+ */
32
+ export declare function diffRequirementBlock(baseBlock: string | null, deltaBlock: string, label: string): string;
33
+ /**
34
+ * Build a map from folded RENAMED TO name to the original main-spec name.
35
+ * Renames apply in source order, so a chain such as A -> B -> C maps C back to
36
+ * A. That is the block a later MODIFIED C replaces.
37
+ */
38
+ export declare function buildRenameMap(renames: Array<{
39
+ from: string;
40
+ to: string;
41
+ }>): Map<string, string>;
42
+ //# sourceMappingURL=requirement-diff.d.ts.map
@@ -0,0 +1,61 @@
1
+ import { formatPatch, OMIT_HEADERS, structuredPatch } from 'diff';
2
+ import { extractRequirementsSection, foldRequirementName, normalizeRequirementName, } from '../core/parsers/requirement-blocks.js';
3
+ /**
4
+ * Find the raw markdown block for a requirement by name in a spec file.
5
+ *
6
+ * Delegates to extractRequirementsSection() which already handles code fences,
7
+ * section boundaries, and requirement header parsing. We just look up by name:
8
+ * exactly first, then falling back to the shared case/whitespace fold so a
9
+ * header that differs only in spelling still shows its diff, flagged inexact.
10
+ *
11
+ * Returns null if no requirement header matches either way.
12
+ */
13
+ export function extractRequirementBlock(specContent, requirementName) {
14
+ const parts = extractRequirementsSection(specContent);
15
+ const targetName = normalizeRequirementName(requirementName);
16
+ const foldedTarget = foldRequirementName(targetName);
17
+ let folded = null;
18
+ for (const block of parts.bodyBlocks) {
19
+ const name = normalizeRequirementName(block.name);
20
+ if (name === targetName) {
21
+ return { raw: block.raw, name, exact: true };
22
+ }
23
+ if (!folded && foldRequirementName(name) === foldedTarget) {
24
+ folded = { raw: block.raw, name, exact: false };
25
+ }
26
+ }
27
+ return folded;
28
+ }
29
+ /**
30
+ * Compute a unified diff between a main-spec requirement block and the delta
31
+ * block that replaces it. A null main block (new capability) diffs against the
32
+ * empty string, so every line reads as an addition.
33
+ *
34
+ * Uses structuredPatch to retain unified-diff hunk ranges while omitting the
35
+ * synthetic file headers, since the caller provides its own labeling.
36
+ */
37
+ export function diffRequirementBlock(baseBlock, deltaBlock, label) {
38
+ const base = ensureTrailingNewline(baseBlock ?? '');
39
+ const delta = ensureTrailingNewline(deltaBlock);
40
+ const patch = structuredPatch(label, label, base, delta);
41
+ return formatPatch(patch, OMIT_HEADERS).trimEnd();
42
+ }
43
+ function ensureTrailingNewline(s) {
44
+ return s.endsWith('\n') ? s : s + '\n';
45
+ }
46
+ /**
47
+ * Build a map from folded RENAMED TO name to the original main-spec name.
48
+ * Renames apply in source order, so a chain such as A -> B -> C maps C back to
49
+ * A. That is the block a later MODIFIED C replaces.
50
+ */
51
+ export function buildRenameMap(renames) {
52
+ const map = new Map();
53
+ for (const r of renames) {
54
+ const from = foldRequirementName(r.from);
55
+ const original = map.get(from) ?? normalizeRequirementName(r.from);
56
+ map.delete(from);
57
+ map.set(foldRequirementName(r.to), original);
58
+ }
59
+ return map;
60
+ }
61
+ //# sourceMappingURL=requirement-diff.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -41,8 +41,8 @@
41
41
  "node": ">=20.19.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@changesets/changelog-github": "^0.7.0",
45
- "@changesets/cli": "^2.31.1",
44
+ "@changesets/changelog-github": "^1.0.0",
45
+ "@changesets/cli": "^3.0.1",
46
46
  "@types/node": "^20.19.43",
47
47
  "@vitest/ui": "^3.2.6",
48
48
  "eslint": "^10.5.0",
@@ -56,6 +56,7 @@
56
56
  "@inquirer/prompts": "^8.5.2",
57
57
  "chalk": "^5.6.2",
58
58
  "commander": "^14.0.0",
59
+ "diff": "^9.0.0",
59
60
  "cross-spawn": "7.0.6",
60
61
  "fast-glob": "^3.3.3",
61
62
  "ora": "^9.4.1",