@fission-ai/openspec 1.14.1 → 1.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -31
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.js +18 -0
- package/dist/commands/completion.js +4 -1
- package/dist/core/archive.js +19 -7
- package/dist/core/artifact-graph/outputs.js +39 -1
- package/dist/core/completions/completion-provider.js +4 -2
- package/dist/core/parsers/requirement-blocks.d.ts +11 -0
- package/dist/core/parsers/requirement-blocks.js +57 -0
- package/dist/core/specs-apply.js +5 -1
- package/dist/core/templates/workflows/explore.js +8 -4
- package/dist/core/templates/workflows/update-change.js +4 -4
- package/dist/core/templates/workflows/verify-change.js +14 -8
- package/dist/core/validation/validator.js +11 -1
- package/dist/utils/change-metadata.d.ts +17 -0
- package/dist/utils/change-metadata.js +27 -2
- package/dist/utils/file-system.d.ts +7 -0
- package/dist/utils/file-system.js +25 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,11 +33,6 @@ Our philosophy:
|
|
|
33
33
|
→ scalable from personal projects to enterprises
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
> [!TIP]
|
|
37
|
-
> **New workflow now available!** We've rebuilt OpenSpec with a new artifact-guided workflow.
|
|
38
|
-
>
|
|
39
|
-
> Run `/opsx:propose "your idea"` to get started. → [Learn more here](docs/opsx.md)
|
|
40
|
-
|
|
41
36
|
<p align="center">
|
|
42
37
|
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/YctCnvvshC">OpenSpec Discord</a> for help and questions.
|
|
43
38
|
</p>
|
|
@@ -112,13 +107,13 @@ OpenSpec is built with OpenSpec — browse this repo's live [specs](openspec/spe
|
|
|
112
107
|
|
|
113
108
|
Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the hard part moves: a feature spans the API server, the web app, and a shared library; requirements are owned by one team and consumed by others; planning starts before any code exists.
|
|
114
109
|
|
|
115
|
-
**[Stores](docs/stores
|
|
110
|
+
**[Stores](https://openspec.dev/docs/stores)** are the answer — planning in a repo of its own. The same `openspec/` shape you already know (specs and changes), shared by `git push` like anything else. One source of truth your whole team and every coding agent can read, across every repo.
|
|
116
111
|
|
|
117
112
|
- **Cross-repo features** — one change, one plan, even when the code lands in three repos.
|
|
118
113
|
- **Shared requirements** — a platform team owns the specs; product teams reference them read-only, right where their coding agent can read them. No drifting wiki.
|
|
119
114
|
- **Plan before code** — capture the plan in the store now; the code repos catch up later.
|
|
120
115
|
|
|
121
|
-
> Stores are in **beta**. Start with the [Stores
|
|
116
|
+
> Stores are in **beta**. Start with the [Stores guide](https://openspec.dev/docs/stores).
|
|
122
117
|
|
|
123
118
|
## Quick Start
|
|
124
119
|
|
|
@@ -143,44 +138,33 @@ cd your-project
|
|
|
143
138
|
openspec init
|
|
144
139
|
```
|
|
145
140
|
|
|
146
|
-
> **Want your AI to do it?** Paste the [setup prompt](docs
|
|
141
|
+
> **Want your AI to do it?** Paste the [setup prompt](https://openspec.dev/docs/installation#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
|
|
147
142
|
|
|
148
143
|
Now talk to your AI:
|
|
149
144
|
|
|
150
|
-
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before any code gets written. ([
|
|
145
|
+
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before any code gets written. ([Quickstart](https://openspec.dev/docs/quickstart))
|
|
151
146
|
- **Already know what you want?** Go straight to `/opsx:propose <what-you-want-to-build>`.
|
|
152
147
|
|
|
153
148
|
Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
|
|
154
149
|
|
|
155
|
-
`/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot), `@opsx-propose` (Amazon Q) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [
|
|
150
|
+
`/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot), `@opsx-propose` (Amazon Q) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [Supported Tools](https://openspec.dev/docs/supported-tools).
|
|
156
151
|
|
|
157
152
|
> [!NOTE]
|
|
158
|
-
> Not sure if your tool is supported? [View the full list](docs/supported-tools
|
|
153
|
+
> Not sure if your tool is supported? [View the full list](https://openspec.dev/docs/supported-tools) – we support 30+ tools and growing.
|
|
159
154
|
>
|
|
160
|
-
> Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](docs
|
|
155
|
+
> Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](https://openspec.dev/docs/installation).
|
|
161
156
|
|
|
162
157
|
## Docs
|
|
163
158
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
→ **[Getting Started](docs/getting-started.md)**: first steps<br>
|
|
167
|
-
→ **[Explore First](docs/explore.md)**: think it through with `/opsx:explore` before you commit<br>
|
|
168
|
-
→ **[How Commands Work](docs/how-commands-work.md)**: where slash commands run vs the CLI<br>
|
|
169
|
-
→ **[Core Concepts at a Glance](docs/overview.md)**: the whole mental model, one page<br>
|
|
170
|
-
→ **[Examples & Recipes](docs/examples.md)**: real changes, start to finish<br>
|
|
171
|
-
→ **[Workflows](docs/workflows.md)**: combos and patterns<br>
|
|
172
|
-
→ **[Existing Projects](docs/existing-projects.md)**: adopt OpenSpec on a brownfield codebase<br>
|
|
173
|
-
→ **[Editing a Change](docs/editing-changes.md)**: update artifacts, go back, reconcile manual edits<br>
|
|
174
|
-
→ **[Commands](docs/commands.md)**: slash commands & skills<br>
|
|
175
|
-
→ **[CLI](docs/cli.md)**: terminal reference<br>
|
|
176
|
-
→ **[Stores](docs/stores-beta/user-guide.md)**: plan in a separate repo, shared across your team (beta)<br>
|
|
177
|
-
→ **[Supported Tools](docs/supported-tools.md)**: tool integrations & install paths<br>
|
|
178
|
-
→ **[Concepts](docs/concepts.md)**: how it all fits<br>
|
|
179
|
-
→ **[Multi-Language](docs/multi-language.md)**: multi-language support<br>
|
|
180
|
-
→ **[Customization](docs/customization.md)**: make it yours<br>
|
|
181
|
-
→ **[Community Showcase](docs/community.md)**: projects and resources built with and for OpenSpec<br>
|
|
182
|
-
→ **[FAQ](docs/faq.md)** · **[Troubleshooting](docs/troubleshooting.md)** · **[Glossary](docs/glossary.md)**: quick help
|
|
159
|
+
Full documentation lives at **[openspec.dev/docs](https://openspec.dev/docs)**.
|
|
183
160
|
|
|
161
|
+
→ **[Quickstart](https://openspec.dev/docs/quickstart)**: your first change, start to finish<br>
|
|
162
|
+
→ **[Set up your project](https://openspec.dev/docs/setup)**: what `openspec init` installs<br>
|
|
163
|
+
→ **[CLI](https://openspec.dev/docs/cli)**: terminal reference<br>
|
|
164
|
+
→ **[Supported Tools](https://openspec.dev/docs/supported-tools)**: tool integrations & install paths<br>
|
|
165
|
+
→ **[Customize](https://openspec.dev/docs/customize)**: profiles, project config, and schemas<br>
|
|
166
|
+
→ **[Stores](https://openspec.dev/docs/stores)**: plan in a separate repo, shared across your team (beta)<br>
|
|
167
|
+
→ **[Community Showcase](docs/community.md)**: projects and resources built with and for OpenSpec
|
|
184
168
|
|
|
185
169
|
## Community schemas
|
|
186
170
|
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -27,6 +27,11 @@ export declare function isJsonRun(command: Command): boolean;
|
|
|
27
27
|
* completions is noise, and `__complete` would burn the one-shot tip invisibly.
|
|
28
28
|
*/
|
|
29
29
|
export declare function isCompletionRun(commandPath: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* True for the hidden `__complete` resolver alone. It runs on every Tab press,
|
|
32
|
+
* so the hooks skip telemetry and the completion tip for it entirely.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isTabCompletionRun(commandPath: string): boolean;
|
|
30
35
|
/**
|
|
31
36
|
* True when the first-run completions tip must be deferred rather than shown.
|
|
32
37
|
*
|
package/dist/cli/index.js
CHANGED
|
@@ -90,6 +90,13 @@ export function isJsonRun(command) {
|
|
|
90
90
|
export function isCompletionRun(commandPath) {
|
|
91
91
|
return commandPath.split(':')[0] === 'completion' || commandPath === '__complete';
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* True for the hidden `__complete` resolver alone. It runs on every Tab press,
|
|
95
|
+
* so the hooks skip telemetry and the completion tip for it entirely.
|
|
96
|
+
*/
|
|
97
|
+
export function isTabCompletionRun(commandPath) {
|
|
98
|
+
return commandPath === '__complete';
|
|
99
|
+
}
|
|
93
100
|
/**
|
|
94
101
|
* True when the first-run completions tip must be deferred rather than shown.
|
|
95
102
|
*
|
|
@@ -149,6 +156,12 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
|
149
156
|
if (opts.color === false) {
|
|
150
157
|
process.env.NO_COLOR = '1';
|
|
151
158
|
}
|
|
159
|
+
// `__complete` runs on every Tab press, with stderr sent to /dev/null by the
|
|
160
|
+
// generated scripts. Tracking it would hold each press open for the request,
|
|
161
|
+
// and the first-run notice would be marked seen without anyone reading it.
|
|
162
|
+
if (isTabCompletionRun(getCommandPath(actionCommand))) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
152
165
|
// Show first-run telemetry notice (if not seen). It's written to stderr, so it
|
|
153
166
|
// never pollutes stdout — but --json runs still defer it (see isJsonRun) so the
|
|
154
167
|
// very first invocation stays free of any incidental output on either stream.
|
|
@@ -160,6 +173,11 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
|
160
173
|
});
|
|
161
174
|
// Shutdown telemetry after command completes
|
|
162
175
|
program.hook('postAction', async (_thisCommand, actionCommand) => {
|
|
176
|
+
// Nothing to flush (preAction tracked nothing), and the tip is always
|
|
177
|
+
// deferred for completion runs, so skip loading either module.
|
|
178
|
+
if (isTabCompletionRun(getCommandPath(actionCommand))) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
163
181
|
// Show the first-run shell-completions tip (on stderr, so piped stdout stays
|
|
164
182
|
// clean). postAction, not preAction: the tip trails the command's own output
|
|
165
183
|
// instead of pushing an error message or `init`'s setup summary down the
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import ora from 'ora';
|
|
2
1
|
import { CompletionFactory } from '../core/completions/factory.js';
|
|
3
2
|
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
|
4
3
|
import { detectShell } from '../utils/shell-detection.js';
|
|
@@ -93,6 +92,9 @@ export class CompletionCommand {
|
|
|
93
92
|
async installForShell(shell, verbose) {
|
|
94
93
|
const generator = CompletionFactory.createGenerator(shell);
|
|
95
94
|
const installer = CompletionFactory.createInstaller(shell);
|
|
95
|
+
// Loaded here, not at the top: `__complete` shares this module and runs on
|
|
96
|
+
// every Tab press, so it must not pay for the spinner.
|
|
97
|
+
const { default: ora } = await import('ora');
|
|
96
98
|
const spinner = ora(`Installing ${shell} completion script...`).start();
|
|
97
99
|
try {
|
|
98
100
|
// Generate the completion script
|
|
@@ -187,6 +189,7 @@ export class CompletionCommand {
|
|
|
187
189
|
return;
|
|
188
190
|
}
|
|
189
191
|
}
|
|
192
|
+
const { default: ora } = await import('ora');
|
|
190
193
|
const spinner = ora(`Uninstalling ${shell} completion script...`).start();
|
|
191
194
|
try {
|
|
192
195
|
const result = await installer.uninstall();
|
package/dist/core/archive.js
CHANGED
|
@@ -1078,13 +1078,22 @@ export class ArchiveCommand {
|
|
|
1078
1078
|
const changesDir = root.changesDir;
|
|
1079
1079
|
const archiveDir = root.archiveDir;
|
|
1080
1080
|
const mainSpecsDir = root.specsDir;
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1081
|
+
const openspecDir = path.dirname(changesDir);
|
|
1082
|
+
// openspec/, changes/ and specs/ may be project-owned symlinks to another
|
|
1083
|
+
// directory; archive/ may not leave the changes directory it lives in.
|
|
1084
|
+
for (const [allowedDirectory, managedDir, allowLinkedLeaf] of [
|
|
1085
|
+
[root.path, openspecDir, true],
|
|
1086
|
+
[openspecDir, changesDir, true],
|
|
1087
|
+
[changesDir, archiveDir, false],
|
|
1088
|
+
[openspecDir, mainSpecsDir, true],
|
|
1085
1089
|
]) {
|
|
1086
1090
|
try {
|
|
1087
|
-
|
|
1091
|
+
if (allowLinkedLeaf) {
|
|
1092
|
+
FileSystemUtils.assertPathWithinAllowingLinkedLeaf(allowedDirectory, managedDir);
|
|
1093
|
+
}
|
|
1094
|
+
else {
|
|
1095
|
+
FileSystemUtils.assertPathWithin(allowedDirectory, managedDir);
|
|
1096
|
+
}
|
|
1088
1097
|
}
|
|
1089
1098
|
catch {
|
|
1090
1099
|
throw new ArchiveBlockedError('archive_path_outside_root', `Refusing to archive through a path outside the OpenSpec root: ${managedDir}`);
|
|
@@ -1744,6 +1753,9 @@ export class ArchiveCommand {
|
|
|
1744
1753
|
if (hasRetirements) {
|
|
1745
1754
|
await assertRetirementAuthorization(changeDir, retirementAuthorizationFingerprint);
|
|
1746
1755
|
}
|
|
1756
|
+
// update.source is canonical (symlinks resolved), so measure it from
|
|
1757
|
+
// the canonical change directory; changes/ may itself be a symlink.
|
|
1758
|
+
const canonicalChangeDir = FileSystemUtils.canonicalizeExistingPath(changeDir);
|
|
1747
1759
|
const verifyArchivedDeltas = async (stagedSource) => {
|
|
1748
1760
|
if (hasRetirements) {
|
|
1749
1761
|
await assertRetirementAuthorization(archivePath, retirementAuthorizationFingerprint,
|
|
@@ -1757,13 +1769,13 @@ export class ArchiveCommand {
|
|
|
1757
1769
|
}
|
|
1758
1770
|
}
|
|
1759
1771
|
for (const proposed of prepared) {
|
|
1760
|
-
const archivedSource = path.join(archivePath, path.relative(
|
|
1772
|
+
const archivedSource = path.join(archivePath, path.relative(canonicalChangeDir, proposed.update.source));
|
|
1761
1773
|
if ((await fingerprintPortableContent(archivedSource)) !==
|
|
1762
1774
|
proposed.sourceContentFingerprint) {
|
|
1763
1775
|
throw new Error(`The archived delta for '${proposed.update.id}' changed during the final move.`);
|
|
1764
1776
|
}
|
|
1765
1777
|
if (stagedSource) {
|
|
1766
|
-
const stagedDelta = path.join(stagedSource, path.relative(
|
|
1778
|
+
const stagedDelta = path.join(stagedSource, path.relative(canonicalChangeDir, proposed.update.source));
|
|
1767
1779
|
if ((await fingerprintPortableContent(stagedDelta)) !==
|
|
1768
1780
|
proposed.sourceContentFingerprint) {
|
|
1769
1781
|
throw new Error(`The active delta for '${proposed.update.id}' changed during the fallback copy.`);
|
|
@@ -4,6 +4,41 @@ import fg from 'fast-glob';
|
|
|
4
4
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
5
5
|
const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
|
|
6
6
|
const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
|
|
7
|
+
// braces <=3.0.3 (fast-glob > micromatch) has no depth guard (GHSA-vfj7-8cjw-p6xm),
|
|
8
|
+
// so reject nesting no real artifact pattern needs before it reaches the parser.
|
|
9
|
+
const MAX_BRACE_NESTING = 16;
|
|
10
|
+
function assertBraceNesting(pattern) {
|
|
11
|
+
// Mirror the braces parser: `[...]` and quoted runs are literal, `(` nests like
|
|
12
|
+
// `{`, and a closer only closes the innermost group of its own kind.
|
|
13
|
+
const open = [];
|
|
14
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
15
|
+
const char = pattern[index];
|
|
16
|
+
if (char === '[') {
|
|
17
|
+
for (let brackets = 1; brackets > 0 && index + 1 < pattern.length;) {
|
|
18
|
+
index += 1;
|
|
19
|
+
if (pattern[index] === '[')
|
|
20
|
+
brackets += 1;
|
|
21
|
+
else if (pattern[index] === ']')
|
|
22
|
+
brackets -= 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else if (char === '"' || char === "'" || char === '`') {
|
|
26
|
+
const end = pattern.indexOf(char, index + 1);
|
|
27
|
+
index = end === -1 ? pattern.length : end;
|
|
28
|
+
}
|
|
29
|
+
else if (char === '{' || char === '(') {
|
|
30
|
+
open.push(char);
|
|
31
|
+
if (open.length > MAX_BRACE_NESTING) {
|
|
32
|
+
// A pattern this deep can run to thousands of characters; quote only its start.
|
|
33
|
+
const shown = pattern.length > 80 ? `${pattern.slice(0, 80)}...` : pattern;
|
|
34
|
+
throw new Error(`Artifact output pattern nests braces more than ${MAX_BRACE_NESTING} levels deep: ${shown}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if ((char === '}' && open.at(-1) === '{') || (char === ')' && open.at(-1) === '(')) {
|
|
38
|
+
open.pop();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
7
42
|
function hasBraceExpansion(pattern) {
|
|
8
43
|
const openings = [];
|
|
9
44
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
@@ -90,8 +125,11 @@ function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments,
|
|
|
90
125
|
* Returns absolute file paths. Glob matches are sorted for deterministic output.
|
|
91
126
|
*/
|
|
92
127
|
export function resolveArtifactOutputs(changeDir, generates) {
|
|
128
|
+
const isGlob = isGlobPattern(generates);
|
|
129
|
+
if (isGlob)
|
|
130
|
+
assertBraceNesting(FileSystemUtils.toPosixPath(generates));
|
|
93
131
|
const outputPath = resolveArtifactOutputPath(changeDir, generates);
|
|
94
|
-
if (!
|
|
132
|
+
if (!isGlob) {
|
|
95
133
|
try {
|
|
96
134
|
return fs.statSync(outputPath).isFile()
|
|
97
135
|
? [FileSystemUtils.canonicalizeExistingPath(outputPath)]
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { getActiveChangeIds, getSpecIds } from '../../utils/item-discovery.js';
|
|
2
|
-
import { listSchemas } from '../artifact-graph/index.js';
|
|
3
2
|
/**
|
|
4
3
|
* Provides dynamic completion suggestions for OpenSpec items (changes and specs).
|
|
5
4
|
* Implements a 2-second cache to avoid excessive file system operations during
|
|
@@ -74,7 +73,10 @@ export class CompletionProvider {
|
|
|
74
73
|
if (this.schemaCache && now - this.schemaCache.timestamp < this.cacheTTL) {
|
|
75
74
|
return this.schemaCache.data;
|
|
76
75
|
}
|
|
77
|
-
// Fetch fresh data
|
|
76
|
+
// Fetch fresh data. The resolver (and the yaml and zod it parses schemas
|
|
77
|
+
// with) loads only here: completing changes or specs never needs it, and
|
|
78
|
+
// the artifact-graph barrel would also pull in fast-glob.
|
|
79
|
+
const { listSchemas } = await import('../artifact-graph/resolver.js');
|
|
78
80
|
const schemaNames = listSchemas(this.projectRoot);
|
|
79
81
|
// Update cache
|
|
80
82
|
this.schemaCache = {
|
|
@@ -70,6 +70,15 @@ export interface OrphanedRequirement {
|
|
|
70
70
|
section: string | null;
|
|
71
71
|
line: number;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Authored text archive does not carry into the main spec: a non-delta `## `
|
|
75
|
+
* section, text above the first `## `, or prose before the first requirement
|
|
76
|
+
* in ADDED/MODIFIED. `section` is null for text above the first `## `.
|
|
77
|
+
*/
|
|
78
|
+
export interface UnappliedContent {
|
|
79
|
+
section: string | null;
|
|
80
|
+
line: number;
|
|
81
|
+
}
|
|
73
82
|
export interface DeltaPlan {
|
|
74
83
|
added: RequirementBlock[];
|
|
75
84
|
modified: RequirementBlock[];
|
|
@@ -83,6 +92,7 @@ export interface DeltaPlan {
|
|
|
83
92
|
unpairedRenames: UnpairedRename[];
|
|
84
93
|
/** Canonical requirement blocks written outside every delta section. */
|
|
85
94
|
orphanedRequirements: OrphanedRequirement[];
|
|
95
|
+
unappliedContent: UnappliedContent[];
|
|
86
96
|
skippedHeaders: SkippedHeader[];
|
|
87
97
|
sectionPresence: {
|
|
88
98
|
added: boolean;
|
|
@@ -95,6 +105,7 @@ export interface DeltaPlan {
|
|
|
95
105
|
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
|
|
96
106
|
*/
|
|
97
107
|
export declare function parseDeltaSpec(content: string): DeltaPlan;
|
|
108
|
+
export declare function describeUnappliedContent(content: UnappliedContent, sentenceStart?: boolean): string;
|
|
98
109
|
/** Both directions of the scenario-name comparison, plus the two totals. */
|
|
99
110
|
export interface ScenarioNameDiff {
|
|
100
111
|
/** Names the current block has that the incoming block does not cover. */
|
|
@@ -136,6 +136,7 @@ export function parseDeltaSpec(content) {
|
|
|
136
136
|
renamed: renamedPairs,
|
|
137
137
|
unpairedRenames,
|
|
138
138
|
orphanedRequirements: findOrphanedRequirements(lines, fenceMask),
|
|
139
|
+
unappliedContent: findUnappliedContent(lines, fenceMask),
|
|
139
140
|
skippedHeaders,
|
|
140
141
|
sectionPresence: {
|
|
141
142
|
added: addedLookup.found,
|
|
@@ -187,6 +188,62 @@ function findOrphanedRequirements(lines, fenceMask) {
|
|
|
187
188
|
}
|
|
188
189
|
return orphans;
|
|
189
190
|
}
|
|
191
|
+
export function describeUnappliedContent(content, sentenceStart = false) {
|
|
192
|
+
const text = content.section === null
|
|
193
|
+
? 'text above the first "## " section'
|
|
194
|
+
: DELTA_SECTION_TITLES.has(content.section.toLowerCase())
|
|
195
|
+
? `text before the first requirement in "## ${content.section}"`
|
|
196
|
+
: `section "## ${content.section}"`;
|
|
197
|
+
return sentenceStart ? text.charAt(0).toUpperCase() + text.slice(1) : text;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Where text the merge never reads begins. HTML comments, `# ` titles, Purpose
|
|
201
|
+
* (warned about separately), REMOVED/RENAMED bodies, and stray `###` headers
|
|
202
|
+
* before the first requirement (INFO since #498) are not reported.
|
|
203
|
+
*/
|
|
204
|
+
function findUnappliedContent(lines, fenceMask) {
|
|
205
|
+
// Blank out comments outside fences, keeping line count; an unterminated one
|
|
206
|
+
// runs to EOF. Comments only decide whether a line holds text: sections come
|
|
207
|
+
// from the same split the merge reads, where a `## ` line inside a comment is
|
|
208
|
+
// still a boundary. Fenced lines keep their text, so a `<!--` in a code sample
|
|
209
|
+
// never hides what follows the fence.
|
|
210
|
+
const comments = lines
|
|
211
|
+
.map((line, i) => (fenceMask[i] ? '' : line))
|
|
212
|
+
.join('\n')
|
|
213
|
+
.replace(/<!--[\s\S]*?(?:--!?>|$)/g, (m) => m.replace(/[^\n]/g, ' '))
|
|
214
|
+
.split('\n');
|
|
215
|
+
const masked = lines.map((line, i) => (fenceMask[i] ? line : comments[i]));
|
|
216
|
+
const sections = splitTopLevelSections(lines, fenceMask);
|
|
217
|
+
const firstText = (from, to, skip) => {
|
|
218
|
+
for (let i = from; i < to; i++)
|
|
219
|
+
if (masked[i].trim() && !skip?.test(masked[i]))
|
|
220
|
+
return i;
|
|
221
|
+
return -1;
|
|
222
|
+
};
|
|
223
|
+
const found = [];
|
|
224
|
+
const preamble = firstText(0, sections.length ? sections[0].body.bodyStartLine - 2 : lines.length, /^#\s/);
|
|
225
|
+
if (preamble !== -1)
|
|
226
|
+
found.push({ section: null, line: preamble + 1 });
|
|
227
|
+
for (const { title, body } of sections) {
|
|
228
|
+
const folded = title.toLowerCase();
|
|
229
|
+
const start = body.bodyStartLine - 1;
|
|
230
|
+
if (folded === 'added requirements' || folded === 'modified requirements') {
|
|
231
|
+
const block = body.lines.findIndex((l, i) => !body.fenceMask[i] && REQUIREMENT_HEADER_REGEX.test(l));
|
|
232
|
+
const first = firstText(start, start + (block === -1 ? body.lines.length : block), /^###\s/);
|
|
233
|
+
if (first !== -1)
|
|
234
|
+
found.push({ section: title, line: first + 1 });
|
|
235
|
+
}
|
|
236
|
+
else if (!DELTA_SECTION_TITLES.has(folded)) {
|
|
237
|
+
// The Purpose carry-over reads past commented-out headers, so only a
|
|
238
|
+
// `## Purpose` outside a comment is the one archive handles.
|
|
239
|
+
if (folded === 'purpose' && masked[start - 1].trim())
|
|
240
|
+
continue;
|
|
241
|
+
if (firstText(start, start + body.lines.length) !== -1)
|
|
242
|
+
found.push({ section: title, line: start });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return found;
|
|
246
|
+
}
|
|
190
247
|
/**
|
|
191
248
|
* Every `## ` section, as a LIST rather than a title-keyed record.
|
|
192
249
|
*
|
package/dist/core/specs-apply.js
CHANGED
|
@@ -8,7 +8,7 @@ import { promises as fs } from 'fs';
|
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import chalk from 'chalk';
|
|
11
|
-
import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
|
|
11
|
+
import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
|
|
12
12
|
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
|
|
13
13
|
import { buildCodeFenceMask } from './parsers/code-fence.js';
|
|
14
14
|
import { MarkdownParser } from './parsers/markdown-parser.js';
|
|
@@ -145,6 +145,10 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
145
145
|
`which is not a delta section, so it was not applied. ` +
|
|
146
146
|
`Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`);
|
|
147
147
|
}
|
|
148
|
+
// Archive carries requirement blocks only; name any other text it leaves behind.
|
|
149
|
+
for (const lost of plan.unappliedContent) {
|
|
150
|
+
warn(`${specName} - ${describeUnappliedContent(lost)} (line ${lost.line}) is not carried into the main spec.`);
|
|
151
|
+
}
|
|
148
152
|
// Pre-validate duplicates within sections
|
|
149
153
|
const addedNames = new Set();
|
|
150
154
|
for (const add of plan.added) {
|
|
@@ -178,6 +178,8 @@ If the user mentions a change or you detect one is relevant:
|
|
|
178
178
|
|
|
179
179
|
\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
|
|
180
180
|
|
|
181
|
+
The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from \`artifactPaths\` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
|
|
182
|
+
|
|
181
183
|
| Insight Type | Where to Capture |
|
|
182
184
|
|----------------------------|-------------------------------------|
|
|
183
185
|
| New requirement discovered | \`specs/<capability-path>/spec.md\` |
|
|
@@ -187,7 +189,7 @@ If the user mentions a change or you detect one is relevant:
|
|
|
187
189
|
| New work identified | \`tasks.md\` |
|
|
188
190
|
| Assumption invalidated | Relevant artifact |
|
|
189
191
|
|
|
190
|
-
Example offers:
|
|
192
|
+
Example offers (default schema):
|
|
191
193
|
- "That's a design decision. Capture it in design.md?"
|
|
192
194
|
- "This is a new requirement. Add it to specs?"
|
|
193
195
|
- "This changes scope. Update the proposal?"
|
|
@@ -316,7 +318,7 @@ You: That changes everything.
|
|
|
316
318
|
There's no required ending. Discovery might:
|
|
317
319
|
|
|
318
320
|
- **Flow into a proposal**: "${DISCOVERY_END_HANDOFF}"
|
|
319
|
-
- **Result in artifact updates**: "Updated design
|
|
321
|
+
- **Result in artifact updates**: "Updated the design with these decisions"
|
|
320
322
|
- **Just provide clarity**: User has what they need, moves on
|
|
321
323
|
- **Continue later**: "We can pick this up anytime"
|
|
322
324
|
|
|
@@ -511,6 +513,8 @@ If the user mentions a change or you detect one is relevant:
|
|
|
511
513
|
|
|
512
514
|
\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
|
|
513
515
|
|
|
516
|
+
The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from \`artifactPaths\` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
|
|
517
|
+
|
|
514
518
|
| Insight Type | Where to Capture |
|
|
515
519
|
|----------------------------|-------------------------------------|
|
|
516
520
|
| New requirement discovered | \`specs/<capability-path>/spec.md\` |
|
|
@@ -520,7 +524,7 @@ If the user mentions a change or you detect one is relevant:
|
|
|
520
524
|
| New work identified | \`tasks.md\` |
|
|
521
525
|
| Assumption invalidated | Relevant artifact |
|
|
522
526
|
|
|
523
|
-
Example offers:
|
|
527
|
+
Example offers (default schema):
|
|
524
528
|
- "That's a design decision. Capture it in design.md?"
|
|
525
529
|
- "This is a new requirement. Add it to specs?"
|
|
526
530
|
- "This changes scope. Update the proposal?"
|
|
@@ -545,7 +549,7 @@ If the user mentions a change or you detect one is relevant:
|
|
|
545
549
|
There's no required ending. Discovery might:
|
|
546
550
|
|
|
547
551
|
- **Flow into a proposal**: "${DISCOVERY_END_HANDOFF}"
|
|
548
|
-
- **Result in artifact updates**: "Updated design
|
|
552
|
+
- **Result in artifact updates**: "Updated the design with these decisions"
|
|
549
553
|
- **Just provide clarity**: User has what they need, moves on
|
|
550
554
|
- **Continue later**: "We can pick this up anytime"
|
|
551
555
|
|
|
@@ -64,7 +64,7 @@ ${CONTINUE_SCOPE_NOTE}
|
|
|
64
64
|
|
|
65
65
|
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
|
|
66
66
|
|
|
67
|
-
The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
|
|
67
|
+
The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
|
|
68
68
|
|
|
69
69
|
3. **Understand the request**
|
|
70
70
|
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
|
|
@@ -108,7 +108,7 @@ After each invocation, show:
|
|
|
108
108
|
**Guardrails**
|
|
109
109
|
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, ${APPLY_GUARDRAIL}.
|
|
110
110
|
- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names.
|
|
111
|
-
- Edit only the concrete files in \`existingOutputPaths
|
|
111
|
+
- Edit only the concrete files in \`existingOutputPaths\`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob \`resolvedOutputPath\`.
|
|
112
112
|
- Do not advance the build frontier: if an artifact has empty \`existingOutputPaths\` and status \`ready\` or \`blocked\`, ${CONTINUE_FRONTIER}. Leave \`skipped\` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose \`existingOutputPaths\` is non-empty.
|
|
113
113
|
- Confirm every edit with the user before writing.
|
|
114
114
|
- If the request changes the change's *intent* rather than refining it, ${INTENT_CHANGE_GUARDRAIL}.`,
|
|
@@ -163,7 +163,7 @@ ${CONTINUE_SCOPE_NOTE}
|
|
|
163
163
|
|
|
164
164
|
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
|
|
165
165
|
|
|
166
|
-
The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
|
|
166
|
+
The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
|
|
167
167
|
|
|
168
168
|
3. **Understand the request**
|
|
169
169
|
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
|
|
@@ -207,7 +207,7 @@ After each invocation, show:
|
|
|
207
207
|
**Guardrails**
|
|
208
208
|
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, ${APPLY_GUARDRAIL}.
|
|
209
209
|
- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names.
|
|
210
|
-
- Edit only the concrete files in \`existingOutputPaths
|
|
210
|
+
- Edit only the concrete files in \`existingOutputPaths\`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob \`resolvedOutputPath\`.
|
|
211
211
|
- Do not advance the build frontier: if an artifact has empty \`existingOutputPaths\` and status \`ready\` or \`blocked\`, ${CONTINUE_FRONTIER}. Leave \`skipped\` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose \`existingOutputPaths\` is non-empty.
|
|
212
212
|
- Confirm every edit with the user before writing.
|
|
213
213
|
- If the request changes the change's *intent* rather than refining it, ${INTENT_CHANGE_GUARDRAIL}.`
|
|
@@ -87,8 +87,11 @@ ${PROJECT_ROOT_GUARD}
|
|
|
87
87
|
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
|
88
88
|
- Recommendation: "Implement requirement X: <description>"
|
|
89
89
|
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
|
90
|
-
-
|
|
91
|
-
-
|
|
90
|
+
- A REMOVED block carries only the requirement name and any \`**Reason**\`/\`**Migration**\` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec. Match it by normalized name, scanning that file's requirement headings rather than string-matching the delta's exact title. Its body and scenarios are the evidence for the behavior that must be gone; never search from the requirement name alone.
|
|
91
|
+
- If that file holds no such requirement under this or a near-equivalent name - for example because an earlier sync already applied the removal - recover the baseline from the same file's history, in the planning repository rather than the current directory: \`git -C "<planningHome.root>" log -p -- openspec/specs/<capability-path>/spec.md\`, or \`git -C "<planningHome.root>" show <commit>^:./openspec/specs/<capability-path>/spec.md\` once you have identified the commit that applied the removal. Empty output means the pathspec matched nothing, not that there is no baseline: \`git log\` reports an unmatched path as success, so re-check the path before drawing any conclusion from silence.
|
|
92
|
+
- A baseline that adds nothing to its own title - no body beyond a restatement of the name, no scenarios - is unusable evidence, the same as a missing one. If no usable baseline is available in either place, mark **Spec Coverage** as not verified for that entry with the reason and stop there: do not search, and never count an unsearched removal as passing.
|
|
93
|
+
- Search codebase for that behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
|
94
|
+
- Finding no implementation is the expected result, but only once the baseline behavior was the thing you searched for. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
|
92
95
|
- If the behavior is still present:
|
|
93
96
|
- Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
|
|
94
97
|
- Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
|
|
@@ -104,7 +107,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
104
107
|
|
|
105
108
|
6. **Verify Correctness**
|
|
106
109
|
|
|
107
|
-
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
|
110
|
+
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each REMOVED and RENAMED entry with a usable baseline is checked there against its baseline behavior, and an entry already marked not verified there stays not verified), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
|
108
111
|
|
|
109
112
|
**Requirement Implementation Mapping**:
|
|
110
113
|
- For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
|
|
@@ -158,7 +161,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
158
161
|
| Coherence | Followed/Issues |
|
|
159
162
|
\`\`\`
|
|
160
163
|
|
|
161
|
-
In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
|
|
164
|
+
In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified", or "1 removal not verified (baseline unavailable)"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
|
|
162
165
|
|
|
163
166
|
**Issues by Priority**:
|
|
164
167
|
|
|
@@ -296,8 +299,11 @@ ${PROJECT_ROOT_GUARD}
|
|
|
296
299
|
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
|
297
300
|
- Recommendation: "Implement requirement X: <description>"
|
|
298
301
|
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
|
299
|
-
-
|
|
300
|
-
-
|
|
302
|
+
- A REMOVED block carries only the requirement name and any \`**Reason**\`/\`**Migration**\` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec. Match it by normalized name, scanning that file's requirement headings rather than string-matching the delta's exact title. Its body and scenarios are the evidence for the behavior that must be gone; never search from the requirement name alone.
|
|
303
|
+
- If that file holds no such requirement under this or a near-equivalent name - for example because an earlier sync already applied the removal - recover the baseline from the same file's history, in the planning repository rather than the current directory: \`git -C "<planningHome.root>" log -p -- openspec/specs/<capability-path>/spec.md\`, or \`git -C "<planningHome.root>" show <commit>^:./openspec/specs/<capability-path>/spec.md\` once you have identified the commit that applied the removal. Empty output means the pathspec matched nothing, not that there is no baseline: \`git log\` reports an unmatched path as success, so re-check the path before drawing any conclusion from silence.
|
|
304
|
+
- A baseline that adds nothing to its own title - no body beyond a restatement of the name, no scenarios - is unusable evidence, the same as a missing one. If no usable baseline is available in either place, mark **Spec Coverage** as not verified for that entry with the reason and stop there: do not search, and never count an unsearched removal as passing.
|
|
305
|
+
- Search codebase for that behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
|
306
|
+
- Finding no implementation is the expected result, but only once the baseline behavior was the thing you searched for. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
|
301
307
|
- If the behavior is still present:
|
|
302
308
|
- Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
|
|
303
309
|
- Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
|
|
@@ -313,7 +319,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
313
319
|
|
|
314
320
|
6. **Verify Correctness**
|
|
315
321
|
|
|
316
|
-
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
|
322
|
+
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each REMOVED and RENAMED entry with a usable baseline is checked there against its baseline behavior, and an entry already marked not verified there stays not verified), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
|
317
323
|
|
|
318
324
|
**Requirement Implementation Mapping**:
|
|
319
325
|
- For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
|
|
@@ -367,7 +373,7 @@ ${PROJECT_ROOT_GUARD}
|
|
|
367
373
|
| Coherence | Followed/Issues |
|
|
368
374
|
\`\`\`
|
|
369
375
|
|
|
370
|
-
In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
|
|
376
|
+
In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified", or "1 removal not verified (baseline unavailable)"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
|
|
371
377
|
|
|
372
378
|
**Issues by Priority**:
|
|
373
379
|
|
|
@@ -5,7 +5,7 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
|
|
|
5
5
|
import { ChangeParser } from '../parsers/change-parser.js';
|
|
6
6
|
import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
|
|
7
7
|
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
|
|
8
|
-
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, } from '../parsers/requirement-blocks.js';
|
|
8
|
+
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, } from '../parsers/requirement-blocks.js';
|
|
9
9
|
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
|
|
10
10
|
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
11
11
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
@@ -203,6 +203,16 @@ export class Validator {
|
|
|
203
203
|
message: `Requirement "${orphan.name}" is ${where}, which is not a delta section, so it is ignored. Move it under "## ADDED Requirements", "## MODIFIED Requirements", "## REMOVED Requirements", or "## RENAMED Requirements".`,
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
|
+
// Archive carries requirement blocks only, by design. Warn so text
|
|
207
|
+
// written elsewhere is moved rather than lost without notice.
|
|
208
|
+
for (const lost of plan.unappliedContent) {
|
|
209
|
+
issues.push({
|
|
210
|
+
level: 'WARNING',
|
|
211
|
+
path: entryPath,
|
|
212
|
+
line: lost.line,
|
|
213
|
+
message: `${describeUnappliedContent(lost, true)} is not carried into the main spec by archive. Move it into a requirement, or into proposal.md or design.md.`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
206
216
|
const sectionNames = [];
|
|
207
217
|
if (plan.sectionPresence.added)
|
|
208
218
|
sectionNames.push('## ADDED Requirements');
|
|
@@ -2,10 +2,22 @@ import { CHANGE_METADATA_KNOWN_KEYS, type ChangeMetadata } from '../core/change-
|
|
|
2
2
|
import { type ProjectConfig } from '../core/project-config.js';
|
|
3
3
|
export declare const METADATA_FILENAME = ".openspec.yaml";
|
|
4
4
|
export { CHANGE_METADATA_KNOWN_KEYS };
|
|
5
|
+
/**
|
|
6
|
+
* Extension keys: a top-level key starting with a lowercase `x-` belongs to a
|
|
7
|
+
* tool or team that reads `.openspec.yaml` alongside OpenSpec (a tracker id, an
|
|
8
|
+
* owner). OpenSpec ignores it and does not report it as unknown. As with
|
|
9
|
+
* OpenAPI's `x-` extensions, the name after the prefix may use any case or
|
|
10
|
+
* spelling, but must be non-empty and free of whitespace, so `x-`, `x- foo`
|
|
11
|
+
* and `X-Foo` are still reported. Extensions are top-level only: a key inside
|
|
12
|
+
* `initiative:` is rejected by that object's strict schema.
|
|
13
|
+
*/
|
|
14
|
+
export declare const EXTENSION_KEY_PATTERN: RegExp;
|
|
15
|
+
export declare function isExtensionMetadataKey(key: string): boolean;
|
|
5
16
|
/**
|
|
6
17
|
* Unknown top-level keys on a parsed .openspec.yaml object. Extra keys are
|
|
7
18
|
* stripped by ChangeMetadataSchema rather than rejected, so callers that want
|
|
8
19
|
* to tell the author a key did nothing have to look at the raw object.
|
|
20
|
+
* Extension keys (`x-*`) are not unknown: they are deliberately someone else's.
|
|
9
21
|
*/
|
|
10
22
|
export declare function listUnknownChangeMetadataKeys(parsed: unknown): string[];
|
|
11
23
|
/**
|
|
@@ -40,6 +52,11 @@ export declare function validateSchemaName(schemaName: string, projectRoot?: str
|
|
|
40
52
|
/**
|
|
41
53
|
* Writes change metadata to .openspec.yaml in the change directory.
|
|
42
54
|
*
|
|
55
|
+
* Meant for a new file only (`createChange` refuses an existing change
|
|
56
|
+
* directory). `ChangeMetadataSchema` strips unknown keys, including `x-`
|
|
57
|
+
* extension keys, so a caller that rewrites an existing file must merge those
|
|
58
|
+
* keys back from the raw YAML first or it silently deletes them.
|
|
59
|
+
*
|
|
43
60
|
* @param changeDir - The path to the change directory
|
|
44
61
|
* @param metadata - The metadata to write
|
|
45
62
|
* @param projectRoot - Optional project root for project-local schema resolution
|
|
@@ -7,10 +7,24 @@ import { readProjectConfig } from '../core/project-config.js';
|
|
|
7
7
|
import { sanitizeInline } from '../core/references.js';
|
|
8
8
|
export const METADATA_FILENAME = '.openspec.yaml';
|
|
9
9
|
export { CHANGE_METADATA_KNOWN_KEYS };
|
|
10
|
+
/**
|
|
11
|
+
* Extension keys: a top-level key starting with a lowercase `x-` belongs to a
|
|
12
|
+
* tool or team that reads `.openspec.yaml` alongside OpenSpec (a tracker id, an
|
|
13
|
+
* owner). OpenSpec ignores it and does not report it as unknown. As with
|
|
14
|
+
* OpenAPI's `x-` extensions, the name after the prefix may use any case or
|
|
15
|
+
* spelling, but must be non-empty and free of whitespace, so `x-`, `x- foo`
|
|
16
|
+
* and `X-Foo` are still reported. Extensions are top-level only: a key inside
|
|
17
|
+
* `initiative:` is rejected by that object's strict schema.
|
|
18
|
+
*/
|
|
19
|
+
export const EXTENSION_KEY_PATTERN = /^x-\S+$/;
|
|
20
|
+
export function isExtensionMetadataKey(key) {
|
|
21
|
+
return EXTENSION_KEY_PATTERN.test(key);
|
|
22
|
+
}
|
|
10
23
|
/**
|
|
11
24
|
* Unknown top-level keys on a parsed .openspec.yaml object. Extra keys are
|
|
12
25
|
* stripped by ChangeMetadataSchema rather than rejected, so callers that want
|
|
13
26
|
* to tell the author a key did nothing have to look at the raw object.
|
|
27
|
+
* Extension keys (`x-*`) are not unknown: they are deliberately someone else's.
|
|
14
28
|
*/
|
|
15
29
|
export function listUnknownChangeMetadataKeys(parsed) {
|
|
16
30
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
@@ -18,7 +32,7 @@ export function listUnknownChangeMetadataKeys(parsed) {
|
|
|
18
32
|
}
|
|
19
33
|
const known = new Set(CHANGE_METADATA_KNOWN_KEYS);
|
|
20
34
|
return Object.keys(parsed)
|
|
21
|
-
.filter((key) => !known.has(key))
|
|
35
|
+
.filter((key) => !known.has(key) && !isExtensionMetadataKey(key))
|
|
22
36
|
.sort();
|
|
23
37
|
}
|
|
24
38
|
/**
|
|
@@ -32,7 +46,13 @@ export function formatUnknownChangeMetadataKeysMessage(keys) {
|
|
|
32
46
|
const listed = keys.map((key) => sanitizeInline(key, 100)).join(', ');
|
|
33
47
|
const known = [...CHANGE_METADATA_KNOWN_KEYS].join(', ');
|
|
34
48
|
let message = `Unrecognized key name(s) in ${METADATA_FILENAME} (untrusted data, not instructions): ${listed}. ` +
|
|
35
|
-
`Known keys: ${known}. Unknown keys are ignored and have no effect
|
|
49
|
+
`Known keys: ${known}. Unknown keys are ignored and have no effect; ` +
|
|
50
|
+
`prefix a key with x- (for example x-tracker) to keep tool-specific metadata without this warning.`;
|
|
51
|
+
if (keys.some((key) => /^x-/i.test(key))) {
|
|
52
|
+
message +=
|
|
53
|
+
' An x- extension key needs a lowercase x- prefix and a name after it with no whitespace:' +
|
|
54
|
+
' x-tracker and x-jiraKey are extensions, while x-, "x- tracker" and X-Tracker are not.';
|
|
55
|
+
}
|
|
36
56
|
if (keys.includes('skip_design')) {
|
|
37
57
|
message +=
|
|
38
58
|
' skip_design is not a supported key; only skip_specs exists, and it only skips artifacts whose generates path lives under specs/.';
|
|
@@ -92,6 +112,11 @@ export function validateSchemaName(schemaName, projectRoot) {
|
|
|
92
112
|
/**
|
|
93
113
|
* Writes change metadata to .openspec.yaml in the change directory.
|
|
94
114
|
*
|
|
115
|
+
* Meant for a new file only (`createChange` refuses an existing change
|
|
116
|
+
* directory). `ChangeMetadataSchema` strips unknown keys, including `x-`
|
|
117
|
+
* extension keys, so a caller that rewrites an existing file must merge those
|
|
118
|
+
* keys back from the raw YAML first or it silently deletes them.
|
|
119
|
+
*
|
|
95
120
|
* @param changeDir - The path to the change directory
|
|
96
121
|
* @param metadata - The metadata to write
|
|
97
122
|
* @param projectRoot - Optional project root for project-local schema resolution
|
|
@@ -15,6 +15,13 @@ export declare class FileSystemUtils {
|
|
|
15
15
|
* Missing suffixes are resolved from their nearest existing ancestor.
|
|
16
16
|
*/
|
|
17
17
|
static assertPathWithin(allowedDirectory: string, targetPath: string): void;
|
|
18
|
+
/**
|
|
19
|
+
* Like assertPathWithin, but a targetPath that is itself a symlink may
|
|
20
|
+
* resolve outside allowedDirectory, so a project can keep a managed
|
|
21
|
+
* directory (for example openspec/changes) in another repository. Its parent
|
|
22
|
+
* must still be confined and the link must resolve to an existing path.
|
|
23
|
+
*/
|
|
24
|
+
static assertPathWithinAllowingLinkedLeaf(allowedDirectory: string, targetPath: string): void;
|
|
18
25
|
static resolveProjectArtifactPath(projectPath: string, artifactPath: string): string;
|
|
19
26
|
static assertProjectArtifactPath(projectPath: string, targetPath: string): void;
|
|
20
27
|
private static isPathWithin;
|
|
@@ -105,6 +105,31 @@ export class FileSystemUtils {
|
|
|
105
105
|
throw new Error(`Path is outside the allowed directory: ${targetPath}`);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Like assertPathWithin, but a targetPath that is itself a symlink may
|
|
110
|
+
* resolve outside allowedDirectory, so a project can keep a managed
|
|
111
|
+
* directory (for example openspec/changes) in another repository. Its parent
|
|
112
|
+
* must still be confined and the link must resolve to an existing path.
|
|
113
|
+
*/
|
|
114
|
+
static assertPathWithinAllowingLinkedLeaf(allowedDirectory, targetPath) {
|
|
115
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
116
|
+
let isLink = false;
|
|
117
|
+
try {
|
|
118
|
+
isLink = nodeFs.lstatSync(resolvedTarget).isSymbolicLink();
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error.code !== 'ENOENT') {
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!isLink) {
|
|
126
|
+
this.assertPathWithin(allowedDirectory, resolvedTarget);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.assertPathWithin(allowedDirectory, path.dirname(resolvedTarget));
|
|
130
|
+
// Throws for a dangling link, which cannot be proven to be a directory.
|
|
131
|
+
nodeFs.realpathSync.native(resolvedTarget);
|
|
132
|
+
}
|
|
108
133
|
static resolveProjectArtifactPath(projectPath, artifactPath) {
|
|
109
134
|
if (path.isAbsolute(artifactPath)) {
|
|
110
135
|
throw new Error(`Refusing to manage an artifact outside the project: ${artifactPath}`);
|