@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.
- package/README.md +1 -0
- package/dist/cli/index.js +12 -2
- package/dist/commands/change.d.ts +24 -0
- package/dist/commands/change.js +229 -5
- package/dist/commands/schema.d.ts +5 -0
- package/dist/commands/schema.js +201 -35
- package/dist/commands/show.js +1 -1
- package/dist/commands/validate.d.ts +57 -0
- package/dist/commands/validate.js +99 -13
- package/dist/commands/workflow/index.d.ts +1 -1
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/status.d.ts +2 -0
- package/dist/commands/workflow/status.js +76 -12
- package/dist/core/artifact-graph/resolver.js +15 -9
- package/dist/core/available-tools.js +16 -1
- package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
- package/dist/core/command-generation/adapters/antigravity.js +2 -2
- package/dist/core/command-generation/adapters/codeassistant.d.ts +15 -0
- package/dist/core/command-generation/adapters/codeassistant.js +29 -0
- package/dist/core/command-generation/adapters/index.d.ts +1 -0
- package/dist/core/command-generation/adapters/index.js +1 -0
- package/dist/core/command-generation/registry.js +2 -0
- package/dist/core/completions/command-registry.js +21 -0
- package/dist/core/completions/generators/fish-generator.d.ts +12 -0
- package/dist/core/completions/generators/fish-generator.js +79 -47
- package/dist/core/completions/templates/fish-templates.d.ts +1 -1
- package/dist/core/completions/templates/fish-templates.js +107 -10
- package/dist/core/completions/types.d.ts +4 -0
- package/dist/core/config.js +7 -1
- package/dist/core/init.d.ts +1 -0
- package/dist/core/init.js +71 -65
- package/dist/core/legacy-cleanup.js +6 -0
- package/dist/core/migration.js +42 -12
- package/dist/core/openspec-root.d.ts +1 -0
- package/dist/core/openspec-root.js +10 -2
- package/dist/core/parsers/requirement-blocks.d.ts +1 -0
- package/dist/core/parsers/requirement-blocks.js +2 -0
- package/dist/core/shared/ide-restart.d.ts +26 -0
- package/dist/core/shared/ide-restart.js +42 -0
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/shared-skill-target.d.ts +13 -0
- package/dist/core/shared-skill-target.js +43 -1
- package/dist/core/specs-apply.js +22 -9
- package/dist/core/templates/workflows/explore.js +108 -73
- package/dist/core/templates/workflows/ff-change.js +8 -0
- package/dist/core/templates/workflows/propose.js +8 -0
- package/dist/core/update.js +28 -31
- package/dist/core/validation/constants.d.ts +3 -0
- package/dist/core/validation/constants.js +12 -0
- package/dist/core/validation/purpose-placeholder.d.ts +45 -0
- package/dist/core/validation/purpose-placeholder.js +117 -0
- package/dist/core/validation/validator.d.ts +12 -3
- package/dist/core/validation/validator.js +90 -4
- package/dist/utils/command-references.js +7 -8
- package/dist/utils/requirement-diff.d.ts +42 -0
- package/dist/utils/requirement-diff.js +61 -0
- 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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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.
|
|
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.
|
|
45
|
-
"@changesets/cli": "^
|
|
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",
|