@fission-ai/openspec 1.14.2 → 1.15.0

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.
Files changed (66) hide show
  1. package/README.md +13 -9
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/change.js +2 -1
  4. package/dist/commands/feedback.js +33 -20
  5. package/dist/commands/install-standalone.d.ts +27 -0
  6. package/dist/commands/install-standalone.js +150 -0
  7. package/dist/commands/schema.js +10 -3
  8. package/dist/commands/spec.js +2 -10
  9. package/dist/commands/validate.js +11 -4
  10. package/dist/commands/workflow/instructions.js +15 -4
  11. package/dist/commands/workflow/new-change.js +12 -4
  12. package/dist/commands/workflow/status.js +5 -2
  13. package/dist/commands/workflow/templates.js +5 -2
  14. package/dist/core/artifact-graph/outputs.js +204 -29
  15. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  16. package/dist/core/artifact-graph/resolver.js +11 -0
  17. package/dist/core/available-tools.d.ts +2 -1
  18. package/dist/core/available-tools.js +8 -28
  19. package/dist/core/completion-tip.d.ts +23 -0
  20. package/dist/core/completion-tip.js +4 -4
  21. package/dist/core/completions/command-registry.js +15 -0
  22. package/dist/core/completions/completion-provider.js +1 -1
  23. package/dist/core/config-schema.d.ts +1 -0
  24. package/dist/core/config-schema.js +1 -0
  25. package/dist/core/file-view.d.ts +13 -0
  26. package/dist/core/file-view.js +14 -0
  27. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  28. package/dist/core/github-copilot/cloud-agent.js +13 -9
  29. package/dist/core/global-config.d.ts +4 -0
  30. package/dist/core/global-config.js +5 -1
  31. package/dist/core/legacy-cleanup.d.ts +11 -1
  32. package/dist/core/legacy-cleanup.js +15 -18
  33. package/dist/core/migration.d.ts +27 -1
  34. package/dist/core/migration.js +77 -29
  35. package/dist/core/profile-sync-drift.d.ts +4 -3
  36. package/dist/core/profile-sync-drift.js +19 -16
  37. package/dist/core/shared/tool-detection.d.ts +12 -4
  38. package/dist/core/shared/tool-detection.js +29 -27
  39. package/dist/core/shared-skill-target.d.ts +12 -5
  40. package/dist/core/shared-skill-target.js +34 -23
  41. package/dist/core/specs-apply.js +23 -5
  42. package/dist/core/standalone-tip.d.ts +6 -0
  43. package/dist/core/standalone-tip.js +38 -0
  44. package/dist/core/templates/workflows/continue-change.js +18 -12
  45. package/dist/core/templates/workflows/explore.js +2 -2
  46. package/dist/core/templates/workflows/ff-change.js +8 -6
  47. package/dist/core/templates/workflows/propose.js +8 -6
  48. package/dist/core/templates/workflows/verify-change.js +4 -4
  49. package/dist/core/update-plan.d.ts +127 -0
  50. package/dist/core/update-plan.js +390 -0
  51. package/dist/core/update.d.ts +45 -1
  52. package/dist/core/update.js +279 -137
  53. package/dist/core/validation/validator.js +3 -3
  54. package/dist/core/version-check.d.ts +8 -1
  55. package/dist/core/version-check.js +48 -5
  56. package/dist/utils/executable-path.d.ts +5 -0
  57. package/dist/utils/executable-path.js +44 -0
  58. package/dist/utils/file-system.d.ts +1 -1
  59. package/dist/utils/file-system.js +1 -1
  60. package/dist/utils/interactive.d.ts +15 -0
  61. package/dist/utils/interactive.js +18 -0
  62. package/dist/utils/spec-discovery.d.ts +4 -4
  63. package/dist/utils/spec-discovery.js +7 -17
  64. package/dist/utils/spec-path-containment.d.ts +17 -0
  65. package/dist/utils/spec-path-containment.js +122 -0
  66. package/package.json +8 -4
@@ -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
- FileSystemUtils.assertPathWithin(path.dirname(mainSpecFile), mainSpecFile);
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
- FileSystemUtils.assertPathWithin(mainSpecRoot, mainSpecFile);
609
+ assertDiscoveredSpecFile(mainSpecRoot, mainSpecFile);
610
610
  try {
611
611
  mainContent = await fs.readFile(mainSpecFile, 'utf-8');
612
612
  }
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Called by the standalone binary's entry point. That copy has no package
3
+ * manager behind it, so a package-manager command is the wrong upgrade advice.
4
+ */
5
+ export declare function markStandaloneBinary(executablePath: string): void;
6
+ /** The standalone binary's own path, or null when running from a package. */
7
+ export declare function getStandaloneBinaryPath(): string | null;
1
8
  /**
2
9
  * The registry to ask. A configured registry is honored only over TLS: a
3
10
  * cleartext hop would aim the request at a link-local or internal address and
@@ -79,7 +86,7 @@ export declare function isNpmGlobalInstall(installDir: string | null, roots?: st
79
86
  */
80
87
  export declare function isSourceCheckout(installDir: string | null): boolean;
81
88
  export type PackageManager = 'npm' | 'pnpm' | 'bun' | 'yarn' | 'volta';
82
- export type InstallScope = 'global' | 'project' | 'temporary' | 'source';
89
+ export type InstallScope = 'global' | 'project' | 'temporary' | 'source' | 'standalone';
83
90
  export interface CliInstallInfo {
84
91
  location: string | null;
85
92
  packageManager: PackageManager | null;
@@ -3,11 +3,13 @@ import http from 'http';
3
3
  import https from 'https';
4
4
  import path from 'path';
5
5
  import { createRequire } from 'module';
6
+ import { fileURLToPath } from 'url';
6
7
  import chalk from 'chalk';
7
8
  import { isCiEnvironment } from '../utils/ci.js';
8
9
  import { isTelemetryOptedOutByEnv } from '../telemetry/opt-out.js';
9
10
  import { FileSystemUtils } from '../utils/file-system.js';
10
11
  import { getGlobalConfig, isGlobalConfigUnreadable } from './global-config.js';
12
+ import { findCopiesOnPath, samePath } from '../utils/executable-path.js';
11
13
  const require = createRequire(import.meta.url);
12
14
  const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
13
15
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
@@ -22,6 +24,34 @@ const MAX_REDIRECTS = 3;
22
24
  * one could smuggle ANSI cursor controls and repaint the lines around it.
23
25
  */
24
26
  const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
27
+ let standaloneBinaryPath = null;
28
+ /**
29
+ * Called by the standalone binary's entry point. That copy has no package
30
+ * manager behind it, so a package-manager command is the wrong upgrade advice.
31
+ */
32
+ export function markStandaloneBinary(executablePath) {
33
+ standaloneBinaryPath = executablePath;
34
+ }
35
+ /** The standalone binary's own path, or null when running from a package. */
36
+ export function getStandaloneBinaryPath() {
37
+ return standaloneBinaryPath;
38
+ }
39
+ /**
40
+ * `openspec install` reinstalls the standalone binary where it already is,
41
+ * under its own name, so the new copy cannot end up shadowed by the old one.
42
+ * Spelled with the full path when `openspec` on PATH is some other copy.
43
+ * Null when this is not the binary.
44
+ */
45
+ function standaloneUpgradeCommand() {
46
+ if (!standaloneBinaryPath)
47
+ return null;
48
+ const first = findCopiesOnPath()[0];
49
+ if (first && samePath(first, standaloneBinaryPath))
50
+ return 'openspec install';
51
+ return process.platform === 'win32'
52
+ ? `& '${standaloneBinaryPath.replace(/'/g, "''")}' install`
53
+ : `'${standaloneBinaryPath.replace(/'/g, `'\\''`)}' install`;
54
+ }
25
55
  /**
26
56
  * The check is opt-out and must never get in the way: no network in CI or
27
57
  * tests, an explicit escape hatch for anyone offline or air-gapped, and the
@@ -304,12 +334,14 @@ export async function getAvailableCliUpdate() {
304
334
  * which copy is actually answering.
305
335
  */
306
336
  export function getInstallDir() {
307
- try {
308
- return path.dirname(require.resolve('../../package.json'));
309
- }
310
- catch {
337
+ // The binary is not a package install, so no package-manager logic applies.
338
+ if (standaloneBinaryPath)
311
339
  return null;
312
- }
340
+ // Resolved at runtime from this module's own (symlink-resolved) location.
341
+ // require.resolve would be resolved by the binary's bundler at build time,
342
+ // baking the build machine's path into it.
343
+ const packageJson = fileURLToPath(new URL('../../package.json', import.meta.url));
344
+ return fs.existsSync(packageJson) ? path.dirname(packageJson) : null;
313
345
  }
314
346
  /**
315
347
  * True when the running CLI resolves from a `node_modules` belonging to the
@@ -508,6 +540,9 @@ function detectGlobalPackageManager(installDir) {
508
540
  }
509
541
  /** Describes the running copy without guessing when its owner is ambiguous. */
510
542
  export function getCliInstallInfo(installDir = getInstallDir(), projectPath = '.') {
543
+ if (standaloneBinaryPath) {
544
+ return { location: path.dirname(standaloneBinaryPath), packageManager: null, scope: 'standalone' };
545
+ }
511
546
  if (!installDir) {
512
547
  return { location: null, packageManager: null, scope: null };
513
548
  }
@@ -537,6 +572,8 @@ export function getCliInstallInfo(installDir = getInstallDir(), projectPath = '.
537
572
  }
538
573
  /** Returns a safe update command only for an install with known global ownership. */
539
574
  export function getCliUpdateCommand(install) {
575
+ if (install.scope === 'standalone')
576
+ return standaloneUpgradeCommand();
540
577
  if (install.scope !== 'global' || install.packageManager === null)
541
578
  return null;
542
579
  return GLOBAL_UPGRADE_COMMANDS[install.packageManager];
@@ -585,6 +622,12 @@ export function buildCliUpdateLines(latestVersion, installDir, projectPath, opti
585
622
  */
586
623
  export function buildUpgradeCommandLines(installDir, projectPath) {
587
624
  const lines = [];
625
+ const standalone = standaloneUpgradeCommand();
626
+ if (standalone) {
627
+ lines.push(` ${standalone}`);
628
+ lines.push(' Then run "openspec update" again to pick up new workflows.');
629
+ return lines;
630
+ }
588
631
  if (isEphemeralRunnerInstall(installDir)) {
589
632
  // That command *is* the update, so there is nothing to run afterwards.
590
633
  lines.push(` npx ${PACKAGE_NAME}@latest update`);
@@ -0,0 +1,5 @@
1
+ /** Every `openspec` on PATH, in the order a shell would find them. */
2
+ export declare function findCopiesOnPath(pathValue?: string, platform?: NodeJS.Platform): string[];
3
+ /** Whether two paths name the same file once symlinks are resolved. */
4
+ export declare function samePath(a: string, b: string): boolean;
5
+ //# sourceMappingURL=executable-path.d.ts.map
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /** Every `openspec` on PATH, in the order a shell would find them. */
4
+ export function findCopiesOnPath(pathValue = process.env.PATH ?? '', platform = process.platform) {
5
+ const names = platform === 'win32' ? ['openspec.exe', 'openspec.cmd'] : ['openspec'];
6
+ const seen = new Set();
7
+ const copies = [];
8
+ for (const dir of pathValue.split(path.delimiter)) {
9
+ if (!dir)
10
+ continue;
11
+ for (const name of names) {
12
+ const candidate = path.join(dir, name);
13
+ try {
14
+ if (!fs.statSync(candidate).isFile())
15
+ continue;
16
+ if (platform !== 'win32')
17
+ fs.accessSync(candidate, fs.constants.X_OK);
18
+ const key = platform === 'win32' ? candidate.toLowerCase() : candidate;
19
+ if (!seen.has(key)) {
20
+ seen.add(key);
21
+ copies.push(candidate);
22
+ }
23
+ }
24
+ catch {
25
+ // Not there, or not runnable: not a copy a shell would run.
26
+ }
27
+ }
28
+ }
29
+ return copies;
30
+ }
31
+ /** Whether two paths name the same file once symlinks are resolved. */
32
+ export function samePath(a, b) {
33
+ const real = (value) => {
34
+ try {
35
+ return fs.realpathSync(value);
36
+ }
37
+ catch {
38
+ return path.resolve(value);
39
+ }
40
+ };
41
+ const [x, y] = [real(a), real(b)];
42
+ return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
43
+ }
44
+ //# sourceMappingURL=executable-path.js.map
@@ -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
  /**
@@ -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, '/');
@@ -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.2",
3
+ "version": "1.15.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -44,22 +44,26 @@
44
44
  "@changesets/changelog-github": "^1.0.0",
45
45
  "@changesets/cli": "^3.0.1",
46
46
  "@types/node": "^20.19.43",
47
- "@vitest/ui": "^4.1.11",
47
+ "@types/picomatch": "^4.0.3",
48
+ "@vitest/ui": "^4.1.12",
48
49
  "eslint": "^10.5.0",
49
50
  "smol-toml": "^1.7.1",
50
51
  "typescript": "^6.0.3",
51
52
  "typescript-eslint": "^8.65.0",
52
- "vitest": "^4.1.11"
53
+ "vite": "^7.3.7",
54
+ "vitest": "^4.1.12"
53
55
  },
54
56
  "dependencies": {
55
57
  "@inquirer/core": "^12.0.0",
56
58
  "@inquirer/prompts": "^8.5.2",
59
+ "brace-expansion": "^5.0.12",
57
60
  "chalk": "^5.6.2",
58
61
  "commander": "^14.0.0",
59
62
  "cross-spawn": "7.0.6",
60
63
  "diff": "^9.0.0",
61
- "fast-glob": "^3.3.3",
62
64
  "ora": "^9.4.1",
65
+ "picomatch": "^4.0.4",
66
+ "tinyglobby": "^0.2.17",
63
67
  "yaml": "^2.8.3",
64
68
  "zod": "^4.4.3"
65
69
  },