From 4ab91fbadfa3d75e792341479c3ad98f02276e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Luj=C3=A1n=20Mu=C3=B1oz?= Date: Tue, 10 Mar 2026 15:50:42 +0100 Subject: [PATCH 1/2] feat: add Codex support for extension command registration (#1767) * feat: add Codex support for extension command registration * test: add codex command registrar mapping check * test: add codex consistency check to test_agent_config_consistency Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- src/specify_cli/extensions.py | 6 ++++++ tests/test_agent_config_consistency.py | 7 +++++++ tests/test_extensions.py | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index b1045e3c..64300b53 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -635,6 +635,12 @@ class CommandRegistrar: "args": "$ARGUMENTS", "extension": ".md" }, + "codex": { + "dir": ".codex/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, "windsurf": { "dir": ".windsurf/workflows", "format": "markdown", diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 607d491d..3615dbc7 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -28,6 +28,13 @@ class TestAgentConfigConsistency: assert cfg["kiro-cli"]["dir"] == ".kiro/prompts" assert "q" not in cfg + def test_extension_registrar_includes_codex(self): + """Extension command registrar should include codex targeting .codex/prompts.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "codex" in cfg + assert cfg["codex"]["dir"] == ".codex/prompts" + def test_release_agent_lists_include_kiro_cli_and_exclude_q(self): """Bash and PowerShell release scripts should agree on agent key set for Kiro.""" sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9ef9cb73..54536322 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -407,6 +407,11 @@ class TestCommandRegistrar: assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts" assert "q" not in CommandRegistrar.AGENT_CONFIGS + def test_codex_agent_config_present(self): + """Codex should be mapped to .codex/prompts.""" + assert "codex" in CommandRegistrar.AGENT_CONFIGS + assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts" + def test_parse_frontmatter_valid(self): """Test parsing valid YAML frontmatter.""" content = """--- From 2632a0f52d1b68c493774ad4a3f6da46c9572747 Mon Sep 17 00:00:00 2001 From: Ben Lawson Date: Tue, 10 Mar 2026 13:02:04 -0400 Subject: [PATCH 2/2] feat(extensions): support .extensionignore to exclude files during install (#1781) * feat(extensions): support .extensionignore to exclude files during install Add .extensionignore support so extension authors can exclude files and folders from being copied when users run 'specify extension add'. The file uses glob-style patterns (one per line), supports comments (#), blank lines, trailing-slash directory patterns, and relative path matching. The .extensionignore file itself is always excluded from the copy. - Add _load_extensionignore() to ExtensionManager - Integrate ignore function into shutil.copytree in install_from_directory - Document .extensionignore in EXTENSION-DEVELOPMENT-GUIDE.md - Add 6 tests covering all pattern matching scenarios - Bump version to 0.1.14 * fix(extensions): use pathspec for gitignore-compatible .extensionignore matching Replace fnmatch with pathspec.GitIgnoreSpec to get proper .gitignore semantics where * does not cross directory boundaries. This addresses review feedback on #1781. Changes: - Switch from fnmatch to pathspec>=0.12.0 (GitIgnoreSpec.from_lines) - Normalize backslashes in patterns for cross-platform compatibility - Distinguish directories from files for trailing-slash patterns - Update docs to accurately describe supported pattern semantics - Add edge-case tests: .., absolute paths, empty file, backslashes, * vs ** boundary behavior, and ! negation - Move changelog entry to [Unreleased] section --- CHANGELOG.md | 7 +- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 61 ++++ pyproject.toml | 1 + src/specify_cli/extensions.py | 71 ++++- tests/test_extensions.py | 340 ++++++++++++++++++++++ 5 files changed, 477 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0fa5d9..45b66216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) + ## [0.2.0] - 2026-03-09 ### Changed @@ -43,7 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix: release-trigger uses release branch + PR instead of direct push to main (#1733) - fix: Split release process to sync pyproject.toml version with git tags (#1732) - ## [0.1.14] - 2026-03-09 ### Added diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index f86beb62..feea7b27 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -332,6 +332,67 @@ echo "$config" --- +## Excluding Files with `.extensionignore` + +Extension authors can create a `.extensionignore` file in the extension root to exclude files and folders from being copied when a user installs the extension with `specify extension add`. This is useful for keeping development-only files (tests, CI configs, docs source, etc.) out of the installed copy. + +### Format + +The file uses `.gitignore`-compatible patterns (one per line), powered by the [`pathspec`](https://pypi.org/project/pathspec/) library: + +- Blank lines are ignored +- Lines starting with `#` are comments +- `*` matches anything **except** `/` (does not cross directory boundaries) +- `**` matches zero or more directories (e.g., `docs/**/*.draft.md`) +- `?` matches any single character except `/` +- A trailing `/` restricts a pattern to directories only +- Patterns containing `/` (other than a trailing slash) are anchored to the extension root +- Patterns without `/` match at any depth in the tree +- `!` negates a previously excluded pattern (re-includes a file) +- Backslashes in patterns are normalised to forward slashes for cross-platform compatibility +- The `.extensionignore` file itself is always excluded automatically + +### Example + +```gitignore +# .extensionignore + +# Development files +tests/ +.github/ +.gitignore + +# Build artifacts +__pycache__/ +*.pyc +dist/ + +# Documentation source (keep only the built README) +docs/ +CONTRIBUTING.md +``` + +### Pattern Matching + +| Pattern | Matches | Does NOT match | +|---------|---------|----------------| +| `*.pyc` | Any `.pyc` file in any directory | — | +| `tests/` | The `tests` directory (and all its contents) | A file named `tests` | +| `docs/*.draft.md` | `docs/api.draft.md` (directly inside `docs/`) | `docs/sub/api.draft.md` (nested) | +| `.env` | The `.env` file at any level | — | +| `!README.md` | Re-includes `README.md` even if matched by an earlier pattern | — | +| `docs/**/*.draft.md` | `docs/api.draft.md`, `docs/sub/api.draft.md` | — | + +### Unsupported Features + +The following `.gitignore` features are **not applicable** in this context: + +- **Multiple `.extensionignore` files**: Only a single file at the extension root is supported (`.gitignore` supports files in subdirectories) +- **`$GIT_DIR/info/exclude` and `core.excludesFile`**: These are Git-specific and have no equivalent here +- **Negation inside excluded directories**: Because file copying uses `shutil.copytree`, excluding a directory prevents recursion into it entirely. A negation pattern cannot re-include a file inside a directory that was itself excluded. For example, the combination `tests/` followed by `!tests/important.py` will **not** preserve `tests/important.py` — the `tests/` directory is skipped at the root level and its contents are never evaluated. To work around this, exclude the directory's contents individually instead of the directory itself (e.g., `tests/*.pyc` and `tests/.cache/` rather than `tests/`). + +--- + ## Validation Rules ### Extension ID diff --git a/pyproject.toml b/pyproject.toml index e7806959..0bb55cea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "truststore>=0.10.4", "pyyaml>=6.0", "packaging>=23.0", + "pathspec>=0.12.0", ] [project.scripts] diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 64300b53..53777bd6 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -14,10 +14,12 @@ import zipfile import shutil from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, List, Any +from typing import Optional, Dict, List, Any, Callable, Set from datetime import datetime, timezone import re +import pathspec + import yaml from packaging import version as pkg_version from packaging.specifiers import SpecifierSet, InvalidSpecifier @@ -280,6 +282,70 @@ class ExtensionManager: self.extensions_dir = project_root / ".specify" / "extensions" self.registry = ExtensionRegistry(self.extensions_dir) + @staticmethod + def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]: + """Load .extensionignore and return an ignore function for shutil.copytree. + + The .extensionignore file uses .gitignore-compatible patterns (one per line). + Lines starting with '#' are comments. Blank lines are ignored. + The .extensionignore file itself is always excluded. + + Pattern semantics mirror .gitignore: + - '*' matches anything except '/' + - '**' matches zero or more directories + - '?' matches any single character except '/' + - Trailing '/' restricts a pattern to directories only + - Patterns with '/' (other than trailing) are anchored to the root + - '!' negates a previously excluded pattern + + Args: + source_dir: Path to the extension source directory + + Returns: + An ignore function compatible with shutil.copytree, or None + if no .extensionignore file exists. + """ + ignore_file = source_dir / ".extensionignore" + if not ignore_file.exists(): + return None + + lines: List[str] = ignore_file.read_text().splitlines() + + # Normalise backslashes in patterns so Windows-authored files work + normalised: List[str] = [] + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + normalised.append(stripped.replace("\\", "/")) + else: + # Preserve blanks/comments so pathspec line numbers stay stable + normalised.append(line) + + # Always ignore the .extensionignore file itself + normalised.append(".extensionignore") + + spec = pathspec.GitIgnoreSpec.from_lines(normalised) + + def _ignore(directory: str, entries: List[str]) -> Set[str]: + ignored: Set[str] = set() + rel_dir = Path(directory).relative_to(source_dir) + for entry in entries: + rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry + # Normalise to forward slashes for consistent matching + rel_path_fwd = rel_path.replace("\\", "/") + + entry_full = Path(directory) / entry + if entry_full.is_dir(): + # Append '/' so directory-only patterns (e.g. tests/) match + if spec.match_file(rel_path_fwd + "/"): + ignored.add(entry) + else: + if spec.match_file(rel_path_fwd): + ignored.add(entry) + return ignored + + return _ignore + def check_compatibility( self, manifest: ExtensionManifest, @@ -353,7 +419,8 @@ class ExtensionManager: if dest_dir.exists(): shutil.rmtree(dest_dir) - shutil.copytree(source_dir, dest_dir) + ignore_fn = self._load_extensionignore(source_dir) + shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) # Register commands with AI agents registered_commands = {} diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 54536322..ba52d034 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1603,3 +1603,343 @@ class TestCatalogStack: assert len(results) == 1 assert results[0]["_catalog_name"] == "org" assert results[0]["_install_allowed"] is True + + +class TestExtensionIgnore: + """Test .extensionignore support during extension installation.""" + + def _make_extension(self, temp_dir, valid_manifest_data, extra_files=None, ignore_content=None): + """Helper to create an extension directory with optional extra files and .extensionignore.""" + import yaml + + ext_dir = temp_dir / "ignored-ext" + ext_dir.mkdir() + + # Write manifest + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(valid_manifest_data, f) + + # Create commands directory with a command file + commands_dir = ext_dir / "commands" + commands_dir.mkdir() + (commands_dir / "hello.md").write_text( + "---\ndescription: \"Test hello command\"\n---\n\n# Hello\n\n$ARGUMENTS\n" + ) + + # Create any extra files/dirs + if extra_files: + for rel_path, content in extra_files.items(): + p = ext_dir / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + if content is None: + # Create directory + p.mkdir(parents=True, exist_ok=True) + else: + p.write_text(content) + + # Write .extensionignore + if ignore_content is not None: + (ext_dir / ".extensionignore").write_text(ignore_content) + + return ext_dir + + def test_no_extensionignore(self, temp_dir, valid_manifest_data): + """Without .extensionignore, all files are copied.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "tests/test_foo.py": "pass"}, + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert (dest / "tests" / "test_foo.py").exists() + + def test_extensionignore_excludes_files(self, temp_dir, valid_manifest_data): + """Files matching .extensionignore patterns are excluded.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "README.md": "# Hello", + "tests/test_foo.py": "pass", + "tests/test_bar.py": "pass", + ".github/workflows/ci.yml": "on: push", + }, + ignore_content="tests/\n.github/\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # Included + assert (dest / "README.md").exists() + assert (dest / "extension.yml").exists() + assert (dest / "commands" / "hello.md").exists() + # Excluded + assert not (dest / "tests").exists() + assert not (dest / ".github").exists() + + def test_extensionignore_glob_patterns(self, temp_dir, valid_manifest_data): + """Glob patterns like *.pyc are respected.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "README.md": "# Hello", + "helpers.pyc": b"\x00".decode("latin-1"), + "commands/cache.pyc": b"\x00".decode("latin-1"), + }, + ignore_content="*.pyc\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert not (dest / "helpers.pyc").exists() + assert not (dest / "commands" / "cache.pyc").exists() + + def test_extensionignore_comments_and_blanks(self, temp_dir, valid_manifest_data): + """Comments and blank lines in .extensionignore are ignored.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "notes.txt": "some notes"}, + ignore_content="# This is a comment\n\nnotes.txt\n\n# Another comment\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert not (dest / "notes.txt").exists() + + def test_extensionignore_itself_excluded(self, temp_dir, valid_manifest_data): + """.extensionignore is never copied to the destination.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + ignore_content="# nothing special here\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "extension.yml").exists() + assert not (dest / ".extensionignore").exists() + + def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data): + """Patterns matching relative paths work correctly.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/guide.md": "# Guide", + "docs/internal/draft.md": "draft", + "README.md": "# Hello", + }, + ignore_content="docs/internal/draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "docs" / "guide.md").exists() + assert not (dest / "docs" / "internal" / "draft.md").exists() + + def test_extensionignore_dotdot_pattern_is_noop(self, temp_dir, valid_manifest_data): + """Patterns with '..' should not escape the extension root.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello"}, + ignore_content="../sibling/\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # Everything should still be copied — the '..' pattern matches nothing inside + assert (dest / "README.md").exists() + assert (dest / "extension.yml").exists() + assert (dest / "commands" / "hello.md").exists() + + def test_extensionignore_absolute_path_pattern_is_noop(self, temp_dir, valid_manifest_data): + """Absolute path patterns should not match anything.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "passwd": "sensitive"}, + ignore_content="/etc/passwd\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # Nothing matches — /etc/passwd is anchored to root and there's no 'etc' dir + assert (dest / "README.md").exists() + assert (dest / "passwd").exists() + + def test_extensionignore_empty_file(self, temp_dir, valid_manifest_data): + """An empty .extensionignore should exclude only itself.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "notes.txt": "notes"}, + ignore_content="", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert (dest / "notes.txt").exists() + assert (dest / "extension.yml").exists() + # .extensionignore itself is still excluded + assert not (dest / ".extensionignore").exists() + + def test_extensionignore_windows_backslash_patterns(self, temp_dir, valid_manifest_data): + """Backslash patterns (Windows-style) are normalised to forward slashes.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/internal/draft.md": "draft", + "docs/guide.md": "# Guide", + }, + ignore_content="docs\\internal\\draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "docs" / "guide.md").exists() + assert not (dest / "docs" / "internal" / "draft.md").exists() + + def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data): + """'*' should NOT match across directory boundaries (gitignore semantics).""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/api.draft.md": "draft", + "docs/sub/api.draft.md": "nested draft", + }, + ignore_content="docs/*.draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # docs/*.draft.md should only match directly inside docs/, NOT subdirs + assert not (dest / "docs" / "api.draft.md").exists() + assert (dest / "docs" / "sub" / "api.draft.md").exists() + + def test_extensionignore_doublestar_crosses_directories(self, temp_dir, valid_manifest_data): + """'**' should match across directory boundaries.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/api.draft.md": "draft", + "docs/sub/api.draft.md": "nested draft", + "docs/guide.md": "guide", + }, + ignore_content="docs/**/*.draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert not (dest / "docs" / "api.draft.md").exists() + assert not (dest / "docs" / "sub" / "api.draft.md").exists() + assert (dest / "docs" / "guide.md").exists() + + def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data): + """'!' negation re-includes a previously excluded file.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/guide.md": "# Guide", + "docs/internal.md": "internal", + "docs/api.md": "api", + }, + ignore_content="docs/*.md\n!docs/api.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # docs/*.md excludes all .md in docs, but !docs/api.md re-includes it + assert not (dest / "docs" / "guide.md").exists() + assert not (dest / "docs" / "internal.md").exists() + assert (dest / "docs" / "api.md").exists()