diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index d2745c583a..28ff7ca757 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -42,6 +42,13 @@ "owner": "compilation/footer.py (build_generation_footer)", "selectors": ["src/apm_cli/compilation/footer.py"], "guards": ["contracts-tooling-generation-footer"] + }, + { + "id": "apmignore-membership", + "decision": "Package ship/deploy/compile path membership from .apmignore", + "owner": "utils/apmignore.py (ApmIgnoreSpec)", + "selectors": ["src/apm_cli/utils/apmignore.py"], + "guards": ["contracts-tooling-apmignore-membership"] } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0dd2347c..3b09be61ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Packages can ship a `.apmignore` file (gitignore semantics, including nested + files and `!` negation) so `apm install`, `apm pack`, and `apm compile` + omit maintainer-only paths such as `evals/`. Root `SKILL.md` and `apm.yml` + cannot be ignored. + ### Changed - Architecture ownership guards now use a sharded JSON registry and a diff --git a/NOTICE b/NOTICE index 17762ac1e0..90c18f98ac 100644 --- a/NOTICE +++ b/NOTICE @@ -1364,6 +1364,395 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --- +## Component. pathspec + +- Version requirement: `>=0.12.0` +- Upstream: https://github.com/cpburnz/python-pathspec +- SPDX: `MPL-2.0` +- Notes: Used by ApmIgnoreSpec to match .apmignore files with gitignore semantics. + +### Open Source License/Copyright Notice. + +_Copyright (c) Caleb P. Burns_ + +``` +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. +``` + +--- + Submitted on behalf of a third-party The contributions below are identified as submitted on behalf of a diff --git a/docs/src/content/docs/concepts/package-anatomy.md b/docs/src/content/docs/concepts/package-anatomy.md index deee96e7ca..beab95f2b5 100644 --- a/docs/src/content/docs/concepts/package-anatomy.md +++ b/docs/src/content/docs/concepts/package-anatomy.md @@ -63,6 +63,8 @@ my-pkg/ +-- apm-policy.yml # Optional org/repo policy. See enterprise docs. +-- scripts/ # Optional helper scripts you author. +-- tests/ # Optional tests for your primitives. ++-- .apmignore # Optional. Omit maintainer-only files from + # install, pack, and compile (gitignore syntax). ``` Anything under `apm_modules/`, `.github/`, `.claude/`, `.cursor/`, or diff --git a/docs/src/content/docs/producer/author-primitives/skills.md b/docs/src/content/docs/producer/author-primitives/skills.md index 900800b3bf..208891e2e0 100644 --- a/docs/src/content/docs/producer/author-primitives/skills.md +++ b/docs/src/content/docs/producer/author-primitives/skills.md @@ -29,6 +29,11 @@ required file; the four conventional subdirectories ship as-is when APM copies the skill to a target. Single-skill repositories may also place `SKILL.md` at the package root. +A root `SKILL.md` package copies the whole tree on install. Put a +`.apmignore` next to it (gitignore syntax) to keep maintainer-only +paths such as `evals/` out of install, pack, and compile. You cannot +ignore `SKILL.md` or `apm.yml`. + ## Frontmatter contract ```yaml diff --git a/docs/src/content/docs/producer/pack-a-bundle.md b/docs/src/content/docs/producer/pack-a-bundle.md index ab35f1ea0d..3be63786e4 100644 --- a/docs/src/content/docs/producer/pack-a-bundle.md +++ b/docs/src/content/docs/producer/pack-a-bundle.md @@ -23,6 +23,10 @@ This is the producer side of [Deploy a local bundle](../../consumer/deploy-a-bun Consumers who receive the artifact run `apm install ./your-bundle` and skip the registry resolver entirely. +To keep maintainer-only files such as `evals/` out of the bundle, add a +`.apmignore` at the package root. The file uses gitignore syntax. `apm pack`, +`apm install`, and `apm compile` all honor it. + ## What `apm pack` produces By default `apm pack` writes a Claude Code plugin directory under `./build/`: diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 6aba33e1c1..b16fed0be5 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -74,6 +74,13 @@ my-package/ ## Install-time discovery rules +Add a `.apmignore` at the package root (gitignore syntax, including +nested files and `!` negation) to keep maintainer-only files such as +`evals/` out of `apm install`, `apm pack`, and `apm compile`. There +are no built-in author patterns. `SKILL.md` and `apm.yml` cannot be +ignored. The git checkout in `apm_modules/` stays complete; filtering +happens at deploy, pack, and compile. + When `.apm/` exists, `apm pack` sources local primitives and hooks from `.apm/`. Without `.apm/`, supported plugin-native root directories (`agents/`, `skills/`, `commands/`, `instructions/`, `extensions/`, and diff --git a/pyproject.toml b/pyproject.toml index 5a38981416..604be64e9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "ruamel.yaml>=0.18.0", "filelock>=3.12", "websockets>=12,<17", + "pathspec>=0.12.0", ] [project.optional-dependencies] diff --git a/scripts/architecture_linter/checks/contracts_apmignore.py b/scripts/architecture_linter/checks/contracts_apmignore.py new file mode 100644 index 0000000000..8e3939cb6c --- /dev/null +++ b/scripts/architecture_linter/checks/contracts_apmignore.py @@ -0,0 +1,96 @@ +"""Package ship/deploy/compile path membership from ``.apmignore``.""" + +from __future__ import annotations + +from scripts.architecture_linter.facts import FactsProvider +from scripts.architecture_linter.groups.common import checked_facts, violation +from scripts.architecture_linter.models import FileFacts, Violation + +_OWNER = "src/apm_cli/utils/apmignore.py" +_CONSTANTS = "src/apm_cli/constants.py" +_CLASS = "ApmIgnoreSpec" +_FILENAME = ".apmignore" +_SRC_PREFIX = "src/apm_cli/" + + +def _pathspec_import_site(facts: FileFacts) -> tuple[int, int] | None: + """Return the first pathspec / GitIgnoreSpec import coordinate, if any.""" + for item in facts.imports: + module = item.module or "" + if module == "pathspec" or module.startswith("pathspec."): + return item.line, item.column + 1 + if "GitIgnoreSpec" in item.names: + return item.line, item.column + 1 + return None + + +def check_apmignore_membership( + provider: FactsProvider, + rule_id: str, +) -> tuple[Violation, ...]: + """Require ``.apmignore`` parsing to stay in utils/apmignore.py.""" + findings: list[Violation] = [] + owner, failures = checked_facts(provider, _OWNER, rule_id, require_python=True) + findings.extend(failures) + if not failures: + definitions = tuple( + definition + for definition in owner.definitions + if definition.name == _CLASS + and definition.kind == "class" + and definition.scope == "" + ) + if len(definitions) != 1: + findings.append( + violation( + rule_id, + _OWNER, + f"{_CLASS} must have exactly one module-level class definition", + line=1, + ) + ) + if _pathspec_import_site(owner) is None: + findings.append( + violation( + rule_id, + _OWNER, + "apmignore owner must import pathspec.GitIgnoreSpec", + line=1, + ) + ) + + for path in provider.inventory: + if path == _OWNER or not path.startswith(_SRC_PREFIX) or not path.endswith(".py"): + continue + facts, failures = checked_facts(provider, path, rule_id, require_python=True) + findings.extend(failures) + if failures: + continue + if path != _CONSTANTS: + for literal in facts.literals: + if _FILENAME in literal.value_repr: + findings.append( + violation( + rule_id, + path, + f"{_FILENAME} filename must stay in {_OWNER}", + line=literal.line, + column=literal.column + 1, + ) + ) + imported = _pathspec_import_site(facts) + if imported is not None: + line, column = imported + findings.append( + violation( + rule_id, + path, + "pathspec GitIgnoreSpec parsing must stay in utils/apmignore.py", + line=line, + column=column, + ) + ) + return tuple(findings) + + +__all__ = ["check_apmignore_membership"] diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index 898a61529a..7a601a7a21 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -16,6 +16,9 @@ import re from collections.abc import Sequence +from scripts.architecture_linter.checks.contracts_apmignore import ( + check_apmignore_membership, +) from scripts.architecture_linter.checks.contracts_generation_footer import ( check_generation_footer_authority, ) @@ -68,6 +71,9 @@ _GUARD_GENERATION_FOOTER = "contracts-tooling-generation-footer" +_GUARD_APMIGNORE = "contracts-tooling-apmignore-membership" + + _SRC_PREFIX = "src/apm_cli/" @@ -595,6 +601,11 @@ def _structural_rule(rule_id: str, description: str, check) -> Rule: "Generated-content footer wording stays owned by compilation/footer.py.", lambda provider: check_generation_footer_authority(provider, _GUARD_GENERATION_FOOTER), ), + _owner_rule( + _GUARD_APMIGNORE, + "Package ship/deploy/compile path membership stays owned by utils/apmignore.py.", + lambda provider: check_apmignore_membership(provider, _GUARD_APMIGNORE), + ), _structural_rule( _CONTRACT_RULE_ID, "Executable test binary selection, rendered CLI parity, and ratchet authority owners.", diff --git a/scripts/notice-metadata.yaml b/scripts/notice-metadata.yaml index 64db4098a6..81d4636491 100644 --- a/scripts/notice-metadata.yaml +++ b/scripts/notice-metadata.yaml @@ -294,3 +294,9 @@ components: spdx: MIT copyright_snippet: Copyright (c) 2022 Seth Michael Larson notes: Verifies HTTPS against the operating-system trust store by default so `apm` works behind a corporate CA / TLS-inspecting proxy. + - name: pathspec + pyproject_name: pathspec + upstream: https://github.com/cpburnz/python-pathspec + spdx: MPL-2.0 + copyright_snippet: Copyright (c) Caleb P. Burns + notes: Used by ApmIgnoreSpec to match .apmignore files with gitignore semantics. diff --git a/src/apm_cli/bundle/plugin_exporter.py b/src/apm_cli/bundle/plugin_exporter.py index f379aa4605..caf268a6b5 100644 --- a/src/apm_cli/bundle/plugin_exporter.py +++ b/src/apm_cli/bundle/plugin_exporter.py @@ -23,6 +23,7 @@ ) from ..models.apm_package import APMPackage, DependencyReference from ..models.dependency.subsets import skill_subset_filter_tokens +from ..utils.apmignore import ApmIgnoreSpec from ..utils.archive import ( projected_archive_path, validate_archive_format, @@ -148,26 +149,30 @@ def _collect_apm_components(apm_dir: Path) -> list[tuple[Path, str]]: if not apm_dir.is_dir(): return components + ignore = ApmIgnoreSpec.load(apm_dir.parent) + # agents/ -> agents/ - _collect_flat(apm_dir / "agents", "agents", components) + _collect_flat(apm_dir / "agents", "agents", components, ignore=ignore) # skills/ -> skills/ (preserve sub-directory structure) - _collect_recursive(apm_dir / "skills", "skills", components) + _collect_recursive(apm_dir / "skills", "skills", components, ignore=ignore) # prompts/ -> commands/ (rename .prompt.md -> .md) - _collect_recursive(apm_dir / "prompts", "commands", components, rename=_rename_prompt) + _collect_recursive( + apm_dir / "prompts", "commands", components, rename=_rename_prompt, ignore=ignore + ) # instructions/ -> instructions/ - _collect_recursive(apm_dir / "instructions", "instructions", components) + _collect_recursive(apm_dir / "instructions", "instructions", components, ignore=ignore) # commands/ -> commands/ - _collect_recursive(apm_dir / "commands", "commands", components) + _collect_recursive(apm_dir / "commands", "commands", components, ignore=ignore) # extensions/ -> extensions/ (canvas extensions, experimental Copilot-only). # Preserved verbatim so an offline bundle can carry a canvas; the files are # inert until the consumer enables the ``canvas`` experimental flag AND # approves the package via allowExecutables / ``apm approve`` at install time. - _collect_recursive(apm_dir / "extensions", "extensions", components) + _collect_recursive(apm_dir / "extensions", "extensions", components, ignore=ignore) return components @@ -179,10 +184,11 @@ def _collect_root_plugin_components(project_root: Path) -> list[tuple[Path, str] ``skills/``, etc. at the repo root) have their files picked up here. """ components: list[tuple[Path, str]] = [] + ignore = ApmIgnoreSpec.load(project_root) for dir_name in PLUGIN_ROOT_DIRS: if dir_name == "hooks": continue - _collect_recursive(project_root / dir_name, dir_name, components) + _collect_recursive(project_root / dir_name, dir_name, components, ignore=ignore) return components @@ -245,6 +251,7 @@ def _collect_bare_skill( slug = _normalize_bare_skill_slug(getattr(dep, "virtual_path", "") or "") if not slug: slug = dep.repo_url.rsplit("/", 1)[-1] if dep.repo_url else "skill" + ignore = ApmIgnoreSpec.load(install_path) for f in sorted(install_path.iterdir()): if ( f.is_file() @@ -255,6 +262,7 @@ def _collect_bare_skill( "apm.lock.yaml", "plugin.json", ) + and not ignore.is_ignored(f, is_dir=False) ): out.append((f, f"skills/{slug}/{f.name}")) @@ -268,12 +276,17 @@ def _collect_flat( out: list[tuple[Path, str]], *, rename=None, + ignore: ApmIgnoreSpec | None = None, ) -> None: """Add every regular non-symlink file directly inside *src_dir*.""" if src_dir.is_symlink() or not src_dir.is_dir(): return for f in sorted(src_dir.iterdir()): - if f.is_file() and not f.is_symlink(): + if ( + f.is_file() + and not f.is_symlink() + and not (ignore and ignore.is_ignored(f, is_dir=False)) + ): name = rename(f.name) if rename else f.name out.append((f, f"{output_prefix}/{name}")) @@ -284,6 +297,7 @@ def _collect_recursive( out: list[tuple[Path, str]], *, rename=None, + ignore: ApmIgnoreSpec | None = None, ) -> None: """Add every regular non-symlink file under *src_dir*, preserving hierarchy.""" if src_dir.is_symlink() or not src_dir.is_dir(): @@ -291,6 +305,8 @@ def _collect_recursive( for f in sorted(src_dir.rglob("*")): if not f.is_file() or f.is_symlink(): continue + if ignore is not None and ignore.is_ignored(f, is_dir=False): + continue rel = f.relative_to(src_dir) name = rename(rel.name) if rename else rel.name out_rel = (rel.parent / name).as_posix() @@ -583,6 +599,7 @@ def _collect_explicit_local_components( components: list[tuple[Path, str]] = [] hooks: dict = {} hooks_present = False + ignore = ApmIgnoreSpec.load(project_root) for declared_path in includes: parts = _deployed_path_parts(declared_path) candidate = project_root.joinpath(*parts) @@ -611,6 +628,8 @@ def _collect_explicit_local_components( f"{entry.name}. Remove the symlink or list a regular path." ) for file_path in (entry for entry in entries if entry.is_file()): + if ignore.is_ignored(file_path, is_dir=False): + continue try: file_path = ensure_path_within(file_path, project_root) except PathTraversalError as exc: diff --git a/src/apm_cli/constants.py b/src/apm_cli/constants.py index 91ae62b4f1..ebdc712eec 100644 --- a/src/apm_cli/constants.py +++ b/src/apm_cli/constants.py @@ -28,6 +28,7 @@ class InstallMode(Enum): GITHUB_DIR = ".github" CLAUDE_DIR = ".claude" GITIGNORE_FILENAME = ".gitignore" +APM_IGNORE_FILENAME = ".apmignore" APM_MODULES_GITIGNORE_PATTERN = "apm_modules/" diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index a423602392..f666dfe629 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -71,9 +71,10 @@ def _build_deployable_copy_ignore( source_plan: "DeployableSourcePlan | None" = None, exclude_apm: bool = False, exclude_apm_yml: bool = False, + package_root: Path | None = None, ) -> Callable[[str, list[str]], list[str]]: """Build a copy filter constrained to the authorized source plan.""" - base_ignore = build_copy_ignore(skip_bin=skip_bin) + base_ignore = build_copy_ignore(skip_bin=skip_bin, package_root=package_root) internal_names = ( *((".apm",) if exclude_apm else ()), *(("apm.yml",) if exclude_apm_yml else ()), @@ -365,7 +366,10 @@ def copy_skill_to_target( shutil.copytree( source_path, skill_dir, - ignore=_build_deployable_copy_ignore(source_plan=source_plan), + ignore=_build_deployable_copy_ignore( + source_plan=source_plan, + package_root=package_info.install_path, + ), ) rewriter = SkillIntegrator() rewriter.init_link_resolver(package_info, target_base) @@ -845,6 +849,9 @@ def _promote_sub_skills( ignore=_build_deployable_copy_ignore( skip_bin=skip_bin, source_plan=source_plan, + package_root=( + source_plan.source_root if source_plan is not None else sub_skill_path + ), ), ) if link_rewriter is not None: @@ -1198,7 +1205,6 @@ def _integrate_native_skill( _rich_warning(detail) shutil.rmtree(target_skill_dir) target_skill_dir.parent.mkdir(parents=True, exist_ok=True) - target_skill_dir.parent.mkdir(parents=True, exist_ok=True) from apm_cli.models.apm_package import PackageType as _PackageType shutil.copytree( @@ -1209,6 +1215,7 @@ def _integrate_native_skill( source_plan=source_plan, exclude_apm=True, exclude_apm_yml=package_info.package_type is _PackageType.MARKETPLACE_PLUGIN, + package_root=package_path, ), ) self._resolve_markdown_links_in_skill_bundle(package_path, target_skill_dir) @@ -1299,6 +1306,7 @@ def _integrate_skill_bundle( SkillIntegrationResult with all promoted skills. """ self.init_link_resolver(package_info, project_root) + package_path = package_info.install_path if targets is None: from apm_cli.integration.targets import active_targets @@ -1308,9 +1316,7 @@ def _integrate_skill_bundle( # which is just the repo/leaf name and collides across owners (see # _build_ownership_maps). _dep_ref = getattr(package_info, "dependency_ref", None) - parent_name = ( - _dep_ref.get_unique_key() if _dep_ref is not None else package_info.install_path.name - ) + parent_name = _dep_ref.get_unique_key() if _dep_ref is not None else package_path.name owned_by, lockfile_native_owners = self._build_ownership_maps(project_root) # noqa: RUF059 total_promoted = 0 diff --git a/src/apm_cli/integration/skill_support.py b/src/apm_cli/integration/skill_support.py index d225f2e879..c7dc20eda7 100644 --- a/src/apm_cli/integration/skill_support.py +++ b/src/apm_cli/integration/skill_support.py @@ -11,18 +11,25 @@ def build_copy_ignore( *, skip_bin: bool = False, + package_root: Path | None = None, ) -> Callable[[str, list[str]], list[str]]: """Build a ``shutil.copytree`` ignore function.""" from apm_cli.security.gate import ignore_non_content - - if not skip_bin: + from apm_cli.utils.apmignore import ApmIgnoreSpec + + extras: list[Callable[[str, list[str]], list[str]]] = [] + if skip_bin: + extras.append(shutil.ignore_patterns("bin")) + if package_root is not None: + return ApmIgnoreSpec.load(package_root).copytree_ignore(*extras) + if not extras: return ignore_non_content - bin_filter = shutil.ignore_patterns("bin") def combined(directory: str, contents: list[str]) -> list[str]: - return list( - set(ignore_non_content(directory, contents)) | set(bin_filter(directory, contents)) - ) + dropped = set(ignore_non_content(directory, contents)) + for extra in extras: + dropped.update(extra(directory, contents)) + return list(dropped) return combined diff --git a/src/apm_cli/primitives/discovery.py b/src/apm_cli/primitives/discovery.py index 526b367d7b..02b1675c5d 100644 --- a/src/apm_cli/primitives/discovery.py +++ b/src/apm_cli/primitives/discovery.py @@ -98,7 +98,10 @@ def clear_discovery_cache() -> None: from earlier runs (tests, REPL, long-lived processes) cannot leak into the next install's discovery results. """ + from ..utils.apmignore import clear_apmignore_cache + _DISCOVERY_CACHE.clear() + clear_apmignore_cache() def _discovery_cache_key( @@ -447,6 +450,7 @@ def _scan_patterns( patterns: dict[str, list[str]], collection: PrimitiveCollection, source: str, + ignore: object | None = None, inventory: CompileInventory | None = None, ) -> None: """Walk *base_dir* once, match files against all patterns, parse and collect. @@ -479,6 +483,8 @@ def _scan_patterns( files = inventory.files_within(base_dir) for file_path in files: + if ignore is not None and ignore.is_ignored(file_path, is_dir=False): + continue rel_path = file_path.relative_to(base_dir).as_posix() if not _matches_any_pattern(rel_path, all_patterns): continue @@ -503,6 +509,10 @@ def scan_directory_with_source( collection (PrimitiveCollection): Collection to add primitives to. source (str): Source identifier for discovered primitives. """ + from ..utils.apmignore import ApmIgnoreSpec + + ignore = ApmIgnoreSpec.load(directory) + # Scan .apm directory within the dependency apm_dir = directory / ".apm" if apm_dir.exists(): @@ -511,6 +521,7 @@ def scan_directory_with_source( DEPENDENCY_PRIMITIVE_PATTERNS, collection, source, + ignore=ignore, inventory=inventory, ) @@ -524,6 +535,7 @@ def scan_directory_with_source( DEPENDENCY_GITHUB_PRIMITIVE_PATTERNS, collection, source, + ignore=ignore, inventory=inventory, ) @@ -532,6 +544,7 @@ def scan_directory_with_source( directory, collection, source, + ignore=ignore, inventory=inventory, ) @@ -553,6 +566,11 @@ def _discover_local_skill( if should_exclude(skill_path, Path(base_dir), exclude_patterns): logger.debug("Excluded by pattern: %s", skill_path) return + from ..utils.apmignore import ApmIgnoreSpec + + if ApmIgnoreSpec.load(Path(base_dir)).is_ignored(skill_path, is_dir=False): + logger.debug("Excluded by package ignore spec: %s", skill_path) + return try: skill = parse_skill_file(skill_path, source="local") collection.add_primitive(skill) @@ -564,6 +582,7 @@ def _discover_skill_in_directory( directory: Path, collection: PrimitiveCollection, source: str, + ignore: object | None = None, inventory: CompileInventory | None = None, ) -> None: """Discover SKILL.md in a package directory. @@ -572,10 +591,13 @@ def _discover_skill_in_directory( directory (Path): Package directory to check. collection (PrimitiveCollection): Collection to add skill to. source (str): Source identifier for the skill. + ignore: Optional package ignore spec. """ if inventory is not None and inventory.nested_repository_root_for(directory) is not None: return skill_path = directory / "SKILL.md" + if ignore is not None and ignore.is_ignored(skill_path, is_dir=False): + return if not skill_path.is_symlink() and skill_path.exists() and _is_readable(skill_path): try: skill = parse_skill_file(skill_path, source=source) @@ -676,6 +698,9 @@ def find_primitive_files( started = time.perf_counter() base_path = Path(base_dir).resolve() + from ..utils.apmignore import ApmIgnoreSpec + + ignore = ApmIgnoreSpec.load(base_path) pattern_tuples: list[tuple[str, ...]] = [ tuple(p for p in pat.split("/") if p) for pat in patterns ] @@ -689,6 +714,7 @@ def find_primitive_files( pattern_tuples, exclude_patterns, started, + ignore=ignore, ) @@ -698,6 +724,7 @@ def _find_primitive_inventory_files( pattern_tuples: list[tuple[str, ...]], exclude_patterns: list[str] | None, started: float, + ignore: object | None = None, ) -> list[Path]: """Classify a compile inventory with the existing primitive glob grammar.""" candidates = inventory.files_within(base_path) @@ -711,6 +738,8 @@ def _find_primitive_inventory_files( continue if exclude_patterns and should_exclude(file_path, base_path, exclude_patterns): continue + if ignore is not None and ignore.is_ignored(file_path, is_dir=False): + continue if file_path.is_file() and not file_path.is_symlink(): valid_files.append(file_path) diff --git a/src/apm_cli/utils/apmignore.py b/src/apm_cli/utils/apmignore.py new file mode 100644 index 0000000000..437311d57f --- /dev/null +++ b/src/apm_cli/utils/apmignore.py @@ -0,0 +1,218 @@ +"""Canonical owner for ``.apmignore`` package membership. + +Every install-deploy copy, pack walk, and compile/discovery walk that +needs to know whether a path ships must call :class:`ApmIgnoreSpec`. +Do not parse ``.apmignore`` anywhere else. +""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from pathspec import GitIgnoreSpec + +from apm_cli.constants import ( + APM_IGNORE_FILENAME, + APM_YML_FILENAME, + DEFAULT_SKIP_DIRS, + SKILL_MD_FILENAME, +) +from apm_cli.utils.paths import portable_relpath + +_REQUIRED_ROOT_FILES = (SKILL_MD_FILENAME, APM_YML_FILENAME) +_LOAD_CACHE: dict[str, ApmIgnoreSpec] = {} + +CopyIgnore = Callable[[str, list[str]], list[str]] + + +class ApmIgnoreError(ValueError): + """Raised when ``.apmignore`` would drop a required package file.""" + + +@dataclass(frozen=True) +class _IgnoreLayer: + """One ``.apmignore`` file, scoped to the directory that contains it.""" + + directory: Path + rel_prefix: str + spec: GitIgnoreSpec + + +class ApmIgnoreSpec: + """Loaded ``.apmignore`` rules for one package root.""" + + def __init__(self, package_root: Path, layers: tuple[_IgnoreLayer, ...]) -> None: + self.package_root = package_root + self._layers = layers + + @classmethod + def load(cls, package_root: Path) -> ApmIgnoreSpec: + """Load root and nested ``.apmignore`` files under *package_root*.""" + try: + root = package_root.resolve() + except (OSError, RuntimeError): + root = package_root.absolute() + cache_key = str(root) + cached = _LOAD_CACHE.get(cache_key) + if cached is not None: + return cached + layers: list[_IgnoreLayer] = [] + if root.is_dir(): + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + current = Path(dirpath) + dirnames[:] = sorted( + name + for name in dirnames + if name not in DEFAULT_SKIP_DIRS and not (current / name).is_symlink() + ) + if APM_IGNORE_FILENAME not in filenames: + continue + ignore_file = current / APM_IGNORE_FILENAME + if ignore_file.is_symlink() or not ignore_file.is_file(): + raise ApmIgnoreError( + f"Cannot load {APM_IGNORE_FILENAME} at {ignore_file}: " + "must be a regular file, not a symlink or directory" + ) + rel_prefix = portable_relpath(current, root) + if rel_prefix in {".", ""} or rel_prefix == current.as_posix(): + rel_prefix = "" + try: + text = ignore_file.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise ApmIgnoreError( + f"Cannot read {APM_IGNORE_FILENAME} at {ignore_file}: {exc}" + ) from exc + layers.append( + _IgnoreLayer( + directory=current, + rel_prefix=rel_prefix, + spec=GitIgnoreSpec.from_lines(text.splitlines()), + ) + ) + layers.sort(key=lambda layer: (layer.rel_prefix.count("/"), layer.rel_prefix)) + spec = cls(root, tuple(layers)) + spec.validate_required() + _LOAD_CACHE[cache_key] = spec + return spec + + def validate_required(self) -> None: + """Refuse to ignore ``SKILL.md`` or ``apm.yml`` at the package root.""" + for name in _REQUIRED_ROOT_FILES: + candidate = self.package_root / name + try: + exists = candidate.is_file() and not candidate.is_symlink() + except OSError: + exists = False + if exists and self.is_ignored(candidate, is_dir=False): + raise ApmIgnoreError(f".apmignore cannot exclude required file {name}") + + def is_ignored(self, path: Path, *, is_dir: bool | None = None) -> bool: + """Return True when *path* is excluded by the loaded rules.""" + if not self._layers: + return False + rel_posix = self._rel_posix(path) + if rel_posix is None: + return False + if is_dir is None: + try: + is_dir = path.is_dir() and not path.is_symlink() + except OSError: + is_dir = False + parts = [part for part in rel_posix.split("/") if part] + for index in range(1, len(parts)): + ancestor = "/".join(parts[:index]) + if self._match_layers(ancestor, is_dir=True): + return True + return self._match_layers(rel_posix, is_dir=bool(is_dir)) + + def copytree_ignore(self, *extras: CopyIgnore) -> CopyIgnore: + """Return a ``shutil.copytree`` ignore callback for this package.""" + from apm_cli.security.gate import ignore_non_content + + extra_callbacks: tuple[CopyIgnore, ...] = (ignore_non_content, *extras) + + def ignore(directory: str, contents: list[str]) -> list[str]: + dropped: set[str] = set() + for extra in extra_callbacks: + dropped.update(extra(directory, contents)) + current = Path(directory) + for name in contents: + if name in dropped: + continue + candidate = current / name + try: + is_dir = candidate.is_dir() and not candidate.is_symlink() + except OSError: + is_dir = False + if self.is_ignored(candidate, is_dir=is_dir): + dropped.add(name) + return list(dropped) + + return ignore + + def _rel_posix(self, path: Path) -> str | None: + rel = portable_relpath(path, self.package_root) + if rel in {".", ""}: + return None + try: + resolved = path.resolve() + except (OSError, RuntimeError): + resolved = path.absolute() + try: + resolved.relative_to(self.package_root) + except ValueError: + return None + return rel + + def _match_layers(self, rel_posix: str, *, is_dir: bool) -> bool: + ignored = False + for layer in self._layers: + layer_rel = _relative_to_layer(rel_posix, layer.rel_prefix) + if layer_rel is None: + continue + decision = _layer_decision(layer.spec, layer_rel, is_dir=is_dir) + if decision is not None: + ignored = decision + return ignored + + +def _relative_to_layer(rel_posix: str, layer_prefix: str) -> str | None: + """Return *rel_posix* relative to a nested ignore file, or None.""" + if not layer_prefix: + return rel_posix + if rel_posix == layer_prefix: + return None + prefix = layer_prefix + "/" + if not rel_posix.startswith(prefix): + return None + return rel_posix[len(prefix) :] + + +def _layer_decision(spec: GitIgnoreSpec, rel_posix: str, *, is_dir: bool) -> bool | None: + """Last matching pattern in one file: True ignore, False include, None none.""" + candidates = [rel_posix] + if is_dir and not rel_posix.endswith("/"): + candidates.append(rel_posix + "/") + best_include: bool | None = None + best_index = -1 + for candidate in candidates: + result = spec.check_file(candidate) + if result.include is None or result.index is None: + continue + if result.index >= best_index: + best_include = bool(result.include) + best_index = result.index + return best_include + + +def load_apmignore(package_root: Path) -> ApmIgnoreSpec: + """Load ``.apmignore`` rules for *package_root*.""" + return ApmIgnoreSpec.load(package_root) + + +def clear_apmignore_cache() -> None: + """Drop cached ignore specs. Called from discovery cache invalidation.""" + _LOAD_CACHE.clear() diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index f144e2f470..90b225ff14 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -6,7 +6,7 @@ every guard executes exactly once per run. Names prove nothing about teeth: a rule whose body was gutted still registers its guard ID and still runs. -This file supplies the missing half of that contract. For each of the 55 +This file supplies the missing half of that contract. For each of the 58 registered owner guards it pins one minimal, meaningful source mutation -- a surgical edit that kills a load-bearing sub-condition of the owning decision -- and asserts the one rule that owns that guard reports a real `Violation`. @@ -83,6 +83,14 @@ class MutationCase: new='getattr(module, "pytest_mark"', intent="Taxonomy inventory stops reading the canonical module-level pytestmark.", ), + MutationCase( + guard_id="contracts-tooling-apmignore-membership", + rule_id="contracts-tooling-apmignore-membership", + path="src/apm_cli/utils/apmignore.py", + old="class ApmIgnoreSpec:", + new="class ApmIgnoreSpecV2:", + intent="Package membership owner loses the one canonical ApmIgnoreSpec class.", + ), MutationCase( guard_id="contracts-tooling-apply-to-placement", rule_id="contracts-tooling-apply-to-placement", diff --git a/tests/unit/scripts/test_architecture_runner.py b/tests/unit/scripts/test_architecture_runner.py index a8fba7d660..bc7b00bf1d 100644 --- a/tests/unit/scripts/test_architecture_runner.py +++ b/tests/unit/scripts/test_architecture_runner.py @@ -600,6 +600,7 @@ def exiting_import( contracts-tests-lifecycle-smoke-partition contracts-tests-taxonomy-classification contracts-tooling-ado-lock-coordinates +contracts-tooling-apmignore-membership contracts-tooling-apply-to-placement contracts-tooling-cached-policy-shape contracts-tooling-dependency-identity diff --git a/tests/unit/utils/test_apmignore.py b/tests/unit/utils/test_apmignore.py new file mode 100644 index 0000000000..da9072d9f4 --- /dev/null +++ b/tests/unit/utils/test_apmignore.py @@ -0,0 +1,191 @@ +"""Tests for the canonical .apmignore owner.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from apm_cli.utils.apmignore import ApmIgnoreError, ApmIgnoreSpec, clear_apmignore_cache + +pytestmark = pytest.mark.component + + +@pytest.fixture(autouse=True) +def _clear_ignore_cache() -> None: + clear_apmignore_cache() + yield + clear_apmignore_cache() + + +def _write(root: Path, rel: str, content: str = "x") -> Path: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +def _spec(root: Path, body: str, *, nested: dict[str, str] | None = None) -> ApmIgnoreSpec: + _write(root, ".apmignore", body) + for rel, nested_body in (nested or {}).items(): + _write(root, rel, nested_body) + return ApmIgnoreSpec.load(root) + + +def test_no_ignore_file_keeps_everything(tmp_path: Path) -> None: + skill = _write(tmp_path, "SKILL.md", "# skill") + evals = _write(tmp_path, "evals/foo.md") + spec = ApmIgnoreSpec.load(tmp_path) + assert spec.is_ignored(skill) is False + assert spec.is_ignored(evals) is False + + +def test_comments_and_blank_lines_are_ignored(tmp_path: Path) -> None: + _write(tmp_path, "SKILL.md", "# skill") + kept = _write(tmp_path, "keep.md") + spec = _spec(tmp_path, "# just a comment\n\n") + assert spec.is_ignored(kept) is False + + +def test_star_and_double_star(tmp_path: Path) -> None: + _write(tmp_path, "SKILL.md", "# skill") + log = _write(tmp_path, "notes.log") + nested_log = _write(tmp_path, "refs/debug.log") + md = _write(tmp_path, "refs/guide.md") + spec = _spec(tmp_path, "*.log\n") + assert spec.is_ignored(log) is True + assert spec.is_ignored(nested_log) is True + assert spec.is_ignored(md) is False + + +def test_directory_only_slash(tmp_path: Path) -> None: + _write(tmp_path, "SKILL.md", "# skill") + evals_dir = tmp_path / "evals" + evals_dir.mkdir() + evals_file = _write(tmp_path, "evals/case.md") + evals_name = _write(tmp_path, "evals.txt") + spec = _spec(tmp_path, "evals/\n") + assert spec.is_ignored(evals_dir, is_dir=True) is True + assert spec.is_ignored(evals_file) is True + assert spec.is_ignored(evals_name) is False + + +def test_last_match_wins_negation(tmp_path: Path) -> None: + _write(tmp_path, "SKILL.md", "# skill") + dropped = _write(tmp_path, "foo.log") + kept = _write(tmp_path, "keep.log") + spec = _spec(tmp_path, "*.log\n!keep.log\n") + assert spec.is_ignored(dropped) is True + assert spec.is_ignored(kept) is False + + +def test_parent_dir_blocks_child_negation(tmp_path: Path) -> None: + _write(tmp_path, "SKILL.md", "# skill") + readme = _write(tmp_path, "evals/README.md") + spec = _spec(tmp_path, "evals/\n!evals/README.md\n") + assert spec.is_ignored(readme) is True + + +def test_unignore_file_when_directory_itself_is_not_ignored(tmp_path: Path) -> None: + _write(tmp_path, "SKILL.md", "# skill") + readme = _write(tmp_path, "evals/README.md") + other = _write(tmp_path, "evals/secret.md") + spec = _spec(tmp_path, "evals/*\n!evals/README.md\n") + assert spec.is_ignored(readme) is False + assert spec.is_ignored(other) is True + + +def test_nested_apmignore_last_match_wins(tmp_path: Path) -> None: + _write(tmp_path, "SKILL.md", "# skill") + local = _write(tmp_path, "skills/foo/tmp/note.md") + spec = _spec( + tmp_path, + "tmp/\n", + nested={"skills/foo/.apmignore": "!tmp/\n!tmp/note.md\n"}, + ) + assert spec.is_ignored(local) is False + + +def test_unreadable_apmignore_fails_closed(tmp_path: Path, monkeypatch) -> None: + _write(tmp_path, "SKILL.md", "# skill") + ignore_path = _write(tmp_path, ".apmignore", "evals/\n") + original = Path.read_text + + def _blocked(self, *args, **kwargs): + if self.name == ".apmignore": + raise OSError("permission denied") + return original(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", _blocked) + with pytest.raises(ApmIgnoreError, match="Cannot read"): + ApmIgnoreSpec.load(tmp_path) + assert ignore_path.exists() + + +def test_cannot_ignore_skill_md(tmp_path: Path) -> None: + _write(tmp_path, "SKILL.md", "# skill") + _write(tmp_path, ".apmignore", "SKILL.md\n") + with pytest.raises(ApmIgnoreError, match=r"SKILL\.md"): + ApmIgnoreSpec.load(tmp_path) + + +def test_cannot_ignore_apm_yml(tmp_path: Path) -> None: + _write(tmp_path, "apm.yml", "name: demo\nversion: 0.0.1\n") + _write(tmp_path, ".apmignore", "apm.yml\n") + with pytest.raises(ApmIgnoreError, match=r"apm\.yml"): + ApmIgnoreSpec.load(tmp_path) + + +def test_path_outside_package_is_not_ignored(tmp_path: Path) -> None: + _write(tmp_path, "SKILL.md", "# skill") + spec = _spec(tmp_path, "secret.md\n") + outside = tmp_path.parent / "other.txt" + outside.write_text("x", encoding="utf-8") + assert spec.is_ignored(outside) is False + + +def test_copytree_drops_evals_and_keeps_skill(tmp_path: Path) -> None: + src = tmp_path / "pkg" + src.mkdir() + _write(src, "SKILL.md", "# skill") + _write(src, "references/guide.md") + _write(src, "evals/case.md") + spec = _spec(src, "evals/\n") + dest = tmp_path / "dest" + shutil.copytree(src, dest, ignore=spec.copytree_ignore()) + copied = sorted(p.relative_to(dest).as_posix() for p in dest.rglob("*") if p.is_file()) + assert copied == [".apmignore", "SKILL.md", "references/guide.md"] + + +def test_discovery_skips_ignored_instruction(tmp_path: Path) -> None: + from apm_cli.primitives.discovery import find_primitive_files + + _write(tmp_path, "SKILL.md", "# skill") + kept = _write( + tmp_path, + ".apm/instructions/keep.instructions.md", + "---\napplyTo: '**'\n---\nkeep\n", + ) + _write( + tmp_path, + "evals/hidden.instructions.md", + "---\napplyTo: '**'\n---\nhidden\n", + ) + _spec(tmp_path, "evals/\n") + found = find_primitive_files(str(tmp_path), ["**/*.instructions.md"]) + assert kept.resolve() in {path.resolve() for path in found} + assert all(path.name != "hidden.instructions.md" for path in found) + + +def test_pack_recursive_collect_skips_evals(tmp_path: Path) -> None: + from apm_cli.bundle.plugin_exporter import _collect_recursive + + _write(tmp_path, "SKILL.md", "# skill") + _write(tmp_path, ".apm/skills/demo/SKILL.md", "---\nname: demo\n---\n") + _write(tmp_path, ".apm/skills/demo/evals/case.md") + spec = _spec(tmp_path, "evals/\n") + out: list[tuple[Path, str]] = [] + _collect_recursive(tmp_path / ".apm" / "skills", "skills", out, ignore=spec) + rels = sorted(rel for _src, rel in out) + assert rels == ["skills/demo/SKILL.md"] diff --git a/uv.lock b/uv.lock index 92645f1c39..d47ee5b873 100644 --- a/uv.lock +++ b/uv.lock @@ -212,6 +212,7 @@ dependencies = [ { name = "gitpython" }, { name = "llm" }, { name = "llm-github-models" }, + { name = "pathspec" }, { name = "python-frontmatter" }, { name = "pyyaml" }, { name = "requests" }, @@ -255,6 +256,7 @@ requires-dist = [ { name = "llm-github-models", specifier = ">=0.18.0" }, { name = "mutmut", marker = "extra == 'dev'", specifier = "==3.6.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "pathspec", specifier = ">=0.12.0" }, { name = "pyinstaller", marker = "extra == 'build'", specifier = ">=6.0.0" }, { name = "pylint", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, @@ -562,7 +564,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [