@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.
@@ -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 fast-glob.
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 fast-glob.
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
- * An in-capability symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the
14
- * artifact graph's globs both count it as content, so dropping it here would
15
- * silently lose the delta on archive. A link outside its capability is
16
- * rejected and a dangling link is skipped. Results are sorted by id.
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 { FileSystemUtils } from './file-system.js';
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
- * An in-capability symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the
22
- * artifact graph's globs both count it as content, so dropping it here would
23
- * silently lose the delta on archive. A link outside its capability is
24
- * rejected and a dangling link is skipped. Results are sorted by id.
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
- assertDiscoveredSpecPath(specsRoot, dir, specFile);
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
- assertDiscoveredSpecPath(specsRoot, dir, specFile);
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.1",
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
  },