@fission-ai/openspec 1.14.1 → 1.14.3
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/change.js +2 -1
- package/dist/commands/completion.js +4 -1
- package/dist/commands/spec.js +2 -10
- package/dist/commands/validate.js +3 -3
- package/dist/core/archive.js +19 -7
- package/dist/core/artifact-graph/outputs.js +241 -28
- 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 +28 -6
- package/dist/core/templates/workflows/continue-change.js +18 -12
- package/dist/core/templates/workflows/explore.js +10 -6
- package/dist/core/templates/workflows/ff-change.js +8 -6
- package/dist/core/templates/workflows/propose.js +8 -6
- 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 +14 -4
- package/dist/utils/change-metadata.d.ts +17 -0
- package/dist/utils/change-metadata.js +27 -2
- package/dist/utils/file-system.d.ts +8 -1
- package/dist/utils/file-system.js +26 -1
- package/dist/utils/interactive.d.ts +15 -0
- package/dist/utils/interactive.js +18 -0
- package/dist/utils/spec-discovery.d.ts +4 -4
- package/dist/utils/spec-discovery.js +7 -17
- package/dist/utils/spec-path-containment.d.ts +17 -0
- package/dist/utils/spec-path-containment.js +122 -0
- package/package.json +5 -2
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
|
package/dist/commands/change.js
CHANGED
|
@@ -10,6 +10,7 @@ import { getActiveChangeIds } from '../utils/item-discovery.js';
|
|
|
10
10
|
import { describeNestedChange, findNestedChangesIn, } from '../utils/nested-change.js';
|
|
11
11
|
import { getTaskProgressForChange } from '../utils/task-progress.js';
|
|
12
12
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
13
|
+
import { assertDiscoveredSpecFile } from '../utils/spec-path-containment.js';
|
|
13
14
|
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
|
14
15
|
import { foldRequirementName, parseDeltaSpec, } from '../core/parsers/requirement-blocks.js';
|
|
15
16
|
import { extractRequirementBlock, diffRequirementBlock, buildRenameMap, } from '../utils/requirement-diff.js';
|
|
@@ -168,7 +169,7 @@ export class ChangeCommand {
|
|
|
168
169
|
const mainSpecPath = path.join(mainSpecsDir, ...capability.split('/'), 'spec.md');
|
|
169
170
|
let mainContent = null;
|
|
170
171
|
try {
|
|
171
|
-
|
|
172
|
+
assertDiscoveredSpecFile(mainSpecsDir, mainSpecPath);
|
|
172
173
|
mainContent = await fs.readFile(mainSpecPath, 'utf-8');
|
|
173
174
|
}
|
|
174
175
|
catch (error) {
|
|
@@ -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/commands/spec.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Validator } from '../core/validation/validator.js';
|
|
|
5
5
|
import { isInteractive } from '../utils/interactive.js';
|
|
6
6
|
import { getSpecIds } from '../utils/item-discovery.js';
|
|
7
7
|
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
|
8
|
-
import {
|
|
8
|
+
import { assertDiscoveredSpecFile } from '../utils/spec-path-containment.js';
|
|
9
9
|
const SPECS_DIR = 'openspec/specs';
|
|
10
10
|
function assertSpecPath(specsDir, specPath) {
|
|
11
11
|
const relativePath = path.relative(path.resolve(specsDir), path.resolve(specPath));
|
|
@@ -14,15 +14,7 @@ function assertSpecPath(specsDir, specPath) {
|
|
|
14
14
|
path.isAbsolute(relativePath)) {
|
|
15
15
|
throw new Error(`Path is outside the allowed directory: ${specPath}`);
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
// Preserve confined spec.md links, including links to a sibling capability.
|
|
19
|
-
FileSystemUtils.assertPathWithin(specsDir, specPath);
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
// A capability directory may intentionally be a monorepo symlink. Treat it
|
|
23
|
-
// as the trust root while still rejecting a link outside that capability.
|
|
24
|
-
FileSystemUtils.assertPathWithin(path.dirname(specPath), specPath);
|
|
25
|
-
}
|
|
17
|
+
assertDiscoveredSpecFile(specsDir, specPath);
|
|
26
18
|
}
|
|
27
19
|
function parseSpecFromFile(specsDir, specPath, specId) {
|
|
28
20
|
assertSpecPath(specsDir, specPath);
|
|
@@ -4,7 +4,7 @@ import { describeNestedChange, findNestedChangesIn, NESTED_CHANGE_ISSUE_MARKER,
|
|
|
4
4
|
import { Validator } from '../core/validation/validator.js';
|
|
5
5
|
import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
|
|
6
6
|
import { resolveRootForCommand, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../core/root-selection.js';
|
|
7
|
-
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
|
|
7
|
+
import { canRenderSpinner, isInteractive, resolveNoInteractive } from '../utils/interactive.js';
|
|
8
8
|
import { getSpecIds } from '../utils/item-discovery.js';
|
|
9
9
|
import { getAvailableChanges } from './workflow/shared.js';
|
|
10
10
|
import { nearestMatches } from '../utils/match.js';
|
|
@@ -356,7 +356,7 @@ export class ValidateCommand {
|
|
|
356
356
|
}
|
|
357
357
|
}
|
|
358
358
|
async runBulkValidation(root, scope, opts) {
|
|
359
|
-
const spinner = !opts.json &&
|
|
359
|
+
const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating...').start() : undefined;
|
|
360
360
|
const [changeIds, specIds] = await Promise.all([
|
|
361
361
|
scope.changes ? this.listChangeIds(root) : Promise.resolve([]),
|
|
362
362
|
scope.specs ? getSpecIds(root.path) : Promise.resolve([]),
|
|
@@ -527,7 +527,7 @@ export class ValidateCommand {
|
|
|
527
527
|
// List first (may throw on a real archive-read failure), then start the
|
|
528
528
|
// spinner so a thrown error never leaves a spinner spinning.
|
|
529
529
|
const ids = await this.listArchivedChangeIds(root);
|
|
530
|
-
const spinner = !opts.json &&
|
|
530
|
+
const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating archived changes...').start() : undefined;
|
|
531
531
|
// The archive is append-only and can hold thousands of changes; a single
|
|
532
532
|
// run resolves them all under one constant projectRoot (root.path), so
|
|
533
533
|
// memoize the schema→glob lookup to avoid re-parsing the same schema.yaml
|
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.`);
|
|
@@ -1,9 +1,221 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import { expand } from 'brace-expansion';
|
|
4
|
+
import picomatch from 'picomatch';
|
|
5
|
+
import * as globby from 'tinyglobby';
|
|
4
6
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
5
7
|
const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
|
|
6
8
|
const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
|
|
9
|
+
const DUPLICATE_SLASH_RE = /(?!^)\/{2,}/gu;
|
|
10
|
+
// Deeply nested braces can overflow a recursive expander. Reject nesting no real
|
|
11
|
+
// artifact pattern needs before brace-expansion or tinyglobby see the pattern.
|
|
12
|
+
const MAX_BRACE_NESTING = 16;
|
|
13
|
+
// Nesting depth alone does not bound the work: `{a,b}` repeated 20 times is
|
|
14
|
+
// only one level deep and expands to 1,048,576 patterns, which exhausts the
|
|
15
|
+
// heap. fast-glob never expanded that far because micromatch compiled the
|
|
16
|
+
// braces to a regex. No real artifact pattern needs more than a few hundred.
|
|
17
|
+
const MAX_BRACE_EXPANSIONS = 1024;
|
|
18
|
+
const ARTIFACT_GLOB_OPTIONS = {
|
|
19
|
+
absolute: true,
|
|
20
|
+
// Braces are expanded first so `{draft}` left behind stays a literal name.
|
|
21
|
+
braceExpansion: false,
|
|
22
|
+
// fast-glob migration: directory patterns must not grow a trailing `/**`.
|
|
23
|
+
expandDirectories: false,
|
|
24
|
+
followSymbolicLinks: true,
|
|
25
|
+
onlyFiles: true,
|
|
26
|
+
};
|
|
27
|
+
function assertBraceNesting(pattern) {
|
|
28
|
+
// Mirror the braces parser: `[...]` and quoted runs are literal, `(` nests like
|
|
29
|
+
// `{`, and a closer only closes the innermost group of its own kind.
|
|
30
|
+
const open = [];
|
|
31
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
32
|
+
const char = pattern[index];
|
|
33
|
+
if (char === '[') {
|
|
34
|
+
for (let brackets = 1; brackets > 0 && index + 1 < pattern.length;) {
|
|
35
|
+
index += 1;
|
|
36
|
+
if (pattern[index] === '[')
|
|
37
|
+
brackets += 1;
|
|
38
|
+
else if (pattern[index] === ']')
|
|
39
|
+
brackets -= 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if (char === '"' || char === "'" || char === '`') {
|
|
43
|
+
const end = pattern.indexOf(char, index + 1);
|
|
44
|
+
index = end === -1 ? pattern.length : end;
|
|
45
|
+
}
|
|
46
|
+
else if (char === '{' || char === '(') {
|
|
47
|
+
open.push(char);
|
|
48
|
+
if (open.length > MAX_BRACE_NESTING) {
|
|
49
|
+
// A pattern this deep can run to thousands of characters; quote only its start.
|
|
50
|
+
const shown = pattern.length > 80 ? `${pattern.slice(0, 80)}...` : pattern;
|
|
51
|
+
throw new Error(`Artifact output pattern nests braces more than ${MAX_BRACE_NESTING} levels deep: ${shown}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else if ((char === '}' && open.at(-1) === '{') || (char === ')' && open.at(-1) === '(')) {
|
|
55
|
+
open.pop();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function isDynamicSegment(segment) {
|
|
60
|
+
return segment.includes('*') || segment.includes('?') || segment.includes('[')
|
|
61
|
+
|| EXTGLOB_RE.test(segment);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Static directory prefix of an expanded pattern, equivalent to the `base`
|
|
65
|
+
* fast-glob's generateTasks used to reject `../` before walking.
|
|
66
|
+
*/
|
|
67
|
+
function globBase(pattern) {
|
|
68
|
+
const segments = pattern.split('/');
|
|
69
|
+
if (segments.length <= 1)
|
|
70
|
+
return '.';
|
|
71
|
+
const base = [];
|
|
72
|
+
for (const segment of segments.slice(0, -1)) {
|
|
73
|
+
if (segment === '')
|
|
74
|
+
continue;
|
|
75
|
+
if (isDynamicSegment(segment))
|
|
76
|
+
break;
|
|
77
|
+
base.push(segment);
|
|
78
|
+
}
|
|
79
|
+
// An absolute pattern must stay absolute, or `path.resolve(changeDir, base)`
|
|
80
|
+
// rebases `/etc/*` inside the change directory and the containment check
|
|
81
|
+
// passes for a pattern that globs the filesystem root.
|
|
82
|
+
if (pattern.startsWith('/'))
|
|
83
|
+
return `/${base.join('/')}`;
|
|
84
|
+
return base.length > 0 ? base.join('/') : '.';
|
|
85
|
+
}
|
|
86
|
+
// brace-expansion 5 leaves the rest of a pattern literal when a non-expanding
|
|
87
|
+
// group such as `{draft}` comes before a range. Expand one real group at a time.
|
|
88
|
+
// NUL-prefixed so no `generates` value can forge them: `resolveArtifactOutputPath`
|
|
89
|
+
// runs `path.join` first, which rejects a NUL byte. A single-character sentinel
|
|
90
|
+
// such as U+0001 is a legal filename character, and a pattern containing one
|
|
91
|
+
// would come back out of the expander as a brace.
|
|
92
|
+
const LITERAL_BRACE_OPEN = '\u0000<';
|
|
93
|
+
const LITERAL_BRACE_CLOSE = '\u0000>';
|
|
94
|
+
function hasTopLevelExpansion(body) {
|
|
95
|
+
let depth = 0;
|
|
96
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
97
|
+
const char = body[index];
|
|
98
|
+
if (char === '{')
|
|
99
|
+
depth += 1;
|
|
100
|
+
else if (char === '}')
|
|
101
|
+
depth = Math.max(0, depth - 1);
|
|
102
|
+
else if (depth === 0) {
|
|
103
|
+
if (char === ',')
|
|
104
|
+
return true;
|
|
105
|
+
if (char === '.' && body[index + 1] === '.')
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
function findTopLevelExpansion(pattern) {
|
|
112
|
+
const stack = [];
|
|
113
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
114
|
+
const char = pattern[index];
|
|
115
|
+
if (char === '{') {
|
|
116
|
+
stack.push(index);
|
|
117
|
+
}
|
|
118
|
+
else if (char === '}' && stack.length > 0) {
|
|
119
|
+
const open = stack.pop();
|
|
120
|
+
if (open === undefined)
|
|
121
|
+
continue;
|
|
122
|
+
// Only the outermost group that actually expands. brace-expansion expands
|
|
123
|
+
// its nested groups in one linear pass. Peeling the innermost `{a,b}` first
|
|
124
|
+
// turns `{a,{a,{a,b}}}` into a binary tree, so a 16-deep pattern does not
|
|
125
|
+
// finish before the test timeout.
|
|
126
|
+
if (stack.length > 0)
|
|
127
|
+
continue;
|
|
128
|
+
const body = pattern.slice(open + 1, index);
|
|
129
|
+
if (!hasTopLevelExpansion(body)) {
|
|
130
|
+
// `{prefix{a,b}}` has no separator of its own, so the outer braces stay
|
|
131
|
+
// literal. Keep scanning inside them; a nested group may still expand.
|
|
132
|
+
const nested = findTopLevelExpansion(body);
|
|
133
|
+
if (!nested)
|
|
134
|
+
continue;
|
|
135
|
+
return {
|
|
136
|
+
pre: `${pattern.slice(0, open + 1)}${nested.pre}`,
|
|
137
|
+
body: nested.body,
|
|
138
|
+
post: `${nested.post}${pattern.slice(index)}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { pre: pattern.slice(0, open), body, post: pattern.slice(index + 1) };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
function assertExpansionBudget(pattern, count) {
|
|
147
|
+
if (count <= MAX_BRACE_EXPANSIONS)
|
|
148
|
+
return;
|
|
149
|
+
// A pattern this wide can run to thousands of characters; quote only its start.
|
|
150
|
+
const shown = pattern.length > 80 ? `${pattern.slice(0, 80)}...` : pattern;
|
|
151
|
+
throw new Error(`Artifact output pattern expands to more than ${MAX_BRACE_EXPANSIONS} patterns: ${shown}`);
|
|
152
|
+
}
|
|
153
|
+
function expandArtifactBraces(pattern) {
|
|
154
|
+
if (pattern.includes('\u0000')) {
|
|
155
|
+
throw new Error('Artifact output pattern contains a NUL byte');
|
|
156
|
+
}
|
|
157
|
+
const pending = [pattern];
|
|
158
|
+
const finished = [];
|
|
159
|
+
while (pending.length > 0) {
|
|
160
|
+
const current = pending.pop();
|
|
161
|
+
if (current === undefined)
|
|
162
|
+
break;
|
|
163
|
+
const group = findTopLevelExpansion(current);
|
|
164
|
+
if (!group) {
|
|
165
|
+
finished.push(current.replaceAll(LITERAL_BRACE_OPEN, '{').replaceAll(LITERAL_BRACE_CLOSE, '}'));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const wrapped = `{${group.body}}`;
|
|
169
|
+
// `max` makes this budget the binding one. Without it `{1..1000000000}`
|
|
170
|
+
// allocates brace-expansion's own 100,000-result default before the check
|
|
171
|
+
// below rejects it. `max` truncates silently, so ask for one result more
|
|
172
|
+
// than the budget allows: a result that long means the pattern is over
|
|
173
|
+
// budget rather than fully expanded. A truncated group never looks literal,
|
|
174
|
+
// because its first alternative is not the group itself.
|
|
175
|
+
const remaining = Math.max(MAX_BRACE_EXPANSIONS - (pending.length + finished.length), 0);
|
|
176
|
+
const alternatives = expand(wrapped, { max: remaining + 1 });
|
|
177
|
+
if (alternatives.length === 1 && alternatives[0] === wrapped) {
|
|
178
|
+
pending.push(`${group.pre}${LITERAL_BRACE_OPEN}${group.body}${LITERAL_BRACE_CLOSE}${group.post}`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
// Checked before the push so the budget bounds memory, not just the result.
|
|
182
|
+
assertExpansionBudget(pattern, pending.length + finished.length + alternatives.length);
|
|
183
|
+
for (const alternative of alternatives) {
|
|
184
|
+
pending.push(`${group.pre}${alternative}${group.post}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const normalized = finished
|
|
188
|
+
.map((value) => value.replace(DUPLICATE_SLASH_RE, '/'))
|
|
189
|
+
.filter((value) => value.length > 0);
|
|
190
|
+
return normalized.length > 0 ? normalized : [pattern];
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* One directory level. picomatch matches the segment; readdir keeps symlink
|
|
194
|
+
* entries tinyglobby would drop when followSymbolicLinks is false.
|
|
195
|
+
*/
|
|
196
|
+
function listDirectoryMatches(directory, segment) {
|
|
197
|
+
let entries;
|
|
198
|
+
try {
|
|
199
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error.code === 'ENOENT')
|
|
203
|
+
return [];
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
const isMatch = picomatch(segment, {
|
|
207
|
+
dot: false,
|
|
208
|
+
nobrace: true,
|
|
209
|
+
noglobstar: true,
|
|
210
|
+
posix: true,
|
|
211
|
+
});
|
|
212
|
+
const names = [];
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (isMatch(entry.name))
|
|
215
|
+
names.push(entry.name);
|
|
216
|
+
}
|
|
217
|
+
return names;
|
|
218
|
+
}
|
|
7
219
|
function hasBraceExpansion(pattern) {
|
|
8
220
|
const openings = [];
|
|
9
221
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
@@ -59,12 +271,16 @@ function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments,
|
|
|
59
271
|
// `**` may consume no directory at all.
|
|
60
272
|
assertGlobDirectoryTraversal(changeDir, canonicalDir, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
|
|
61
273
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
274
|
+
// `.` and `..` are not directory entries. Resolve them directly so a
|
|
275
|
+
// `**/../` pattern is confined before any glob runs.
|
|
276
|
+
if (segment === '' || segment === '.' || segment === '..') {
|
|
277
|
+
const candidate = path.resolve(canonicalDir, segment === '' ? '.' : segment);
|
|
278
|
+
const canonicalCandidate = FileSystemUtils.canonicalizeExistingPath(candidate);
|
|
279
|
+
FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalCandidate);
|
|
280
|
+
assertGlobDirectoryTraversal(changeDir, canonicalCandidate, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const matches = listDirectoryMatches(canonicalDir, segment === '**' ? '*' : segment);
|
|
68
284
|
for (const match of matches) {
|
|
69
285
|
const candidate = path.join(canonicalDir, match);
|
|
70
286
|
try {
|
|
@@ -90,8 +306,11 @@ function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments,
|
|
|
90
306
|
* Returns absolute file paths. Glob matches are sorted for deterministic output.
|
|
91
307
|
*/
|
|
92
308
|
export function resolveArtifactOutputs(changeDir, generates) {
|
|
309
|
+
const isGlob = isGlobPattern(generates);
|
|
310
|
+
if (isGlob)
|
|
311
|
+
assertBraceNesting(FileSystemUtils.toPosixPath(generates));
|
|
93
312
|
const outputPath = resolveArtifactOutputPath(changeDir, generates);
|
|
94
|
-
if (!
|
|
313
|
+
if (!isGlob) {
|
|
95
314
|
try {
|
|
96
315
|
return fs.statSync(outputPath).isFile()
|
|
97
316
|
? [FileSystemUtils.canonicalizeExistingPath(outputPath)]
|
|
@@ -101,27 +320,21 @@ export function resolveArtifactOutputs(changeDir, generates) {
|
|
|
101
320
|
return [];
|
|
102
321
|
}
|
|
103
322
|
}
|
|
104
|
-
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
|
105
|
-
|
|
323
|
+
const normalizedPattern = FileSystemUtils.toPosixPath(generates).replace(DUPLICATE_SLASH_RE, '/');
|
|
324
|
+
// Expand braces before any filesystem access. tinyglobby has no generateTasks,
|
|
325
|
+
// and a `../` alternative is invisible until the braces are expanded.
|
|
326
|
+
const patterns = expandArtifactBraces(normalizedPattern);
|
|
327
|
+
for (const pattern of patterns) {
|
|
328
|
+
FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, globBase(pattern)));
|
|
329
|
+
}
|
|
330
|
+
for (const pattern of patterns) {
|
|
331
|
+
assertGlobDirectoryTraversal(changeDir, changeDir, pattern.split('/').slice(0, -1));
|
|
332
|
+
}
|
|
333
|
+
const matches = globby
|
|
334
|
+
.globSync(patterns, {
|
|
106
335
|
cwd: changeDir,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// Preserve linked artifact directories; confine traversal and concrete matches.
|
|
110
|
-
followSymbolicLinks: true,
|
|
111
|
-
};
|
|
112
|
-
// Task generation expands braces without accessing the filesystem. Validate
|
|
113
|
-
// every task base before globbing, including paths introduced by expansion.
|
|
114
|
-
const tasks = fg.generateTasks(normalizedPattern, globOptions);
|
|
115
|
-
for (const task of tasks) {
|
|
116
|
-
FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, task.base));
|
|
117
|
-
}
|
|
118
|
-
for (const task of tasks) {
|
|
119
|
-
for (const positivePattern of task.positive) {
|
|
120
|
-
assertGlobDirectoryTraversal(changeDir, changeDir, positivePattern.split('/').slice(0, -1));
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
const matches = fg
|
|
124
|
-
.sync(normalizedPattern, globOptions)
|
|
336
|
+
...ARTIFACT_GLOB_OPTIONS,
|
|
337
|
+
})
|
|
125
338
|
.map((match) => {
|
|
126
339
|
const normalizedMatch = path.normalize(match);
|
|
127
340
|
FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
|
|
@@ -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 tinyglobby.
|
|
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. */
|