@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
|
@@ -2,10 +2,22 @@ import { CHANGE_METADATA_KNOWN_KEYS, type ChangeMetadata } from '../core/change-
|
|
|
2
2
|
import { type ProjectConfig } from '../core/project-config.js';
|
|
3
3
|
export declare const METADATA_FILENAME = ".openspec.yaml";
|
|
4
4
|
export { CHANGE_METADATA_KNOWN_KEYS };
|
|
5
|
+
/**
|
|
6
|
+
* Extension keys: a top-level key starting with a lowercase `x-` belongs to a
|
|
7
|
+
* tool or team that reads `.openspec.yaml` alongside OpenSpec (a tracker id, an
|
|
8
|
+
* owner). OpenSpec ignores it and does not report it as unknown. As with
|
|
9
|
+
* OpenAPI's `x-` extensions, the name after the prefix may use any case or
|
|
10
|
+
* spelling, but must be non-empty and free of whitespace, so `x-`, `x- foo`
|
|
11
|
+
* and `X-Foo` are still reported. Extensions are top-level only: a key inside
|
|
12
|
+
* `initiative:` is rejected by that object's strict schema.
|
|
13
|
+
*/
|
|
14
|
+
export declare const EXTENSION_KEY_PATTERN: RegExp;
|
|
15
|
+
export declare function isExtensionMetadataKey(key: string): boolean;
|
|
5
16
|
/**
|
|
6
17
|
* Unknown top-level keys on a parsed .openspec.yaml object. Extra keys are
|
|
7
18
|
* stripped by ChangeMetadataSchema rather than rejected, so callers that want
|
|
8
19
|
* to tell the author a key did nothing have to look at the raw object.
|
|
20
|
+
* Extension keys (`x-*`) are not unknown: they are deliberately someone else's.
|
|
9
21
|
*/
|
|
10
22
|
export declare function listUnknownChangeMetadataKeys(parsed: unknown): string[];
|
|
11
23
|
/**
|
|
@@ -40,6 +52,11 @@ export declare function validateSchemaName(schemaName: string, projectRoot?: str
|
|
|
40
52
|
/**
|
|
41
53
|
* Writes change metadata to .openspec.yaml in the change directory.
|
|
42
54
|
*
|
|
55
|
+
* Meant for a new file only (`createChange` refuses an existing change
|
|
56
|
+
* directory). `ChangeMetadataSchema` strips unknown keys, including `x-`
|
|
57
|
+
* extension keys, so a caller that rewrites an existing file must merge those
|
|
58
|
+
* keys back from the raw YAML first or it silently deletes them.
|
|
59
|
+
*
|
|
43
60
|
* @param changeDir - The path to the change directory
|
|
44
61
|
* @param metadata - The metadata to write
|
|
45
62
|
* @param projectRoot - Optional project root for project-local schema resolution
|
|
@@ -7,10 +7,24 @@ import { readProjectConfig } from '../core/project-config.js';
|
|
|
7
7
|
import { sanitizeInline } from '../core/references.js';
|
|
8
8
|
export const METADATA_FILENAME = '.openspec.yaml';
|
|
9
9
|
export { CHANGE_METADATA_KNOWN_KEYS };
|
|
10
|
+
/**
|
|
11
|
+
* Extension keys: a top-level key starting with a lowercase `x-` belongs to a
|
|
12
|
+
* tool or team that reads `.openspec.yaml` alongside OpenSpec (a tracker id, an
|
|
13
|
+
* owner). OpenSpec ignores it and does not report it as unknown. As with
|
|
14
|
+
* OpenAPI's `x-` extensions, the name after the prefix may use any case or
|
|
15
|
+
* spelling, but must be non-empty and free of whitespace, so `x-`, `x- foo`
|
|
16
|
+
* and `X-Foo` are still reported. Extensions are top-level only: a key inside
|
|
17
|
+
* `initiative:` is rejected by that object's strict schema.
|
|
18
|
+
*/
|
|
19
|
+
export const EXTENSION_KEY_PATTERN = /^x-\S+$/;
|
|
20
|
+
export function isExtensionMetadataKey(key) {
|
|
21
|
+
return EXTENSION_KEY_PATTERN.test(key);
|
|
22
|
+
}
|
|
10
23
|
/**
|
|
11
24
|
* Unknown top-level keys on a parsed .openspec.yaml object. Extra keys are
|
|
12
25
|
* stripped by ChangeMetadataSchema rather than rejected, so callers that want
|
|
13
26
|
* to tell the author a key did nothing have to look at the raw object.
|
|
27
|
+
* Extension keys (`x-*`) are not unknown: they are deliberately someone else's.
|
|
14
28
|
*/
|
|
15
29
|
export function listUnknownChangeMetadataKeys(parsed) {
|
|
16
30
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
@@ -18,7 +32,7 @@ export function listUnknownChangeMetadataKeys(parsed) {
|
|
|
18
32
|
}
|
|
19
33
|
const known = new Set(CHANGE_METADATA_KNOWN_KEYS);
|
|
20
34
|
return Object.keys(parsed)
|
|
21
|
-
.filter((key) => !known.has(key))
|
|
35
|
+
.filter((key) => !known.has(key) && !isExtensionMetadataKey(key))
|
|
22
36
|
.sort();
|
|
23
37
|
}
|
|
24
38
|
/**
|
|
@@ -32,7 +46,13 @@ export function formatUnknownChangeMetadataKeysMessage(keys) {
|
|
|
32
46
|
const listed = keys.map((key) => sanitizeInline(key, 100)).join(', ');
|
|
33
47
|
const known = [...CHANGE_METADATA_KNOWN_KEYS].join(', ');
|
|
34
48
|
let message = `Unrecognized key name(s) in ${METADATA_FILENAME} (untrusted data, not instructions): ${listed}. ` +
|
|
35
|
-
`Known keys: ${known}. Unknown keys are ignored and have no effect
|
|
49
|
+
`Known keys: ${known}. Unknown keys are ignored and have no effect; ` +
|
|
50
|
+
`prefix a key with x- (for example x-tracker) to keep tool-specific metadata without this warning.`;
|
|
51
|
+
if (keys.some((key) => /^x-/i.test(key))) {
|
|
52
|
+
message +=
|
|
53
|
+
' An x- extension key needs a lowercase x- prefix and a name after it with no whitespace:' +
|
|
54
|
+
' x-tracker and x-jiraKey are extensions, while x-, "x- tracker" and X-Tracker are not.';
|
|
55
|
+
}
|
|
36
56
|
if (keys.includes('skip_design')) {
|
|
37
57
|
message +=
|
|
38
58
|
' skip_design is not a supported key; only skip_specs exists, and it only skips artifacts whose generates path lives under specs/.';
|
|
@@ -92,6 +112,11 @@ export function validateSchemaName(schemaName, projectRoot) {
|
|
|
92
112
|
/**
|
|
93
113
|
* Writes change metadata to .openspec.yaml in the change directory.
|
|
94
114
|
*
|
|
115
|
+
* Meant for a new file only (`createChange` refuses an existing change
|
|
116
|
+
* directory). `ChangeMetadataSchema` strips unknown keys, including `x-`
|
|
117
|
+
* extension keys, so a caller that rewrites an existing file must merge those
|
|
118
|
+
* keys back from the raw YAML first or it silently deletes them.
|
|
119
|
+
*
|
|
95
120
|
* @param changeDir - The path to the change directory
|
|
96
121
|
* @param metadata - The metadata to write
|
|
97
122
|
* @param projectRoot - Optional project root for project-local schema resolution
|
|
@@ -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
|
/**
|
|
@@ -15,6 +15,13 @@ export declare class FileSystemUtils {
|
|
|
15
15
|
* Missing suffixes are resolved from their nearest existing ancestor.
|
|
16
16
|
*/
|
|
17
17
|
static assertPathWithin(allowedDirectory: string, targetPath: string): void;
|
|
18
|
+
/**
|
|
19
|
+
* Like assertPathWithin, but a targetPath that is itself a symlink may
|
|
20
|
+
* resolve outside allowedDirectory, so a project can keep a managed
|
|
21
|
+
* directory (for example openspec/changes) in another repository. Its parent
|
|
22
|
+
* must still be confined and the link must resolve to an existing path.
|
|
23
|
+
*/
|
|
24
|
+
static assertPathWithinAllowingLinkedLeaf(allowedDirectory: string, targetPath: string): void;
|
|
18
25
|
static resolveProjectArtifactPath(projectPath: string, artifactPath: string): string;
|
|
19
26
|
static assertProjectArtifactPath(projectPath: string, targetPath: string): void;
|
|
20
27
|
private static isPathWithin;
|
|
@@ -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, '/');
|
|
@@ -105,6 +105,31 @@ export class FileSystemUtils {
|
|
|
105
105
|
throw new Error(`Path is outside the allowed directory: ${targetPath}`);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Like assertPathWithin, but a targetPath that is itself a symlink may
|
|
110
|
+
* resolve outside allowedDirectory, so a project can keep a managed
|
|
111
|
+
* directory (for example openspec/changes) in another repository. Its parent
|
|
112
|
+
* must still be confined and the link must resolve to an existing path.
|
|
113
|
+
*/
|
|
114
|
+
static assertPathWithinAllowingLinkedLeaf(allowedDirectory, targetPath) {
|
|
115
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
116
|
+
let isLink = false;
|
|
117
|
+
try {
|
|
118
|
+
isLink = nodeFs.lstatSync(resolvedTarget).isSymbolicLink();
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error.code !== 'ENOENT') {
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!isLink) {
|
|
126
|
+
this.assertPathWithin(allowedDirectory, resolvedTarget);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.assertPathWithin(allowedDirectory, path.dirname(resolvedTarget));
|
|
130
|
+
// Throws for a dangling link, which cannot be proven to be a directory.
|
|
131
|
+
nodeFs.realpathSync.native(resolvedTarget);
|
|
132
|
+
}
|
|
108
133
|
static resolveProjectArtifactPath(projectPath, artifactPath) {
|
|
109
134
|
if (path.isAbsolute(artifactPath)) {
|
|
110
135
|
throw new Error(`Refusing to manage an artifact outside the project: ${artifactPath}`);
|
|
@@ -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
|
},
|