@fission-ai/openspec 1.14.2 → 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/dist/commands/change.js +2 -1
- package/dist/commands/spec.js +2 -10
- package/dist/commands/validate.js +3 -3
- package/dist/core/artifact-graph/outputs.js +204 -29
- package/dist/core/completions/completion-provider.js +1 -1
- package/dist/core/specs-apply.js +23 -5
- package/dist/core/templates/workflows/continue-change.js +18 -12
- package/dist/core/templates/workflows/explore.js +2 -2
- package/dist/core/templates/workflows/ff-change.js +8 -6
- package/dist/core/templates/workflows/propose.js +8 -6
- package/dist/core/validation/validator.js +3 -3
- package/dist/utils/file-system.d.ts +1 -1
- package/dist/utils/file-system.js +1 -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/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) {
|
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
|
|
@@ -1,12 +1,29 @@
|
|
|
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;
|
|
7
|
-
|
|
8
|
-
//
|
|
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.
|
|
9
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
|
+
};
|
|
10
27
|
function assertBraceNesting(pattern) {
|
|
11
28
|
// Mirror the braces parser: `[...]` and quoted runs are literal, `(` nests like
|
|
12
29
|
// `{`, and a closer only closes the innermost group of its own kind.
|
|
@@ -39,6 +56,166 @@ function assertBraceNesting(pattern) {
|
|
|
39
56
|
}
|
|
40
57
|
}
|
|
41
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
|
+
}
|
|
42
219
|
function hasBraceExpansion(pattern) {
|
|
43
220
|
const openings = [];
|
|
44
221
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
@@ -94,12 +271,16 @@ function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments,
|
|
|
94
271
|
// `**` may consume no directory at all.
|
|
95
272
|
assertGlobDirectoryTraversal(changeDir, canonicalDir, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
|
|
96
273
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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);
|
|
103
284
|
for (const match of matches) {
|
|
104
285
|
const candidate = path.join(canonicalDir, match);
|
|
105
286
|
try {
|
|
@@ -139,27 +320,21 @@ export function resolveArtifactOutputs(changeDir, generates) {
|
|
|
139
320
|
return [];
|
|
140
321
|
}
|
|
141
322
|
}
|
|
142
|
-
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
|
143
|
-
|
|
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, {
|
|
144
335
|
cwd: changeDir,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// Preserve linked artifact directories; confine traversal and concrete matches.
|
|
148
|
-
followSymbolicLinks: true,
|
|
149
|
-
};
|
|
150
|
-
// Task generation expands braces without accessing the filesystem. Validate
|
|
151
|
-
// every task base before globbing, including paths introduced by expansion.
|
|
152
|
-
const tasks = fg.generateTasks(normalizedPattern, globOptions);
|
|
153
|
-
for (const task of tasks) {
|
|
154
|
-
FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, task.base));
|
|
155
|
-
}
|
|
156
|
-
for (const task of tasks) {
|
|
157
|
-
for (const positivePattern of task.positive) {
|
|
158
|
-
assertGlobDirectoryTraversal(changeDir, changeDir, positivePattern.split('/').slice(0, -1));
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const matches = fg
|
|
162
|
-
.sync(normalizedPattern, globOptions)
|
|
336
|
+
...ARTIFACT_GLOB_OPTIONS,
|
|
337
|
+
})
|
|
163
338
|
.map((match) => {
|
|
164
339
|
const normalizedMatch = path.normalize(match);
|
|
165
340
|
FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
|
|
@@ -75,7 +75,7 @@ export class CompletionProvider {
|
|
|
75
75
|
}
|
|
76
76
|
// Fetch fresh data. The resolver (and the yaml and zod it parses schemas
|
|
77
77
|
// with) loads only here: completing changes or specs never needs it, and
|
|
78
|
-
// the artifact-graph barrel would also pull in
|
|
78
|
+
// the artifact-graph barrel would also pull in tinyglobby.
|
|
79
79
|
const { listSchemas } = await import('../artifact-graph/resolver.js');
|
|
80
80
|
const schemaNames = listSchemas(this.projectRoot);
|
|
81
81
|
// Update cache
|
package/dist/core/specs-apply.js
CHANGED
|
@@ -15,6 +15,7 @@ import { MarkdownParser } from './parsers/markdown-parser.js';
|
|
|
15
15
|
import { MIN_PURPOSE_LENGTH, PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from './validation/constants.js';
|
|
16
16
|
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
|
17
17
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
18
|
+
import { assertSpecFileWithin } from '../utils/spec-path-containment.js';
|
|
18
19
|
import { matchLineEnding } from '../utils/line-endings.js';
|
|
19
20
|
function isLexicallyWithin(allowedDirectory, targetPath) {
|
|
20
21
|
const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
|
|
@@ -29,7 +30,7 @@ function resolveTrustedSpecPath(specsRoot, specPath, projectRoot) {
|
|
|
29
30
|
}
|
|
30
31
|
try {
|
|
31
32
|
// Preserve spec.md links that remain inside the overall specs tree.
|
|
32
|
-
|
|
33
|
+
assertSpecFileWithin(specsRoot, specPath);
|
|
33
34
|
const root = FileSystemUtils.canonicalizeExistingPath(specsRoot);
|
|
34
35
|
return {
|
|
35
36
|
root,
|
|
@@ -52,7 +53,7 @@ function resolveTrustedSpecPath(specsRoot, specPath, projectRoot) {
|
|
|
52
53
|
process.emitWarning(`Capability '${path.basename(path.dirname(specPath))}' links outside the project; writing to ${root}`, 'OpenSpecExternalSpecWrite');
|
|
53
54
|
}
|
|
54
55
|
const file = path.join(root, path.basename(specPath));
|
|
55
|
-
|
|
56
|
+
assertSpecFileWithin(root, file);
|
|
56
57
|
return { root, file };
|
|
57
58
|
}
|
|
58
59
|
}
|
|
@@ -60,7 +61,7 @@ function assertTrustedSpecPath(root, specPath) {
|
|
|
60
61
|
if (FileSystemUtils.canonicalizeExistingPath(root) !== path.resolve(root)) {
|
|
61
62
|
throw new Error(`Path is outside the allowed directory: ${specPath}`);
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
assertSpecFileWithin(root, specPath);
|
|
64
65
|
}
|
|
65
66
|
// -----------------------------------------------------------------------------
|
|
66
67
|
// Public API
|
|
@@ -1042,6 +1043,14 @@ export async function pruneEmptyDirs(startDir, boundaryDir) {
|
|
|
1042
1043
|
*/
|
|
1043
1044
|
export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
|
|
1044
1045
|
assertTrustedSpecPath(update.targetRoot, update.target);
|
|
1046
|
+
// Pin what the assertion just checked. A capability's spec.md may be a link
|
|
1047
|
+
// to the file the project keeps elsewhere, so the write follows a link by
|
|
1048
|
+
// design - and resolving it once, here, is what keeps that from becoming a
|
|
1049
|
+
// redirect: a link repointed after this line, including from inside
|
|
1050
|
+
// `beforeMutate`, cannot send the write to a file nothing verified. The
|
|
1051
|
+
// bytes still land in the same place a path-based write would have put them,
|
|
1052
|
+
// which keeps the in-place semantics below intact.
|
|
1053
|
+
const verifiedTarget = FileSystemUtils.canonicalizeExistingPath(update.target);
|
|
1045
1054
|
// Create target directory if needed
|
|
1046
1055
|
const targetDir = path.dirname(update.target);
|
|
1047
1056
|
await fs.mkdir(targetDir, { recursive: true });
|
|
@@ -1051,17 +1060,26 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
|
|
|
1051
1060
|
// requirement moved. A spec that does not exist yet stays LF.
|
|
1052
1061
|
// Only a missing file means "no convention to match". Swallowing every error
|
|
1053
1062
|
// would read an existing but unreadable spec as absent and rewrite it as LF.
|
|
1054
|
-
const previous = await fs.readFile(
|
|
1063
|
+
const previous = await fs.readFile(verifiedTarget, 'utf-8').catch((error) => {
|
|
1055
1064
|
if (error.code === 'ENOENT')
|
|
1056
1065
|
return undefined;
|
|
1057
1066
|
throw error;
|
|
1058
1067
|
});
|
|
1059
1068
|
const toWrite = previous === undefined ? rebuilt : matchLineEnding(rebuilt, previous);
|
|
1060
1069
|
await options.beforeMutate?.();
|
|
1070
|
+
// `beforeMutate` awaits arbitrary work, so it is the widest gap between the
|
|
1071
|
+
// check above and the write below. Re-check there, and refuse unless the
|
|
1072
|
+
// target still resolves to the very file that was verified: pinning a
|
|
1073
|
+
// pathname is not enough on its own, because the pinned path can itself be
|
|
1074
|
+
// replaced by a link to somewhere else.
|
|
1075
|
+
assertTrustedSpecPath(update.targetRoot, update.target);
|
|
1076
|
+
if (FileSystemUtils.canonicalizeExistingPath(update.target) !== verifiedTarget) {
|
|
1077
|
+
throw new Error(`Spec target for '${update.id}' changed before it could be written: ${update.target}`);
|
|
1078
|
+
}
|
|
1061
1079
|
// Preserve the established in-place write semantics: symlink referents,
|
|
1062
1080
|
// hard-linked specs, ACLs, extended attributes, and filesystems without hard
|
|
1063
1081
|
// links must continue to behave as they did before capability retirement.
|
|
1064
|
-
await fs.writeFile(
|
|
1082
|
+
await fs.writeFile(verifiedTarget, toWrite);
|
|
1065
1083
|
if (options.silent)
|
|
1066
1084
|
return;
|
|
1067
1085
|
const specName = update.id;
|
|
@@ -14,8 +14,8 @@ const PLANNING_COMPLETE_HANDOFF = optionalWorkflow('apply', 'You can now impleme
|
|
|
14
14
|
*/
|
|
15
15
|
const PARTIAL_ARTIFACT_CHECK = `**Before anything else, finish a partly written artifact**:
|
|
16
16
|
- An artifact whose output is a glob pattern (e.g. \`specs/**/*.md\`) reads \`done\` as soon as one matching file exists, so a run interrupted while writing its files leaves it \`done\` with some still missing
|
|
17
|
-
- For each such \`done\` artifact, first get its instructions and read any completed dependencies needed to determine the expected files. Then compare those files with \`artifactPaths.<id>.existingOutputPaths\`. For spec-driven's \`specs\`, read the proposal and check for one \`specs/<capability-path>/spec.md\` per capability it lists; resolve each expected path against \`changeRoot\` before comparing path identity
|
|
18
|
-
- If any are missing, write only the missing files, and STOP - that counts as this invocation's ONE artifact. If you cannot tell whether a file was left out on purpose, ask the user`;
|
|
17
|
+
- For each such \`done\` artifact, first get its instructions and read any completed dependencies needed to determine the expected files. Then compare those files with \`artifactPaths.<id>.existingOutputPaths\`. For spec-driven's \`specs\`, read the proposal and check for one \`specs/<capability-path>/spec.md\` per capability it lists; resolve each expected path against \`changeRoot\` before comparing path identity, and verify each one remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists
|
|
18
|
+
- Never write an expected path that fails that check; stop and tell the user. If any are missing, write only the missing files, each at its checked path - when the \`instruction\` field delegates creation to a skill or command, invoke it only if it can write each file to its checked path; otherwise stop and tell the user - and STOP - that counts as this invocation's ONE artifact. If you cannot tell whether a file was left out on purpose, ask the user`;
|
|
19
19
|
export function getContinueChangeSkillTemplate() {
|
|
20
20
|
return {
|
|
21
21
|
name: 'openspec-continue-change',
|
|
@@ -88,10 +88,13 @@ ${PROJECT_ROOT_GUARD}
|
|
|
88
88
|
- \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact
|
|
89
89
|
- **Create the artifact file**:
|
|
90
90
|
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
|
|
91
|
-
- If the \`
|
|
92
|
-
-
|
|
93
|
-
-
|
|
94
|
-
|
|
91
|
+
- Choose the output path before creating anything. If the \`resolvedOutputPath\` specified in instructions is a glob pattern, choose a concrete path inside \`changeRoot\` using the schema instruction and the change's context, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
|
92
|
+
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user
|
|
93
|
+
- Otherwise, write it yourself:
|
|
94
|
+
- Use \`template\` as the structure - fill in its sections
|
|
95
|
+
- Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file
|
|
96
|
+
- Write the file to that output path
|
|
97
|
+
- Either way, verify the artifact file exists at that output path
|
|
95
98
|
- Show what was created and what's now unlocked
|
|
96
99
|
- STOP after creating ONE artifact
|
|
97
100
|
|
|
@@ -119,7 +122,7 @@ After each invocation, show:
|
|
|
119
122
|
|
|
120
123
|
The artifact types and their purpose depend on the schema. The \`instruction\` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names.
|
|
121
124
|
|
|
122
|
-
If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly.
|
|
125
|
+
If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there.
|
|
123
126
|
|
|
124
127
|
**Guardrails**
|
|
125
128
|
- Create ONE artifact per invocation
|
|
@@ -210,10 +213,13 @@ ${PROJECT_ROOT_GUARD}
|
|
|
210
213
|
- \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact
|
|
211
214
|
- **Create the artifact file**:
|
|
212
215
|
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
|
|
213
|
-
- If the \`
|
|
214
|
-
-
|
|
215
|
-
-
|
|
216
|
-
|
|
216
|
+
- Choose the output path before creating anything. If the \`resolvedOutputPath\` specified in instructions is a glob pattern, choose a concrete path inside \`changeRoot\` using the schema instruction and the change's context, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
|
217
|
+
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user
|
|
218
|
+
- Otherwise, write it yourself:
|
|
219
|
+
- Use \`template\` as the structure - fill in its sections
|
|
220
|
+
- Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file
|
|
221
|
+
- Write the file to that output path
|
|
222
|
+
- Either way, verify the artifact file exists at that output path
|
|
217
223
|
- Show what was created and what's now unlocked
|
|
218
224
|
- STOP after creating ONE artifact
|
|
219
225
|
|
|
@@ -241,7 +247,7 @@ After each invocation, show:
|
|
|
241
247
|
|
|
242
248
|
The artifact types and their purpose depend on the schema. The \`instruction\` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names.
|
|
243
249
|
|
|
244
|
-
If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly.
|
|
250
|
+
If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there.
|
|
245
251
|
|
|
246
252
|
**Guardrails**
|
|
247
253
|
- Create ONE artifact per invocation
|
|
@@ -156,7 +156,7 @@ If the user asks you to capture the exploration as a new change, that request is
|
|
|
156
156
|
|
|
157
157
|
1. Run \`openspec new change "<name>"\` (with \`--store <id>\` when applicable) before creating any artifacts. Never create a new change directory under \`openspec/changes/\` by hand; the CLI scaffold creates required metadata such as \`.openspec.yaml\`. Keep the selected \`--store <id>\` on every applicable follow-up \`status\` and \`instructions\` command.
|
|
158
158
|
2. Run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is \`ready\`, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own \`instruction\` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run \`openspec instructions "<prerequisite-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) for that prerequisite whether it is \`ready\` or \`blocked\`. If its own \`instruction\` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
|
|
159
|
-
3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it
|
|
159
|
+
3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. Choose the output path before creating anything: when \`resolvedOutputPath\` is a glob, use the instruction to choose a concrete path inside \`changeRoot\` and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
|
|
160
160
|
4. After creating each artifact, re-run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) and continue until every requested artifact is \`done\`, \`skipped\`, or was deliberately skipped because its own \`instruction\` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still \`blocked\` only because you deliberately skipped a conditional prerequisite, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
|
|
161
161
|
|
|
162
162
|
Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: ${CAPTURE_PLANNING_HANDOFF}, and ${CAPTURE_APPLY_HANDOFF}. Capturing artifacts never starts implementing them.
|
|
@@ -491,7 +491,7 @@ If the user asks you to capture the exploration as a new change, that request is
|
|
|
491
491
|
|
|
492
492
|
1. Run \`openspec new change "<name>"\` (with \`--store <id>\` when applicable) before creating any artifacts. Never create a new change directory under \`openspec/changes/\` by hand; the CLI scaffold creates required metadata such as \`.openspec.yaml\`. Keep the selected \`--store <id>\` on every applicable follow-up \`status\` and \`instructions\` command.
|
|
493
493
|
2. Run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is \`ready\`, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own \`instruction\` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run \`openspec instructions "<prerequisite-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) for that prerequisite whether it is \`ready\` or \`blocked\`. If its own \`instruction\` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
|
|
494
|
-
3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it
|
|
494
|
+
3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. Choose the output path before creating anything: when \`resolvedOutputPath\` is a glob, use the instruction to choose a concrete path inside \`changeRoot\` and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
|
|
495
495
|
4. After creating each artifact, re-run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) and continue until every requested artifact is \`done\`, \`skipped\`, or was deliberately skipped because its own \`instruction\` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still \`blocked\` only because you deliberately skipped a conditional prerequisite, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
|
|
496
496
|
|
|
497
497
|
Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: ${CAPTURE_PLANNING_HANDOFF}, and ${CAPTURE_APPLY_HANDOFF}. Capturing artifacts never starts implementing them.
|
|
@@ -73,8 +73,9 @@ ${PROJECT_ROOT_GUARD}
|
|
|
73
73
|
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
|
74
74
|
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
|
75
75
|
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
|
76
|
-
-
|
|
77
|
-
-
|
|
76
|
+
- Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
|
77
|
+
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
|
78
|
+
- Otherwise create the artifact file using \`template\` as the structure and write it to that output path
|
|
78
79
|
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
|
|
79
80
|
- Show brief progress: "✓ Created <artifact-id>"
|
|
80
81
|
|
|
@@ -108,7 +109,7 @@ After completing all artifacts, summarize:
|
|
|
108
109
|
**Artifact Creation Guidelines**
|
|
109
110
|
|
|
110
111
|
- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
|
111
|
-
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
|
|
112
|
+
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
|
112
113
|
- The schema defines what each artifact should contain - follow it
|
|
113
114
|
- Read dependency artifacts for context before creating new ones
|
|
114
115
|
- Use \`template\` as the structure for your output file - fill in its sections
|
|
@@ -191,8 +192,9 @@ ${PROJECT_ROOT_GUARD}
|
|
|
191
192
|
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
|
192
193
|
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
|
193
194
|
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
|
194
|
-
-
|
|
195
|
-
-
|
|
195
|
+
- Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
|
196
|
+
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
|
197
|
+
- Otherwise create the artifact file using \`template\` as the structure and write it to that output path
|
|
196
198
|
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
|
|
197
199
|
- Show brief progress: "✓ Created <artifact-id>"
|
|
198
200
|
|
|
@@ -226,7 +228,7 @@ After completing all artifacts, summarize:
|
|
|
226
228
|
**Artifact Creation Guidelines**
|
|
227
229
|
|
|
228
230
|
- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
|
229
|
-
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
|
|
231
|
+
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
|
230
232
|
- The schema defines what each artifact should contain - follow it
|
|
231
233
|
- Read dependency artifacts for context before creating new ones
|
|
232
234
|
- Use \`template\` as the structure for your output file - fill in its sections
|
|
@@ -119,8 +119,9 @@ ${PROJECT_ROOT_GUARD}
|
|
|
119
119
|
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
|
120
120
|
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
|
121
121
|
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
|
122
|
-
-
|
|
123
|
-
-
|
|
122
|
+
- Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
|
123
|
+
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
|
124
|
+
- Otherwise create the artifact file using \`template\` as the structure and write it to that output path
|
|
124
125
|
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
|
|
125
126
|
- Show brief progress: "Created <artifact-id>"
|
|
126
127
|
|
|
@@ -154,7 +155,7 @@ After completing all artifacts, summarize:
|
|
|
154
155
|
**Artifact Creation Guidelines**
|
|
155
156
|
|
|
156
157
|
- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
|
157
|
-
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
|
|
158
|
+
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
|
158
159
|
- The schema defines what each artifact should contain - follow it
|
|
159
160
|
- Read dependency artifacts for context before creating new ones
|
|
160
161
|
- Use \`template\` as the structure for your output file - fill in its sections
|
|
@@ -283,8 +284,9 @@ ${PROJECT_ROOT_GUARD}
|
|
|
283
284
|
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
|
284
285
|
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
|
285
286
|
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
|
286
|
-
-
|
|
287
|
-
-
|
|
287
|
+
- Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
|
288
|
+
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
|
289
|
+
- Otherwise create the artifact file using \`template\` as the structure and write it to that output path
|
|
288
290
|
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
|
|
289
291
|
- Show brief progress: "Created <artifact-id>"
|
|
290
292
|
|
|
@@ -318,7 +320,7 @@ After completing all artifacts, summarize:
|
|
|
318
320
|
**Artifact Creation Guidelines**
|
|
319
321
|
|
|
320
322
|
- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
|
321
|
-
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
|
|
323
|
+
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
|
322
324
|
- The schema defines what each artifact should contain - follow it
|
|
323
325
|
- Read dependency artifacts for context before creating new ones
|
|
324
326
|
- Use \`template\` as the structure for your output file - fill in its sections
|
|
@@ -9,6 +9,7 @@ import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractR
|
|
|
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';
|
|
12
|
+
import { assertDiscoveredSpecFile } from '../../utils/spec-path-containment.js';
|
|
12
13
|
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
|
|
13
14
|
import { METADATA_FILENAME, formatUnknownChangeMetadataKeysMessage, readSkipSpecsMarker, readUnknownChangeMetadataKeys, resolveSchemaForChange, } from '../../utils/change-metadata.js';
|
|
14
15
|
import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
|
|
@@ -314,8 +315,7 @@ export class Validator {
|
|
|
314
315
|
// authoring time instead of days later at archive time (#1477).
|
|
315
316
|
if (options.mainSpecsDir && plan.modified.length > 0) {
|
|
316
317
|
const mainSpecFile = path.join(options.mainSpecsDir, ...specId.split('/'), 'spec.md');
|
|
317
|
-
|
|
318
|
-
issues.push(...(await this.findScenarioLossIssues(plan.modified, plan.renamed, mainSpecFile, entryPath, path.dirname(mainSpecFile))));
|
|
318
|
+
issues.push(...(await this.findScenarioLossIssues(plan.modified, plan.renamed, mainSpecFile, entryPath, options.mainSpecsDir)));
|
|
319
319
|
}
|
|
320
320
|
// Validate REMOVED (names only)
|
|
321
321
|
for (const name of plan.removed) {
|
|
@@ -606,7 +606,7 @@ export class Validator {
|
|
|
606
606
|
*/
|
|
607
607
|
async findScenarioLossIssues(modified, renamed, mainSpecFile, entryPath, mainSpecRoot) {
|
|
608
608
|
let mainContent;
|
|
609
|
-
|
|
609
|
+
assertDiscoveredSpecFile(mainSpecRoot, mainSpecFile);
|
|
610
610
|
try {
|
|
611
611
|
mainContent = await fs.readFile(mainSpecFile, 'utf-8');
|
|
612
612
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare class FileSystemUtils {
|
|
2
2
|
/**
|
|
3
3
|
* Converts a path to use forward slashes (POSIX style).
|
|
4
|
-
* Essential for cross-platform compatibility with glob libraries like
|
|
4
|
+
* Essential for cross-platform compatibility with glob libraries like tinyglobby.
|
|
5
5
|
*/
|
|
6
6
|
static toPosixPath(p: string): string;
|
|
7
7
|
/**
|
|
@@ -65,7 +65,7 @@ function findMarkerIndex(content, marker, fromIndex = 0) {
|
|
|
65
65
|
export class FileSystemUtils {
|
|
66
66
|
/**
|
|
67
67
|
* Converts a path to use forward slashes (POSIX style).
|
|
68
|
-
* Essential for cross-platform compatibility with glob libraries like
|
|
68
|
+
* Essential for cross-platform compatibility with glob libraries like tinyglobby.
|
|
69
69
|
*/
|
|
70
70
|
static toPosixPath(p) {
|
|
71
71
|
return p.replace(/\\/g, '/');
|
|
@@ -67,4 +67,19 @@ export declare function confirmPrompt(prompt: ConfirmPrompt, io?: {
|
|
|
67
67
|
input?: Readable;
|
|
68
68
|
output?: Writable;
|
|
69
69
|
}): Promise<boolean>;
|
|
70
|
+
/**
|
|
71
|
+
* Whether a spinner can safely render to `stream`.
|
|
72
|
+
*
|
|
73
|
+
* ora decides how many lines to erase with `ceil(width / stream.columns)`, so a
|
|
74
|
+
* TTY that reports zero columns makes that count `Infinity` and `clear()` never
|
|
75
|
+
* ends: the spinner emits cursor-up/clear-line escapes as fast as it can and the
|
|
76
|
+
* command never finishes (#2106). Zero-width TTYs are real — a pty inherited
|
|
77
|
+
* from a pre-commit runner such as lefthook, or `script -qec` — and nothing can
|
|
78
|
+
* be drawn in zero columns anyway, so refuse the spinner and let the caller fall
|
|
79
|
+
* back to its plain output.
|
|
80
|
+
*/
|
|
81
|
+
export declare function canRenderSpinner(stream?: {
|
|
82
|
+
isTTY?: boolean;
|
|
83
|
+
columns?: number;
|
|
84
|
+
}): boolean;
|
|
70
85
|
//# sourceMappingURL=interactive.d.ts.map
|
|
@@ -153,4 +153,22 @@ function readYesNo(prompt, input, output) {
|
|
|
153
153
|
});
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Whether a spinner can safely render to `stream`.
|
|
158
|
+
*
|
|
159
|
+
* ora decides how many lines to erase with `ceil(width / stream.columns)`, so a
|
|
160
|
+
* TTY that reports zero columns makes that count `Infinity` and `clear()` never
|
|
161
|
+
* ends: the spinner emits cursor-up/clear-line escapes as fast as it can and the
|
|
162
|
+
* command never finishes (#2106). Zero-width TTYs are real — a pty inherited
|
|
163
|
+
* from a pre-commit runner such as lefthook, or `script -qec` — and nothing can
|
|
164
|
+
* be drawn in zero columns anyway, so refuse the spinner and let the caller fall
|
|
165
|
+
* back to its plain output.
|
|
166
|
+
*/
|
|
167
|
+
export function canRenderSpinner(stream = process.stderr) {
|
|
168
|
+
// A non-TTY stream never enters the clear loop: ora disables itself and
|
|
169
|
+
// prints one plain line instead.
|
|
170
|
+
if (!stream.isTTY)
|
|
171
|
+
return true;
|
|
172
|
+
return (stream.columns ?? 0) > 0;
|
|
173
|
+
}
|
|
156
174
|
//# sourceMappingURL=interactive.js.map
|
|
@@ -10,10 +10,10 @@ export interface DiscoveredSpec {
|
|
|
10
10
|
* are found (#1353). A `spec.md` sitting directly in the root is ignored,
|
|
11
11
|
* matching the historical requirement that specs live in a capability folder.
|
|
12
12
|
* Dot-directories are skipped and symlinked directories are not followed.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
|
|
14
|
+
* graph's globs both count it as content, so dropping it here would silently
|
|
15
|
+
* lose the delta on archive. `assertSpecFilePathAllowed` decides how far such
|
|
16
|
+
* a link may point; a dangling link is skipped. Results are sorted by id.
|
|
17
17
|
*
|
|
18
18
|
* A missing root (ENOENT) yields an empty list, but any other read failure
|
|
19
19
|
* (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
|
|
@@ -1,27 +1,17 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { assertDiscoveredSpecFile } from './spec-path-containment.js';
|
|
4
4
|
import { parseDeltaSpec } from '../core/parsers/requirement-blocks.js';
|
|
5
|
-
function assertDiscoveredSpecPath(specsRoot, capabilityDir, specFile) {
|
|
6
|
-
try {
|
|
7
|
-
FileSystemUtils.assertPathWithin(specsRoot, specFile);
|
|
8
|
-
}
|
|
9
|
-
catch {
|
|
10
|
-
// Direct capability directories may intentionally be external monorepo
|
|
11
|
-
// links. In that case, confine the file to the capability itself.
|
|
12
|
-
FileSystemUtils.assertPathWithin(capabilityDir, specFile);
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
5
|
/**
|
|
16
6
|
* Recursively discover every `spec.md` under a specs root, so both the flat
|
|
17
7
|
* `specs/<id>/spec.md` layout and nested `specs/<area>/<id>/spec.md` layouts
|
|
18
8
|
* are found (#1353). A `spec.md` sitting directly in the root is ignored,
|
|
19
9
|
* matching the historical requirement that specs live in a capability folder.
|
|
20
10
|
* Dot-directories are skipped and symlinked directories are not followed.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
11
|
+
* A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
|
|
12
|
+
* graph's globs both count it as content, so dropping it here would silently
|
|
13
|
+
* lose the delta on archive. `assertSpecFilePathAllowed` decides how far such
|
|
14
|
+
* a link may point; a dangling link is skipped. Results are sorted by id.
|
|
25
15
|
*
|
|
26
16
|
* A missing root (ENOENT) yields an empty list, but any other read failure
|
|
27
17
|
* (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
|
|
@@ -49,13 +39,13 @@ export async function discoverSpecFiles(specsRoot) {
|
|
|
49
39
|
else if (entry.name === 'spec.md' && segments.length > 0) {
|
|
50
40
|
const specFile = path.join(dir, entry.name);
|
|
51
41
|
if (entry.isFile()) {
|
|
52
|
-
|
|
42
|
+
assertDiscoveredSpecFile(specsRoot, specFile);
|
|
53
43
|
results.push({ id: segments.join('/'), specFile });
|
|
54
44
|
}
|
|
55
45
|
else if (entry.isSymbolicLink()) {
|
|
56
46
|
try {
|
|
57
47
|
if ((await fs.stat(specFile)).isFile()) {
|
|
58
|
-
|
|
48
|
+
assertDiscoveredSpecFile(specsRoot, specFile);
|
|
59
49
|
results.push({ id: segments.join('/'), specFile });
|
|
60
50
|
}
|
|
61
51
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Containment for a `spec.md` under a trust root already established for it:
|
|
3
|
+
* inside the root, or a link out of the root that stays inside the project.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately does not re-derive a trust root from the file's own capability
|
|
6
|
+
* directory. Callers use this to recheck a path immediately before reading or
|
|
7
|
+
* writing it, and a capability directory swapped for a link since the path was
|
|
8
|
+
* resolved has to be refused rather than trusted afresh.
|
|
9
|
+
*/
|
|
10
|
+
export declare function assertSpecFileWithin(root: string, specFile: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Containment for a `spec.md` as it is discovered, where the capability
|
|
13
|
+
* directory itself may deliberately be a link into another repository and is
|
|
14
|
+
* then the file's own trust root.
|
|
15
|
+
*/
|
|
16
|
+
export declare function assertDiscoveredSpecFile(specsRoot: string, specFile: string): void;
|
|
17
|
+
//# sourceMappingURL=spec-path-containment.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as nodeFs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { OPENSPEC_DIR_NAME } from '../core/config.js';
|
|
4
|
+
import { FileSystemUtils } from './file-system.js';
|
|
5
|
+
/**
|
|
6
|
+
* The project a specs root belongs to, or null when it cannot be named.
|
|
7
|
+
*
|
|
8
|
+
* Every specs root OpenSpec reads is addressed through the project's
|
|
9
|
+
* `openspec` directory - `<project>/openspec/specs` for main specs and
|
|
10
|
+
* `<project>/openspec/changes/<change>/specs` (or `.../changes/archive/...`)
|
|
11
|
+
* for a change's deltas - so the project is the parent of that `openspec`
|
|
12
|
+
* directory.
|
|
13
|
+
*
|
|
14
|
+
* The directory below the match has to be `specs` or `changes`, which is what
|
|
15
|
+
* makes this a planning root rather than a directory that merely shares the
|
|
16
|
+
* name: a store kept at `~/openspec/my-store` would otherwise hand out `~` as
|
|
17
|
+
* its boundary. A specs root reached some other way (a store given by path)
|
|
18
|
+
* has no project to name, and containment stays at the specs root.
|
|
19
|
+
*/
|
|
20
|
+
function findProjectRoot(specsRoot) {
|
|
21
|
+
let current = path.resolve(specsRoot);
|
|
22
|
+
let below = null;
|
|
23
|
+
while (true) {
|
|
24
|
+
const parent = path.dirname(current);
|
|
25
|
+
if (parent === current) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (path.basename(current) === OPENSPEC_DIR_NAME &&
|
|
29
|
+
(below === 'specs' || below === 'changes')) {
|
|
30
|
+
return parent;
|
|
31
|
+
}
|
|
32
|
+
below = path.basename(current);
|
|
33
|
+
current = parent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Path-segment containment on the paths as written, without resolving links. */
|
|
37
|
+
function isLexicallyWithin(allowedDirectory, targetPath) {
|
|
38
|
+
const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
|
|
39
|
+
return (relative === '' ||
|
|
40
|
+
(relative !== '..' &&
|
|
41
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
42
|
+
!path.isAbsolute(relative)));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A project may keep a capability's spec file elsewhere - beside the product
|
|
46
|
+
* documentation it belongs to - and leave a relative symlink at
|
|
47
|
+
* `openspec/specs/<capability>/spec.md`, so OpenSpec and the docs read and
|
|
48
|
+
* write the same bytes. Accept such a link, and refuse one that leaves the
|
|
49
|
+
* project, so a link swapped underneath a repo cannot turn a spec read or an
|
|
50
|
+
* archive write into an arbitrary file access.
|
|
51
|
+
*
|
|
52
|
+
* `outsideError` is rethrown when the file is not a link at all, so a path
|
|
53
|
+
* that simply escapes its root keeps reporting that.
|
|
54
|
+
*/
|
|
55
|
+
function assertLinkedSpecFileInProject(root, specFile, outsideError) {
|
|
56
|
+
const projectRoot = findProjectRoot(root);
|
|
57
|
+
if (projectRoot === null) {
|
|
58
|
+
throw outsideError;
|
|
59
|
+
}
|
|
60
|
+
const resolvedFile = path.resolve(specFile);
|
|
61
|
+
let isLink = false;
|
|
62
|
+
try {
|
|
63
|
+
isLink = nodeFs.lstatSync(resolvedFile).isSymbolicLink();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
isLink = false;
|
|
67
|
+
}
|
|
68
|
+
if (!isLink) {
|
|
69
|
+
throw outsideError;
|
|
70
|
+
}
|
|
71
|
+
// Only where the link points is the project's choice. The directory holding
|
|
72
|
+
// it must still be reached from inside the root.
|
|
73
|
+
FileSystemUtils.assertPathWithin(root, path.dirname(resolvedFile));
|
|
74
|
+
// Canonicalizes through the link, so a target outside the project is refused
|
|
75
|
+
// and a dangling link - which cannot be proven contained - fails loudly.
|
|
76
|
+
FileSystemUtils.assertPathWithin(projectRoot, resolvedFile);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Containment for a `spec.md` under a trust root already established for it:
|
|
80
|
+
* inside the root, or a link out of the root that stays inside the project.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately does not re-derive a trust root from the file's own capability
|
|
83
|
+
* directory. Callers use this to recheck a path immediately before reading or
|
|
84
|
+
* writing it, and a capability directory swapped for a link since the path was
|
|
85
|
+
* resolved has to be refused rather than trusted afresh.
|
|
86
|
+
*/
|
|
87
|
+
export function assertSpecFileWithin(root, specFile) {
|
|
88
|
+
try {
|
|
89
|
+
FileSystemUtils.assertPathWithin(root, specFile);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
assertLinkedSpecFileInProject(root, specFile, error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Containment for a `spec.md` as it is discovered, where the capability
|
|
97
|
+
* directory itself may deliberately be a link into another repository and is
|
|
98
|
+
* then the file's own trust root.
|
|
99
|
+
*/
|
|
100
|
+
export function assertDiscoveredSpecFile(specsRoot, specFile) {
|
|
101
|
+
const capabilityDir = path.dirname(path.resolve(specFile));
|
|
102
|
+
try {
|
|
103
|
+
FileSystemUtils.assertPathWithin(specsRoot, specFile);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
// The capability directory is the file's own trust root only while it is
|
|
108
|
+
// addressed from inside the specs root: the link it may be is deliberate,
|
|
109
|
+
// a `..` out of the root is not.
|
|
110
|
+
if (isLexicallyWithin(specsRoot, capabilityDir)) {
|
|
111
|
+
try {
|
|
112
|
+
FileSystemUtils.assertPathWithin(capabilityDir, specFile);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Not a file of its own capability either; the link rule decides.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
assertLinkedSpecFileInProject(specsRoot, specFile, error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=spec-path-containment.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fission-ai/openspec",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.3",
|
|
4
4
|
"description": "AI-native system for spec-driven development",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openspec",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"@changesets/changelog-github": "^1.0.0",
|
|
45
45
|
"@changesets/cli": "^3.0.1",
|
|
46
46
|
"@types/node": "^20.19.43",
|
|
47
|
+
"@types/picomatch": "^4.0.3",
|
|
47
48
|
"@vitest/ui": "^4.1.11",
|
|
48
49
|
"eslint": "^10.5.0",
|
|
49
50
|
"smol-toml": "^1.7.1",
|
|
@@ -54,12 +55,14 @@
|
|
|
54
55
|
"dependencies": {
|
|
55
56
|
"@inquirer/core": "^12.0.0",
|
|
56
57
|
"@inquirer/prompts": "^8.5.2",
|
|
58
|
+
"brace-expansion": "^5.0.12",
|
|
57
59
|
"chalk": "^5.6.2",
|
|
58
60
|
"commander": "^14.0.0",
|
|
59
61
|
"cross-spawn": "7.0.6",
|
|
60
62
|
"diff": "^9.0.0",
|
|
61
|
-
"fast-glob": "^3.3.3",
|
|
62
63
|
"ora": "^9.4.1",
|
|
64
|
+
"picomatch": "^4.0.4",
|
|
65
|
+
"tinyglobby": "^0.2.17",
|
|
63
66
|
"yaml": "^2.8.3",
|
|
64
67
|
"zod": "^4.4.3"
|
|
65
68
|
},
|