From ee93d0bbf7e51aeb856eb0ae40c44d0f3e2edaa1 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 17 Sep 2026 20:29:05 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(foundation):=20=F0=9F=A7=B1=20define?= =?UTF-8?q?=20scoped=20ignore=20composition=20plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the universal baseline and a Rust overlay, validate explicit project scopes and source hashes, and preserve local text in deterministic proposals. Keep active-root migration as the next reviewed part. Refs #82 Roadmap-Step: EMP-Q04 --- .github/workflows/automation.yml | 8 +- docs/foundation/INVENTORY.md | 14 +- docs/foundation/README.md | 27 +- docs/foundation/gitignore/ITERATION-01.md | 50 ++- docs/foundation/gitignore/README.md | 238 ++++++++---- foundation/catalog.json | 28 +- .../contracts/empathy.gitignore-plan.json | 55 +++ foundation/empathy.manifest.json | 13 +- foundation/ignore/rust.gitignore | 3 + foundation/ignore/universal.gitignore | 4 +- ...pository-foundation-catalog.v1.schema.json | 83 ++++- ...ository-foundation-manifest.v1.schema.json | 37 +- tests/test_foundation_ignore.py | 343 ++++++++++++++++++ tests/test_gitignore_baseline.py | 8 +- tools/foundation.py | 110 +++++- tools/foundation_ignore.py | 229 ++++++++++++ 16 files changed, 1127 insertions(+), 123 deletions(-) create mode 100644 foundation/contracts/empathy.gitignore-plan.json create mode 100644 foundation/ignore/rust.gitignore create mode 100644 tests/test_foundation_ignore.py create mode 100644 tools/foundation_ignore.py diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index 19d4ec4d..91360bc1 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -19,7 +19,9 @@ on: - "REUSE.toml" - "Taskfile.yml" - "egolint/**" - - "foundation/ignore/**" + - "foundation/**" + - "schemas/repository-foundation-*.schema.json" + - "tools/foundation*.py" - "tests/**" push: branches: @@ -38,7 +40,9 @@ on: - "REUSE.toml" - "Taskfile.yml" - "egolint/**" - - "foundation/ignore/**" + - "foundation/**" + - "schemas/repository-foundation-*.schema.json" + - "tools/foundation*.py" - "tests/**" workflow_dispatch: diff --git a/docs/foundation/INVENTORY.md b/docs/foundation/INVENTORY.md index 6f16ad1a..ba3d3a18 100644 --- a/docs/foundation/INVENTORY.md +++ b/docs/foundation/INVENTORY.md @@ -2,7 +2,7 @@ > Generated from `foundation/catalog.json`. Do not edit by hand. -- Contract: `empathy/repository-foundation@1.0.0` +- Contract: `empathy/repository-foundation@1.1.0` - Canonical owner: `egohygiene/empathy` - Canonical artifacts: `30` @@ -18,7 +18,7 @@ | `.github/workflows/codeql.yml` | security | profile | required | risk-hardened | Static security analysis caller | | `.github/workflows/dependency-review.yml` | security | profile | required | risk-hardened | Dependency change gate | | `.github/workflows/megalinter.yml` | quality | profile | required | quality-baseline | Thin repository caller for reusable quality automation | -| `.gitignore` | metadata | required | repository-owned | — | Repository-specific generated and local-state exclusions | +| `.gitignore` | metadata | required | repository-owned | — | Repository-owned exclusions with explicit, scoped baseline composition | | `.identity/identity.toml` | metadata | profile | repository-owned | product | Consumer-owned product identity input | | `.mega-linter.yml` | quality | profile | repository-owned | quality-baseline | Repository-selected EgoLint and MegaLinter policy overlay | | `.release-please-manifest.json` | release | profile | repository-owned | release-automated | Repository release state | @@ -39,10 +39,20 @@ | `pyproject.toml` | metadata | profile | repository-owned | language-python | Python project intent | | `release-please-config.json` | release | profile | repository-owned | release-automated | Repository release strategy | +## Gitignore composition sources + +Sources are owned by `egohygiene/empathy`; they are not required consumer paths. + +| ID | Profile | Source path | SHA-256 | +| --- | --- | --- | --- | +| `universal` | all declared scopes | `foundation/ignore/universal.gitignore` | `79280ac4f3147ead97a0b21b01f40241238f08fa5d63abe3f81c8b64e7f179f0` | +| `rust-build` | language-rust | `foundation/ignore/rust.gitignore` | `16627c4b93c30be2aa2c977a48ee3157d0a7b8ff44e02008b9085f0bd8d94f96` | + ## Generated outputs | Path | Owner | Canonical input | Checked in | | --- | --- | --- | --- | | `docs/ecosystem/CONTEXT.md` | `egohygiene/hygiene` | `catalog/repositories.yaml + catalog/repository-context.json` | `true` | | `docs/foundation/INVENTORY.md` | `egohygiene/empathy` | `foundation/catalog.json` | `true` | +| `foundation/contracts/empathy.gitignore-plan.json` | `egohygiene/empathy` | `foundation/catalog.json + foundation/empathy.manifest.json + foundation/ignore/*` | `true` | | `foundation/contracts/empathy.repository-contract.toml` | `egohygiene/empathy` | `foundation/catalog.json + foundation/empathy.manifest.json` | `true` | diff --git a/docs/foundation/README.md b/docs/foundation/README.md index 954fbc03..b0ebf0f7 100644 --- a/docs/foundation/README.md +++ b/docs/foundation/README.md @@ -18,15 +18,24 @@ profiles. - `docs/foundation/INVENTORY.md` is the deterministic human-readable inventory. - `foundation/contracts/empathy.repository-contract.toml` is the canonical, - offline EgoLint projection. + offline EgoLint presence/ownership projection. +- `foundation/contracts/empathy.gitignore-plan.json` is a deterministic proposal + with source and output hashes, not active-root adoption evidence. ## Gitignore contract under review -The [layered gitignore proposal](gitignore/README.md) records the candidate -universal rules, the existing-rule audit, Git behavior checks, and the first -file-contract review checkpoint. It is the first bounded part of -[issue #82](https://github.com/egohygiene/empathy/issues/82); catalog integration -and adoption remain subsequent work. +The [layered gitignore contract](gitignore/README.md) registers the universal +baseline and a Rust overlay in foundation `1.1.0`. The manifest explicitly +selects project roots, overlays, and repository-owned local additions. Planning +checks source hashes and emits JSON; Holon retains filesystem materialization +ownership. This is part 2 of +[issue #82](https://github.com/egohygiene/empathy/issues/82). +Golden-root migration and Filament adoption remain subsequent work. + +The v1 schema filenames retain their major version; catalog/schema versions and +manifest references advance together to `1.1.0`. The resolver requires an exact +version match. Existing `1.0.0` consumers keep their pinned input and resolver +until explicitly upgraded. The selected Holon contract stays at `1.0.0`. ## Composition boundary @@ -48,6 +57,8 @@ filesystem mutation engine. Identical catalog and manifest inputs resolve to byte-identical JSON and EgoLint TOML. Re-resolving the checked-in Empathy manifest produces no diff. Holon may later consume this released catalog when planning and materializing repositories; Pace may propose reviewed upgrades. +The EgoLint projection still describes paths, ownership, executable flags, and +markers; its version bump does not introduce ignore-content conformance. ## Validation @@ -61,6 +72,10 @@ python3 tools/foundation.py validate-manifest \ --manifest "foundation/empathy.manifest.json" python3 tools/foundation.py check-inventory \ --output "docs/foundation/INVENTORY.md" +python3 tools/foundation.py check-gitignore-plan \ + --manifest "foundation/empathy.manifest.json" \ + --source-root "." \ + --output "foundation/contracts/empathy.gitignore-plan.json" python3 tools/foundation.py check-contract \ --manifest "foundation/empathy.manifest.json" \ --source-revision "<40-character-empathy-commit>" \ diff --git a/docs/foundation/gitignore/ITERATION-01.md b/docs/foundation/gitignore/ITERATION-01.md index e7e2c262..a1177311 100644 --- a/docs/foundation/gitignore/ITERATION-01.md +++ b/docs/foundation/gitignore/ITERATION-01.md @@ -67,9 +67,37 @@ completed CI results. A passing local command or a merged PR does not establish that those checks passed. The known catalog/workflow pin mismatch remains a separate failure; this correction does not claim all-green repository CI. -After the correction is reviewed, take profile composition and contract -integration as the next bounded PR. Keep golden-root adoption in a later PR so -the composition model can be reviewed before its migration effects. +[PR #86](https://github.com/egohygiene/empathy/pull/86) merged as +`60a002418304ae379d373dc2d377b5b7cf1e2ceb`. Its completed MegaLinter run passed. +The next inspection verified that merge before starting part 2 from Empathy +`4c87f02849949a5fe04d79f36f65eca4b3c28486`. + +## Part 2 checkpoint: composition and contract integration + +Foundation `1.1.0` registers the universal baseline and explicitly scoped Rust +build overlay on the existing required, repository-owned `gitignore` artifact. +The manifest chooses project roots and local text. Composition orders selected +overlays, verbatim local additions, and the baseline last in every declared +scope. The Rust fixture proves scoped build exclusion and a reviewed exception +with adjacent output still ignored. + +`tools/foundation.py plan-gitignore` emits a JSON proposal with source and content +hashes. `check-gitignore-plan` detects drift. The golden plan is not an assertion +that the active root matches it. Inventory and EgoLint projections are +regenerated; the latter still checks presence/ownership and markers, not ignore +content. Its source revision must point to a real commit containing this +catalog, not the old main revision. + +Preservation, updates, rollback, source identity, schema compatibility, and the +unmanaged-nested-file limit are recorded in the [contract](README.md). Filesystem +merging remains Holon's responsibility. This part performs no root migration, +release, Filament adoption, or fleet operation. It contributes to `EMP-Q04`; +the roadmap step and #82 remain incomplete. + +Review the explicit scope selection, baseline-last precedence, verbatim local +text model, and source integrity contract in the linked part 2 PR. Record exact +test results and completed CI there, then update #82 and the master epic. Stop +for maintainer review before beginning migration. ## Edges learned in this part @@ -88,20 +116,10 @@ the composition model can be reviewed before its migration effects. - Merging this part accepts a bounded source proposal; it does not prove materialization, reusable CI conformance, or fleet adoption. -## Next bounded part after review +## Next bounded part after part 2 review -Reverify the merged part 1 PR and its validation correction. Then prepare one -Empathy PR for scoped profile composition and deterministic contract integration: - -- Define the artifact identity, composition order, preservation, provenance, - and rollback behavior using the existing foundation catalog/manifest model. -- Give applicable overlays explicit owners, selection conditions, and project - roots. A selected Rust project can ignore its own `/target/` without hiding - source under an unrelated `target/` directory. -- Regenerate affected inventory/EgoLint projections and prove repeatability, - profile scope, and local exceptions in composition fixtures. - -A subsequent PR migrates Empathy's active root. Before that change, resolve every +Verify the part 2 merge and its review decisions, then prepare one PR to migrate +Empathy's active root. Before that change, resolve every remaining audit relocation against actual project roots and keep private-material protection explicit and tested. Check for newly visible untracked files without exposing contents. Keep #82 open until its full acceptance criteria are met. diff --git a/docs/foundation/gitignore/README.md b/docs/foundation/gitignore/README.md index 590bc8ff..a7a78fa3 100644 --- a/docs/foundation/gitignore/README.md +++ b/docs/foundation/gitignore/README.md @@ -1,99 +1,181 @@ # Layered gitignore contract -This is the **part 1 proposal** for [Empathy #82](https://github.com/egohygiene/empathy/issues/82). -Review the universal rules and their behavior before adopting them. The -[iteration checkpoint](ITERATION-01.md) records the process and next bounded step. +This is the **part 2 composition contract** for [Empathy #82](https://github.com/egohygiene/empathy/issues/82). +Part 1's universal rules are registered for planning; active-root adoption +remains a separate review. The [iteration checkpoint](ITERATION-01.md) records +the process and next bounded step. ## Contract record -| Field | Proposal | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Purpose | Keep disposable local state out of Git without hiding reasonable source or reviewed artifacts. | -| Consumer path | `.gitignore`; already required and repository-owned in the foundation catalog. | -| Applicability | Universal candidate plus explicitly selected profile rules and justified repository-local rules. | -| Canonical source | [`foundation/ignore/universal.gitignore`](../../../foundation/ignore/universal.gitignore), owned by Empathy. | -| Content model | Curated baseline with explicit profile composition and local additions; composition is not implemented in this part. | -| Source identity | Review the source at a commit; no released artifact version or downstream pin exists yet. | -| Local variation | Narrow project rules and reviewed exceptions; local rules must preserve required protections. | -| Update behavior | Candidate only. Catalog integration, composition ordering, provenance, preservation, and rollback mechanics remain part 2 work. | -| Validation | Actual Git ignore behavior in isolated temporary repositories, including intentionally visible paths and duplicate-rule detection. | -| Adoption | No golden-root or Filament adoption yet. The existing root `.gitignore` remains active. | +| Field | Contract | +| --------------- | ------------------------------------------------------------------------------------------- | +| Purpose | Keep disposable local state out of Git without hiding source or reviewed artifacts. | +| Consumer path | `.gitignore`; required and repository-owned in the existing foundation catalog. | +| Applicability | Universal baseline in every declared scope, selected profile overlays, and local rules. | +| Canonical owner | Empathy owns baseline/profile sources; consumers own local additions. | +| Content model | Selected overlays in order, verbatim local additions, then the universal baseline. | +| Source identity | Foundation `1.1.0`, format `empathy.gitignore/v1`, source IDs/paths and SHA-256 hashes. | +| Local variation | Narrow project rules and reviewed exceptions that retain baseline protections. | +| Update behavior | Emit/check a deterministic JSON plan; planning never reads or writes consumer ignore files. | +| Validation | Catalog/manifest validation, source integrity, repeatability, and actual Git behavior. | +| Adoption | Golden-root migration and Filament adoption remain pending. | ## Universal rule decisions -The candidate has 27 active rules, including negations. It reserves only the -reviewed OS/editor debris, local environment patterns, private `.secrets/` -namespace, and unambiguous `.cache/`, `.tmp/`, `.venv/`, `__pycache__/`, and -`node_modules/` directories. These namespaces are local at every depth. +The [universal baseline](../../../foundation/ignore/universal.gitignore) has 27 +active rules, including negations. It reserves reviewed OS/editor debris, local +environment patterns, the private `.secrets/` namespace, and `.cache/`, `.tmp/`, +`.venv/`, `__pycache__/`, and `node_modules/` directories at every depth. - Keep shared `.vscode/` and `.idea/` configuration visible. Ignore only the named JetBrains user state covered by its [version-control guidance](https://intellij-support.jetbrains.com/hc/en-us/articles/206544839-How-to-manage-projects-under-Version-Control-Systems). - Ignore `.env` and local variants. Allow `.env.example`, `.env.sample`, - `.env.template`, and variants ending in those three suffixes, including + `.env.template`, and variants ending in those suffixes, including `.env.production.example`. Templates contain placeholders, never credentials. - `.env.example.local` remains ignored. Templates inside `.secrets/` remain hidden. + `.env.example.local` remains ignored, as do templates inside `.secrets/`. - Preserve lockfiles, public certificates, binary fixtures, archives, patches, - screenshots, snapshots, logs used as fixtures, and reviewed reports by default. -- Keep `bin/`, `build/`, `dist/`, `out/`, `target/`, `vendor/`, `coverage/`, and - similar ambiguous names out of the universal layer. Profile rules require - evidence of generated output and an appropriate project scope. + screenshots, snapshots, fixture logs, and reviewed reports by default. +- Keep ambiguous names such as `bin/`, `build/`, `dist/`, `out/`, `target/`, + `vendor/`, and `coverage/` out of the universal layer. Profile rules need + evidence of generated output and an explicit project scope. The [rule audit](RULE_AUDIT.md) classifies all 176 rules in the existing root at -the recorded revision. Its relocation proposals do not claim that profiles or -narrower secret protections have already been implemented. +the recorded revision. Its relocation proposals still need reconciliation +against actual project roots before active-root migration. + +## Selection and source identity + +The existing `gitignore` artifact in `foundation/catalog.json` has `composition` +metadata. Empathy owns all registered sources. `universal` names the baseline; +`rust-build` selects the [Rust overlay](../../../foundation/ignore/rust.gitignore) +and requires the resolved `language-rust` profile. Source paths are Empathy +inputs, not new required consumer files. Other language profiles do not yet +have ignore overlays. + +The optional manifest `gitignore` field declares every planned ignore file: + +```json +{ + "gitignore": { + "scopes": [ + { "root": ".", "overlays": [], "local_additions": "" }, + { + "root": "apps/rust", + "overlays": ["rust-build"], + "local_additions": "# Owner: repository maintainer; reason: reviewed build note.\n!/target/\n/target/*\n!/target/README.md\n" + } + ] + } +} +``` + +The root `.` must appear once. Roots are normalized repository-relative +directories, unique ignoring case; traversal, absolute paths, Git metadata, +glob characters, and ignore-file/directory collisions are rejected. Profiles +resolve through the existing dependency graph. Selecting `language-rust` alone +installs nothing: each project scope must explicitly select `rust-build`. Use +the Cargo workspace root when that workspace owns the build output. Empathy's +proposed root scope follows its root Cargo workspace; it does not claim the +other existing root exclusions have been migrated. + +Scopes sort by root. Overlay order within a scope is intentional and preserved. +Duplicate or unknown selections and overlays whose profiles are not selected +fail validation. Omitting `gitignore` remains a valid presence-only manifest; +requesting a plan without scopes fails. + +Planning verifies every registered source's raw UTF-8 SHA-256, including +unselected overlays. Missing files, source paths escaping the supplied source +root, duplicate active rules, and unanchored overlay patterns fail. Each overlay +pattern must start with `/` or `!/`, relative to its selected scope. Canonical +fragments and local additions use LF; nonempty text ends in LF. ## Layering and exceptions -Proposed ownership remains: Empathy owns baseline/profile policy, consumers own -their local facts, Holon owns materialization, EgoLint owns conformance semantics, -Relay runs reusable checks, and Pace owns reviewed fleet convergence. +Empathy owns baseline/profile policy, consumers own local facts, Holon owns +materialization, EgoLint owns conformance semantics, Relay runs reusable checks, +and Pace owns reviewed fleet convergence. -The next part must define deterministic composition against the existing catalog -and manifest. Avoid concatenating every language's rules into the root. A Rust -project can own `/target/` in its nearest `.gitignore`, without hiding unrelated -`target/` paths elsewhere. Existing profile names are not evidence that ignore -overlays for them already exist. +Each planned file contains selected overlays in manifest order, repository-local +text verbatim, then the universal baseline. Local comments, blank lines, rule +order, and intentional repetitions survive unchanged. The baseline comes last +in **each declared scope**, so local `!.env`, `!/.secrets/`, or `!/node_modules/` +rules cannot undo those protections. Its template exceptions also take +precedence for traversable paths; templates must contain placeholders. Local +additions can override profile outputs but cannot customize the baseline's final +matches. Changes to that policy need review of the canonical source. -Git applies directory scope and pattern order, rather than this ownership model: -later matching rules win at the same level, and a closer `.gitignore` can override -ancestor rules. A rule containing an internal slash is relative to its ignore -file; a slashless name can match at any depth. A leading slash anchors the rule -to that ignore file's directory. These are -[Git's documented semantics](https://git-scm.com/docs/gitignore). +Git applies directory scope and pattern order. Later matching rules win at the +same level, while a closer `.gitignore` can override ancestor rules. Slashless +names can match at any depth; a leading slash anchors a rule to the ignore file's +directory. See [Git's documented semantics](https://git-scm.com/docs/gitignore). -To keep a reviewed file inside an otherwise ignored output directory, keep the -parent traversable and ignore its contents. For example, in a selected project's -own `.gitignore`: +For a reviewed file inside the Rust output directory, local additions can use: ```gitignore -/build/* -!/build/README.md +!/target/ +/target/* +!/target/README.md ``` -Using `/build/` followed by `!/build/README.md` does not work: Git cannot reinclude -files beneath an excluded parent. A global `!**/.gitkeep` has the same limitation. -Document the path, owner, and reason for an exception, and test both the exception -and adjacent generated files. Do not use exceptions to expose private local state. +The first line reopens the directory excluded by the overlay; the next two +ignore its contents and reinclude only the reviewed note. Simply appending +`!/target/README.md` after `/target/` does not work: Git cannot reinclude files +beneath an excluded parent. A global `!**/.gitkeep` has the same limitation. +Document the path, owner, and reason, and test the exception and adjacent output. + +## Preservation, updates, and rollback + +The plan records `status: plan-only`, generator, foundation version, repository, +resolved profiles, ordered layer owners/IDs/paths/hashes, local text hash, and +each proposed file's content and SHA-256. Catalog and resolved-manifest hashes +use sorted-key compact JSON (`ensure_ascii=False`, separators `,` and `:`, UTF-8, +no trailing newline). Fragment and content hashes cover exact UTF-8 bytes. +Consumers should pin an accepted Empathy commit as well as its contract version; +this part creates no release or downstream upgrade pin. + +`.gitignore` stays required and repository-owned. A `preserve` override is carried +into the plan, not treated as permission to overwrite or bypass the baseline. +Planning never reads an existing consumer `.gitignore` and cannot infer which +lines are local. Adoption must review the existing file and represent retained +local rules explicitly in the manifest. Unknown existing text and edits must +be preserved or surfaced as a conflict by a future Holon materializer; this +module does not implement that merge engine. + +For a source upgrade, change the reviewed fragment and catalog hash, then +regenerate the plan and inventory. A hash mismatch fails instead of accepting +drift. Keep local additions unless the maintainer explicitly reviews their +change. The plan diff exposes ordering and visibility changes before adoption. +Rollback restores the previous pinned catalog, matching resolver, manifest, and +fragments and regenerates the plan. Restoring an adopted consumer file requires +a reviewed materialization/revert, retaining intervening local edits; reverting +the plan alone does not revert an active file. + +The EgoLint TOML is regenerated against a real source commit and advances with +foundation `1.1.0`. It still projects presence, ownership, executable flags, and +markers only. Ignore-content conformance belongs to EgoLint and is not implied +by the plan or presence check. ## Secret boundary and migration gate Ignore rules do not remove already tracked files, prevent forced additions, or -detect secrets. The behavior checks deliberately demonstrate that a nested -`!.env` can bypass a root rule. A future conformance check must detect prohibited -overrides; this candidate does not enforce that policy by itself. +detect secrets. Tests demonstrate that an unmanaged nested `!.env` can bypass a +root rule. Repeating the baseline protects declared scopes; it does not police +deeper files or arbitrary local patterns. A future EgoLint conformance check +must detect prohibited overrides; this composer does not provide +organization-wide enforcement. Before replacing the active root, account for every current credential rule. For `*.key`, keystores, certificates, `*.secrets.*`, and infrastructure state or variables, identify the real private locations, add narrow profile/local rules, and test them alongside public/fixture exceptions. Use `.secrets/` for deliberate private local material; it does not cover secrets stored arbitrarily elsewhere. -Keep secret scanning as a separate protection. Do not drop existing protections -merely because an extension is absent from the universal candidate. +Keep secret scanning separate. Do not drop existing protections merely because +an extension is absent from the universal baseline. -Migration must inspect untracked files newly exposed by changed rules without -printing their contents or staging them wholesale. An ignored file is never -evidence that deletion is safe. No cleanup is required by this proposal. +Migration must inspect newly exposed untracked paths without printing their +contents or staging them wholesale. An ignored file is never evidence that +deletion is safe. No cleanup is required by this contract. ## Validation @@ -102,25 +184,31 @@ Run from the repository root: ```bash python3 -m unittest discover \ --start-directory tests \ - --pattern "test_gitignore_baseline.py" \ + --pattern "test_*ignore*.py" \ --verbose +python3 tools/foundation.py plan-gitignore \ + --manifest "foundation/empathy.manifest.json" \ + --source-root "." \ + --output "foundation/contracts/empathy.gitignore-plan.json" +python3 tools/foundation.py check-gitignore-plan \ + --manifest "foundation/empathy.manifest.json" \ + --source-root "." \ + --output "foundation/contracts/empathy.gitignore-plan.json" ruff check \ --config "egolint/.config/lint/python/ruff.toml" \ - "tests/test_gitignore_baseline.py" + "tools/foundation.py" "tools/foundation_ignore.py" \ + "tests/test_gitignore_baseline.py" "tests/test_foundation_ignore.py" ``` -Use the explicit Ruff configuration above to match MegaLinter. A bare -`ruff check` resolves the root `pyproject.toml`, whose narrower rule selection -does not cover the CI policy. Run the behavior and lint checks before handoff, -then inspect the PR's final CI results before declaring validation complete. - -The harness installs the candidate into temporary Git repositories, creates -real untracked fixtures, and uses `git check-ignore --no-index`. Failures include -the winning rule via `--verbose`. It isolates Git configuration, inherited Git -environment overrides, templates, and global excludes. The full automation test -job also runs these tests when the candidate changes. - -Nested examples prove Git semantics; they are not released profile fixtures or -golden-consumer proof. Catalog/manifest validation, profile composition, -generated inventory and contract checks, and root adoption remain required to -finish #82. Filament follows an accepted, immutable upstream contract. +Use the explicit Ruff configuration to match MegaLinter; the root +`pyproject.toml` selects fewer rules. Inspect completed CI before handoff. +The harness installs baseline/composed content into temporary repositories and +uses real untracked fixtures with `git check-ignore --no-index`. Failures show +the winning rule. It isolates inherited Git configuration, templates, and global +excludes. Catalog, manifest, schemas, sources, and composer changes trigger the +automation test suite. + +Fixtures prove composition and Git semantics. The golden plan proves +repeatability, not adoption. Golden-root migration must reconcile the remaining +audit rules and is the next bounded PR. Keep #82 open until its full acceptance +criteria are met. Filament follows an accepted, immutable upstream contract. diff --git a/foundation/catalog.json b/foundation/catalog.json index 2b21b8c9..7d8c7324 100644 --- a/foundation/catalog.json +++ b/foundation/catalog.json @@ -1,7 +1,7 @@ { - "schema_version": "1.0.0", + "schema_version": "1.1.0", "id": "empathy/repository-foundation", - "version": "1.0.0", + "version": "1.1.0", "owner": "egohygiene/empathy", "holon_contract": { "owner": "egohygiene/holon", @@ -168,7 +168,23 @@ "profiles": [], "executable": false, "markers": [], - "description": "Repository-specific generated and local-state exclusions" + "description": "Repository-owned exclusions with explicit, scoped baseline composition", + "composition": { + "format": "empathy.gitignore/v1", + "baseline": { + "id": "universal", + "path": "foundation/ignore/universal.gitignore", + "sha256": "79280ac4f3147ead97a0b21b01f40241238f08fa5d63abe3f81c8b64e7f179f0" + }, + "overlays": [ + { + "id": "rust-build", + "path": "foundation/ignore/rust.gitignore", + "sha256": "16627c4b93c30be2aa2c977a48ee3157d0a7b8ff44e02008b9085f0bd8d94f96", + "profile": "language-rust" + } + ] + } }, { "id": "taskfile", @@ -484,6 +500,12 @@ { "id": "license_expression", "type": "string", "required": true } ], "generated_outputs": [ + { + "path": "foundation/contracts/empathy.gitignore-plan.json", + "owner": "egohygiene/empathy", + "source": "foundation/catalog.json + foundation/empathy.manifest.json + foundation/ignore/*", + "checked_in": true + }, { "path": "docs/foundation/INVENTORY.md", "owner": "egohygiene/empathy", diff --git a/foundation/contracts/empathy.gitignore-plan.json b/foundation/contracts/empathy.gitignore-plan.json new file mode 100644 index 00000000..67cea1ea --- /dev/null +++ b/foundation/contracts/empathy.gitignore-plan.json @@ -0,0 +1,55 @@ +{ + "files": [ + { + "content": "# Composed ignore proposal; see the foundation plan for source hashes.\n\n# Profile: rust-build\n# Empathy Rust build overlay; select the Cargo workspace/project root explicitly.\n# Anchoring keeps unrelated nested target/ source directories visible.\n/target/\n\n# Repository-owned local additions.\n\n# Universal baseline (last in every declared scope).\n# Empathy universal ignore baseline; see docs/foundation/gitignore/README.md.\n# Registered for composition plans; active-root adoption is a separate step.\n\n# Operating-system metadata.\n.DS_Store\n.AppleDouble\n.LSOverride\n._*\nThumbs.db\nDesktop.ini\n$RECYCLE.BIN/\n\n# Editor-local state; shared .idea/ and .vscode/ configuration stays visible.\n*~\n*.swp\n*.swo\n**/.idea/workspace.xml\n**/.idea/usage.statistics.xml\n**/.idea/shelf/\n\n# Local environments. Template suffixes are deliberate, not prefix matches.\n.env\n.env.*\n!.env.example\n!.env.sample\n!.env.template\n!.env.*.example\n!.env.*.sample\n!.env.*.template\n\n# Reserved private local namespace; public keys and fixtures stay visible.\n.secrets/\n\n# Unambiguous local caches, environments, and installed dependencies.\n.cache/\n.tmp/\n.venv/\n__pycache__/\nnode_modules/\n\n# Language/build outputs, logs, reports, and credential extensions need\n# profile or repository ownership. Do not add universal bin/, build/, dist/,\n# out/, target/, vendor/, *.log, *.key, or whole-editor-directory rules.\n# Lockfiles, reviewed artifacts, and shared configuration remain trackable.\n# A blanket .gitkeep negation cannot reopen an ignored parent directory.\n", + "content_sha256": "da2b4cc02aa72650796b5400ca602650f4fb8fe8e72a388723a09b25a899d1bc", + "layers": [ + { + "id": "rust-build", + "kind": "overlay", + "owner": "egohygiene/empathy", + "path": "foundation/ignore/rust.gitignore", + "profile": "language-rust", + "sha256": "16627c4b93c30be2aa2c977a48ee3157d0a7b8ff44e02008b9085f0bd8d94f96" + }, + { + "kind": "local", + "owner": "egohygiene/empathy", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "id": "universal", + "kind": "baseline", + "owner": "egohygiene/empathy", + "path": "foundation/ignore/universal.gitignore", + "sha256": "79280ac4f3147ead97a0b21b01f40241238f08fa5d63abe3f81c8b64e7f179f0" + } + ], + "override": null, + "ownership": "repository-owned", + "path": ".gitignore" + } + ], + "format": "empathy.gitignore/v1", + "foundation": "empathy/repository-foundation@1.1.0", + "generator": "tools/foundation.py plan-gitignore", + "profiles": [ + "agent-context", + "community-health", + "documentation", + "language-node", + "language-rust", + "product", + "quality-baseline", + "release-automated", + "risk-hardened", + "universal" + ], + "repository": "egohygiene/empathy", + "source": { + "catalog_sha256": "c3a74a7acc2cda2644e8ef21e7ef1b4dcde928c9e781937d849e834b2067919c", + "owner": "egohygiene/empathy", + "resolved_manifest_sha256": "dc499ff956a9ee6da9aaa8ac38e1a7b326fed3adc96f94906b2ccf875ff0cc00" + }, + "status": "plan-only" +} diff --git a/foundation/empathy.manifest.json b/foundation/empathy.manifest.json index 732184be..0d0ad1c7 100644 --- a/foundation/empathy.manifest.json +++ b/foundation/empathy.manifest.json @@ -1,7 +1,7 @@ { - "schema_version": "1.0.0", + "schema_version": "1.1.0", "repository": "egohygiene/empathy", - "foundation": "empathy/repository-foundation@1.0.0", + "foundation": "empathy/repository-foundation@1.1.0", "selected_profiles": [ "agent-context", "community-health", @@ -21,6 +21,15 @@ "mode": "preserve" } ], + "gitignore": { + "scopes": [ + { + "root": ".", + "overlays": ["rust-build"], + "local_additions": "" + } + ] + }, "repository_owned": { "display_name": "Empathy", "description": "Golden repository baseline and integration consumer for Ego Hygiene", diff --git a/foundation/ignore/rust.gitignore b/foundation/ignore/rust.gitignore new file mode 100644 index 00000000..ee7a07ff --- /dev/null +++ b/foundation/ignore/rust.gitignore @@ -0,0 +1,3 @@ +# Empathy Rust build overlay; select the Cargo workspace/project root explicitly. +# Anchoring keeps unrelated nested target/ source directories visible. +/target/ diff --git a/foundation/ignore/universal.gitignore b/foundation/ignore/universal.gitignore index 52acc802..baaea08b 100644 --- a/foundation/ignore/universal.gitignore +++ b/foundation/ignore/universal.gitignore @@ -1,5 +1,5 @@ -# Empathy universal ignore candidate; see docs/foundation/gitignore/README.md. -# This source is not yet selected by the catalog or installed at the root. +# Empathy universal ignore baseline; see docs/foundation/gitignore/README.md. +# Registered for composition plans; active-root adoption is a separate step. # Operating-system metadata. .DS_Store diff --git a/schemas/repository-foundation-catalog.v1.schema.json b/schemas/repository-foundation-catalog.v1.schema.json index b4b14822..a36c4997 100644 --- a/schemas/repository-foundation-catalog.v1.schema.json +++ b/schemas/repository-foundation-catalog.v1.schema.json @@ -17,7 +17,7 @@ "generated_outputs" ], "properties": { - "schema_version": { "const": "1.0.0" }, + "schema_version": { "const": "1.1.0" }, "id": { "const": "empathy/repository-foundation" }, "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, "owner": { "const": "egohygiene/empathy" }, @@ -45,6 +45,67 @@ } }, "$defs": { + "ignoreComposition": { + "type": "object", + "additionalProperties": false, + "required": ["format", "baseline", "overlays"], + "properties": { + "format": { + "const": "empathy.gitignore/v1" + }, + "baseline": { + "$ref": "#/$defs/ignoreSource" + }, + "overlays": { + "type": "array", + "items": { + "$ref": "#/$defs/ignoreOverlay" + } + } + } + }, + "ignoreSource": { + "type": "object", + "additionalProperties": false, + "required": ["id", "path", "sha256"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]+$" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "ignoreOverlay": { + "type": "object", + "additionalProperties": false, + "required": ["id", "path", "sha256", "profile"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]+$" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "profile": { + "type": "string", + "minLength": 1 + } + } + }, "uniqueStrings": { "type": "array", "uniqueItems": true, @@ -90,8 +151,24 @@ "profiles": { "$ref": "#/$defs/uniqueStrings" }, "executable": { "type": "boolean" }, "markers": { "$ref": "#/$defs/uniqueStrings" }, - "description": { "type": "string", "minLength": 1 } - } + "description": { "type": "string", "minLength": 1 }, + "composition": { "$ref": "#/$defs/ignoreComposition" } + }, + "allOf": [ + { + "if": { "properties": { "id": { "const": "gitignore" } } }, + "then": { + "required": ["composition"], + "properties": { + "path": { "const": ".gitignore" }, + "kind": { "const": "file" }, + "presence": { "const": "required" }, + "ownership": { "const": "repository-owned" } + } + }, + "else": { "not": { "required": ["composition"] } } + } + ] }, "repositoryOwnedField": { "type": "object", diff --git a/schemas/repository-foundation-manifest.v1.schema.json b/schemas/repository-foundation-manifest.v1.schema.json index 26799ddf..8069c4bc 100644 --- a/schemas/repository-foundation-manifest.v1.schema.json +++ b/schemas/repository-foundation-manifest.v1.schema.json @@ -13,9 +13,9 @@ "repository_owned" ], "properties": { - "schema_version": { "const": "1.0.0" }, + "schema_version": { "const": "1.1.0" }, "repository": { "type": "string", "pattern": "^[a-z0-9.-]+/[a-z0-9.-]+$" }, - "foundation": { "const": "empathy/repository-foundation@1.0.0" }, + "foundation": { "const": "empathy/repository-foundation@1.1.0" }, "selected_profiles": { "type": "array", "minItems": 1, @@ -35,6 +35,39 @@ } } }, + "gitignore": { + "type": "object", + "additionalProperties": false, + "required": ["scopes"], + "properties": { + "scopes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["root", "overlays", "local_additions"], + "properties": { + "root": { + "type": "string", + "minLength": 1 + }, + "overlays": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "local_additions": { + "type": "string" + } + } + } + } + } + }, "repository_owned": { "type": "object", "additionalProperties": { diff --git a/tests/test_foundation_ignore.py b/tests/test_foundation_ignore.py new file mode 100644 index 00000000..5dc4e97d --- /dev/null +++ b/tests/test_foundation_ignore.py @@ -0,0 +1,343 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT + +"""Verify source integrity, explicit selection, and actual composed Git behavior.""" + +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +import copy +import hashlib +import io +from pathlib import Path +import sys +import tempfile +import unittest + +import test_gitignore_baseline + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +import foundation_ignore # noqa: E402 + +import foundation # noqa: E402 + + +def inputs(): + return ( + foundation.load_json(ROOT / "foundation/catalog.json"), + foundation.load_json(ROOT / "foundation/empathy.manifest.json"), + ) + + +def scope(root=".", overlays=(), local=""): + return {"root": root, "overlays": list(overlays), "local_additions": local} + + +def definition(catalog): + return next(item for item in catalog["artifacts"] if item["id"] == "gitignore")["composition"] + + +class IgnoreContractTests(unittest.TestCase): + def setUp(self) -> None: + self.catalog, self.manifest = inputs() + + def test_checked_in_plan_is_current_and_byte_repeatable(self) -> None: + first, errors = foundation.plan_gitignore(self.catalog, self.manifest, ROOT) + self.assertEqual([], errors) + second, errors = foundation.plan_gitignore( + dict(reversed(list(self.catalog.items()))), self.manifest, ROOT + ) + self.assertEqual([], errors) + self.assertEqual(foundation.render_resolved(first), foundation.render_resolved(second)) + self.assertEqual( + (ROOT / "foundation/contracts/empathy.gitignore-plan.json").read_text(encoding="utf-8"), + foundation.render_resolved(first), + ) + self.assertEqual("plan-only", first["status"]) + self.assertEqual( + ["overlay", "local", "baseline"], + [layer["kind"] for layer in first["files"][0]["layers"]], + ) + for file in first["files"]: + self.assertEqual( + hashlib.sha256(file["content"].encode()).hexdigest(), file["content_sha256"] + ) + for layer in file["layers"]: + if "path" in layer: + self.assertEqual( + hashlib.sha256((ROOT / layer["path"]).read_bytes()).hexdigest(), + layer["sha256"], + ) + self.assertEqual("egohygiene/empathy", layer["owner"]) + + def test_scopes_are_sorted_but_local_text_is_preserved(self) -> None: + local = "# Keep the reviewed build note.\n!/target/\n/target/*\n!/target/README.md\n\n# Repetition is intentional.\n/target/*\n!/target/README.md\n" + self.manifest["gitignore"]["scopes"] = [scope("apps/rust", ("rust-build",), local), scope()] + first, errors = foundation.plan_gitignore(self.catalog, self.manifest, ROOT) + self.assertEqual([], errors) + self.manifest["gitignore"]["scopes"].reverse() + second, errors = foundation.plan_gitignore(self.catalog, self.manifest, ROOT) + self.assertEqual([], errors) + self.assertEqual(first, second) + self.assertEqual( + [".gitignore", "apps/rust/.gitignore"], [file["path"] for file in first["files"]] + ) + self.assertIn(local, first["files"][1]["content"]) + self.assertEqual(foundation_ignore.digest(local), first["files"][1]["layers"][1]["sha256"]) + self.assertEqual("egohygiene/empathy", first["files"][1]["layers"][1]["owner"]) + + def test_omitted_selection_remains_valid_but_cannot_silently_plan(self) -> None: + del self.manifest["gitignore"] + resolved, errors = foundation.resolve_manifest(self.catalog, self.manifest) + self.assertEqual([], errors) + self.assertNotIn("gitignore", resolved) + plan, errors = foundation.plan_gitignore(self.catalog, self.manifest, ROOT) + self.assertIsNone(plan) + self.assertIn("manifest must select gitignore scopes before planning", errors) + + def test_overlay_requires_profile_and_explicit_scope_selection(self) -> None: + self.manifest["selected_profiles"].remove("language-rust") + plan, errors = foundation.plan_gitignore(self.catalog, self.manifest, ROOT) + self.assertIsNone(plan) + self.assertTrue(any("requires selected profile language-rust" in error for error in errors)) + self.manifest["gitignore"]["scopes"] = [scope()] + plan, errors = foundation.plan_gitignore(self.catalog, self.manifest, ROOT) + self.assertEqual([], errors) + self.assertEqual( + ["local", "baseline"], [layer["kind"] for layer in plan["files"][0]["layers"]] + ) + + def test_invalid_scope_configuration_fails_closed(self) -> None: + invalid = [ + None, + {}, + {"scopes": []}, + {"scopes": [None]}, + {"scopes": [scope("nested")]}, + {"scopes": [scope(), scope()]}, + {"scopes": [scope(), scope("Apps"), scope("apps")]}, + {"scopes": [scope(overlays=("missing",))]}, + {"scopes": [scope(overlays=("rust-build", "rust-build"))]}, + ] + invalid.extend( + {"scopes": [scope(), scope(root)]} + for root in ( + "../escape", + "/absolute", + "./nested", + "a//b", + "a/../b", + "a/", + "a\\b", + ".git", + "a/.GiT", + "a/*", + "a\nb", + "a/.gitignore", + " leading", + ) + ) + invalid.extend( + {"scopes": [scope(local=local)]} + for local in ("/cache/", "bad\r\n", "bad\0\n", None, []) + ) + invalid.extend( + {"scopes": [{**scope(), "overlays": value}]} for value in (None, "rust-build", [{}]) + ) + for selection in invalid: + with self.subTest(selection=selection): + self.manifest["gitignore"] = selection + plan, errors = foundation.plan_gitignore(self.catalog, self.manifest, ROOT) + self.assertIsNone(plan) + self.assertTrue(errors) + + def test_invalid_source_metadata_fails_closed(self) -> None: + invalid = [None, {}, {**definition(self.catalog), "unknown": True}] + for field, value in ( + ("id", []), + ("path", "../escape"), + ("path", "."), + ("sha256", "not-a-digest"), + ): + candidate = copy.deepcopy(definition(self.catalog)) + candidate["baseline"][field] = value + invalid.append(candidate) + duplicate = copy.deepcopy(definition(self.catalog)) + duplicate["overlays"].append(duplicate["overlays"][0]) + invalid.append(duplicate) + for candidate in invalid: + with self.subTest(candidate=candidate): + catalog = copy.deepcopy(self.catalog) + next(item for item in catalog["artifacts"] if item["id"] == "gitignore")[ + "composition" + ] = candidate + plan, errors = foundation.plan_gitignore(catalog, self.manifest, ROOT) + self.assertIsNone(plan) + self.assertTrue(errors) + + def test_missing_changed_unscoped_and_duplicate_sources_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + sources = definition(self.catalog) + baseline = sources["baseline"] + path = root / baseline["path"] + path.parent.mkdir(parents=True) + path.write_bytes((ROOT / baseline["path"]).read_bytes()) + rust = sources["overlays"][0] + for contents, expected in ( + (None, "cannot read"), + ("/changed/\n", "digest mismatch"), + ("target/\n", "must be anchored"), + ("/target/\n/target/\n", "unique active rules"), + ("/target/\r\n", "LF-terminated"), + ): + with self.subTest(contents=contents): + if contents is not None: + (root / rust["path"]).write_bytes(contents.encode()) + if expected != "digest mismatch": + rust["sha256"] = foundation_ignore.digest(contents) + plan, errors = foundation.plan_gitignore(self.catalog, self.manifest, root) + self.assertIsNone(plan) + self.assertTrue(any(expected in error for error in errors), errors) + + def test_source_symlink_cannot_escape_supplied_source_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "foundation").symlink_to(ROOT / "foundation", target_is_directory=True) + plan, errors = foundation.plan_gitignore(self.catalog, self.manifest, root) + self.assertIsNone(plan) + self.assertTrue(all("escapes source root" in error for error in errors)) + + def test_preserve_metadata_does_not_drop_baseline_or_claim_materialization(self) -> None: + self.manifest["overrides"].append({"artifact": "gitignore", "mode": "preserve"}) + self.manifest["repository"] = "egohygiene/fixture" + before = (ROOT / ".gitignore").read_bytes() + plan, errors = foundation.plan_gitignore(self.catalog, self.manifest, ROOT) + self.assertEqual([], errors) + file = plan["files"][0] + self.assertEqual("preserve", file["override"]) + self.assertEqual("repository-owned", file["ownership"]) + self.assertEqual("egohygiene/fixture", file["layers"][1]["owner"]) + self.assertEqual("baseline", file["layers"][-1]["kind"]) + self.assertEqual(before, (ROOT / ".gitignore").read_bytes()) + + def test_cli_checks_staleness_and_never_writes_consumer_ignore_files(self) -> None: + with ( + tempfile.TemporaryDirectory() as temporary, + redirect_stdout(io.StringIO()), + redirect_stderr(io.StringIO()), + ): + output = Path(temporary) / "plan.json" + arguments = [ + "--manifest", + str(ROOT / "foundation/empathy.manifest.json"), + "--source-root", + str(ROOT), + "--output", + str(output), + ] + prefix = ["--catalog", str(ROOT / "foundation/catalog.json")] + self.assertEqual(0, foundation.main([*prefix, "plan-gitignore", *arguments])) + self.assertEqual(0, foundation.main([*prefix, "check-gitignore-plan", *arguments])) + output.write_text("{}\n", encoding="utf-8") + self.assertEqual(1, foundation.main([*prefix, "check-gitignore-plan", *arguments])) + self.assertEqual("{}\n", output.read_text(encoding="utf-8")) + ignore = Path(temporary) / ".gitignore" + ignore.write_text("original\n", encoding="utf-8") + self.assertEqual( + 2, foundation.main([*prefix, "plan-gitignore", *arguments[:-1], str(ignore)]) + ) + self.assertEqual("original\n", ignore.read_text(encoding="utf-8")) + + +class ComposedIgnoreBehaviorTests(test_gitignore_baseline.GitignoreFixture): + def install(self, scopes): + catalog, manifest = inputs() + manifest["gitignore"]["scopes"] = scopes + plan, errors = foundation.plan_gitignore(catalog, manifest, ROOT) + self.assertEqual([], errors) + for file in plan["files"]: + self.write(file["path"], file["content"]) + + def test_nested_rust_scope_leaves_unrelated_target_sources_visible(self) -> None: + self.install([scope(), scope("apps/rust", ("rust-build",))]) + self.assert_paths( + ("apps/rust/target/debug/example", "apps/rust/.env", "other/.env"), ignored=True + ) + self.assert_paths( + ( + "target/source.rs", + "apps/other/target/source.rs", + "apps/rust/fixtures/target/source.rs", + "apps/rust/Cargo.lock", + "apps/rust/src/lib.rs", + ), + ignored=False, + ) + + def test_root_rust_overlay_is_anchored_to_the_workspace(self) -> None: + self.install([scope(overlays=("rust-build",))]) + self.assert_paths(("target/debug/example",), ignored=True) + self.assert_paths(("tests/fixtures/target/source.rs", "Cargo.lock"), ignored=False) + + def test_profile_selection_alone_does_not_install_any_overlay(self) -> None: + self.install([scope()]) + self.assert_paths(("target/source.rs", "apps/rust/target/source.rs"), ignored=False) + + def test_local_exception_reopens_parent_and_keeps_adjacent_output_ignored(self) -> None: + self.install( + [ + scope(), + scope( + "apps/rust", + ("rust-build",), + "# Owner: fixture maintainer; reason: reviewed build note.\n!/target/\n/target/*\n!/target/README.md\n", + ), + ] + ) + self.assert_paths( + ("apps/rust/target/debug/example", "apps/rust/target/other.md"), ignored=True + ) + self.assert_paths(("apps/rust/target/README.md", "other/target/source.rs"), ignored=False) + + def test_baseline_wins_over_local_negations_in_every_declared_scope(self) -> None: + local = "!.env\n!.env.*\n!/.secrets/\n!/.secrets/**\n!/node_modules/\n!/node_modules/**\n" + self.install([scope(local=local), scope("apps/rust", ("rust-build",), local)]) + for prefix in ("", "apps/rust/"): + self.assert_paths( + tuple( + prefix + path + for path in ( + ".env", + ".env.production", + ".env.example.local", + ".secrets/example.key", + "node_modules/dep/index.js", + ) + ), + ignored=True, + ) + self.assert_paths( + tuple( + prefix + path + for path in ( + ".env.example", + ".env.production.template", + ".vscode/settings.json", + "fixtures/public.key", + ) + ), + ignored=False, + ) + + def test_unmanaged_nested_ignore_can_still_override_ancestor_baseline(self) -> None: + self.install([scope()]) + self.write("unmanaged/.gitignore", "!.env\n") + self.assert_paths(("unmanaged/.env",), ignored=False) + self.assert_paths(("other/.env",), ignored=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gitignore_baseline.py b/tests/test_gitignore_baseline.py index bde81c7c..1a3c48da 100644 --- a/tests/test_gitignore_baseline.py +++ b/tests/test_gitignore_baseline.py @@ -16,8 +16,8 @@ BASELINE = ROOT / "foundation" / "ignore" / "universal.gitignore" -class GitignoreBaselineTests(unittest.TestCase): - """Check untracked files in isolated repositories, independent of Empathy.""" +class GitignoreFixture(unittest.TestCase): + """Share isolated Git setup and assertions across ignore behavior suites.""" def setUp(self) -> None: self.git_executable = shutil.which("git") @@ -75,6 +75,10 @@ def assert_paths(self, paths: tuple[str, ...], *, ignored: bool) -> None: f"{diagnostic.stdout or diagnostic.stderr or result.stderr}" ) + +class GitignoreBaselineTests(GitignoreFixture): + """Check untracked files against the universal baseline.""" + def test_operating_system_and_editor_local_state_is_ignored(self) -> None: self.assert_paths( ( diff --git a/tools/foundation.py b/tools/foundation.py index 09544779..5e6f3df6 100755 --- a/tools/foundation.py +++ b/tools/foundation.py @@ -14,8 +14,10 @@ import sys from typing import Any -SCHEMA_VERSION = "1.0.0" -FOUNDATION_REFERENCE = "empathy/repository-foundation@1.0.0" +import foundation_ignore + +SCHEMA_VERSION = "1.1.0" +FOUNDATION_REFERENCE = "empathy/repository-foundation@1.1.0" REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$") REQUIRED_CATEGORIES = { "agent-context", @@ -107,7 +109,9 @@ def validate_catalog(catalog: dict[str, Any]) -> list[str]: if isinstance(requires, list) and isinstance(conflicts, list): unknown = (set(requires) | set(conflicts)) - profile_names if unknown: - errors.append(f"profile {name} references unknown profiles: {', '.join(sorted(unknown))}") + errors.append( + f"profile {name} references unknown profiles: {', '.join(sorted(unknown))}" + ) if name in requires or name in conflicts: errors.append(f"profile {name} cannot require or conflict with itself") graph[name] = [item for item in requires if isinstance(item, str)] @@ -179,6 +183,19 @@ def visit(name: str, stack: list[str]) -> None: errors.append(f"{prefix} directory cannot be executable") if not isinstance(artifact.get("description"), str) or not artifact["description"].strip(): errors.append(f"{prefix}.description must be a non-empty string") + if identifier == "gitignore": + if (path, artifact.get("kind"), presence, ownership) != ( + ".gitignore", + "file", + "required", + "repository-owned", + ): + errors.append("gitignore must remain a required repository-owned .gitignore file") + errors.extend( + foundation_ignore.validate_definition(artifact.get("composition"), profile_names) + ) + elif "composition" in artifact: + errors.append("composition is supported only for the gitignore artifact") if len(ids) != len(set(ids)): errors.append("artifact ids must be unique") if len(paths) != len(set(paths)): @@ -239,6 +256,8 @@ def resolve_manifest( """Resolve profile closure and safe ownership overrides.""" errors = validate_catalog(catalog) + if errors: + return None, errors if manifest.get("schema_version") != SCHEMA_VERSION: errors.append(f"manifest schema_version must be {SCHEMA_VERSION}") if manifest.get("foundation") != FOUNDATION_REFERENCE: @@ -259,10 +278,21 @@ def resolve_manifest( for identifier, artifact in artifacts.items() if artifact["presence"] == "required" or ( - artifact["presence"] == "profile" - and set(artifact["profiles"]) & set(resolved_profiles) + artifact["presence"] == "profile" and set(artifact["profiles"]) & set(resolved_profiles) ) } + ignore_selection = manifest.get("gitignore") + if "gitignore" in manifest: + if "gitignore" not in selected_artifacts: + errors.append("manifest gitignore requires the selected gitignore artifact") + else: + errors.extend( + foundation_ignore.validate_selection( + ignore_selection, + selected_artifacts["gitignore"]["composition"], + resolved_profiles, + ) + ) overrides = manifest.get("overrides") if not isinstance(overrides, list): errors.append("overrides must be an array") @@ -324,7 +354,7 @@ def resolve_manifest( for artifact in sorted(catalog["artifacts"], key=lambda artifact: artifact["id"]) if artifact["presence"] == "optional" ] - return { + resolved = { "schema_version": SCHEMA_VERSION, "foundation": FOUNDATION_REFERENCE, "repository": repository, @@ -332,7 +362,23 @@ def resolve_manifest( "artifacts": resolved_artifacts, "optional_artifacts": optional_artifacts, "repository_owned": {key: repository_owned[key] for key in sorted(repository_owned)}, - }, [] + } + if ignore_selection is not None: + resolved["gitignore"] = { + "scopes": sorted(ignore_selection["scopes"], key=lambda scope: scope["root"]) + } + return resolved, [] + + +def plan_gitignore( + catalog: dict[str, Any], manifest: dict[str, Any], source_root: Path +) -> tuple[dict[str, Any] | None, list[str]]: + """Resolve and compose proposed ignore files without adopting them.""" + resolved, errors = resolve_manifest(catalog, manifest) + if errors: + return None, errors + assert resolved is not None + return foundation_ignore.compose(catalog, resolved, source_root) def render_resolved(resolved: dict[str, Any]) -> str: @@ -363,6 +409,26 @@ def render_inventory(catalog: dict[str, Any]) -> str: f"| `{artifact['path']}` | {artifact['category']} | {artifact['presence']} | " f"{artifact['ownership']} | {profiles} | {description} |" ) + for artifact in catalog["artifacts"]: + if "composition" not in artifact: + continue + definition = artifact["composition"] + lines.extend( + [ + "", + "## Gitignore composition sources", + "", + f"Sources are owned by `{catalog['owner']}`; they are not required consumer paths.", + "", + "| ID | Profile | Source path | SHA-256 |", + "| --- | --- | --- | --- |", + ] + ) + for source in [definition["baseline"], *definition["overlays"]]: + lines.append( + f"| `{source['id']}` | {source.get('profile', 'all declared scopes')} | " + f"`{source['path']}` | `{source['sha256']}` |" + ) lines.extend( [ "", @@ -389,7 +455,7 @@ def render_egolint_contract(resolved: dict[str, Any], source_revision: str) -> s lines = [ "schema-version = 1", 'id = "empathy-universal-foundation"', - 'version = "1.0.0"', + f'version = "{SCHEMA_VERSION}"', 'profile = "empathy/golden-foundation"', "provisional = false", "", @@ -472,6 +538,11 @@ def build_parser() -> argparse.ArgumentParser: subparser.add_argument("--manifest", type=Path, required=True) subparser.add_argument("--source-revision", required=True) subparser.add_argument("--output", type=Path, required=True) + for command in ("plan-gitignore", "check-gitignore-plan"): + subparser = subparsers.add_parser(command) + subparser.add_argument("--manifest", type=Path, required=True) + subparser.add_argument("--source-root", type=Path, default=Path()) + subparser.add_argument("--output", type=Path, required=True) return parser @@ -515,6 +586,29 @@ def main(argv: list[str] | None = None) -> int: print(f"foundation manifest validation failed: {error}", file=sys.stderr) return 1 assert resolved is not None + if arguments.command in {"plan-gitignore", "check-gitignore-plan"}: + if arguments.output.suffix != ".json": + print( + "gitignore plan output must be a JSON file, not a consumer ignore file", + file=sys.stderr, + ) + return 2 + plan, errors = foundation_ignore.compose(catalog, resolved, arguments.source_root) + if errors: + for error in errors: + print(f"gitignore planning failed: {error}", file=sys.stderr) + return 1 + assert plan is not None + rendered = render_resolved(plan) + if arguments.command == "plan-gitignore": + _write(arguments.output, rendered) + print(f"wrote gitignore plan: {arguments.output}") + return 0 + if not _check(arguments.output, rendered): + print(f"generated gitignore plan is stale: {arguments.output}", file=sys.stderr) + return 1 + print(f"generated gitignore plan current: {arguments.output}") + return 0 if arguments.command == "validate-manifest": print( f"foundation manifest valid: {len(resolved['profiles'])} profiles, " diff --git a/tools/foundation_ignore.py b/tools/foundation_ignore.py new file mode 100644 index 00000000..0aae371f --- /dev/null +++ b/tools/foundation_ignore.py @@ -0,0 +1,229 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT +# ruff: noqa: INP001 +# This module supports the standalone tools/foundation.py CLI. + +"""Compose reviewable ignore plans without materializing consumer files.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +from typing import Any + +FORMAT = "empathy.gitignore/v1" +IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]+$") +DIGEST = re.compile(r"^[0-9a-f]{64}$") + + +def digest(contents: str) -> str: + return hashlib.sha256(contents.encode("utf-8")).hexdigest() + + +def object_digest(value: Any) -> str: + """Hash canonical JSON: sorted keys, compact separators, UTF-8, no newline.""" + return digest(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"))) + + +def _safe_path(value: Any, *, root: bool = False) -> bool: + if not isinstance(value, str) or not value: + return False + if value == ".": + return root + if any(not char.isprintable() or char in '\\:*?[]<>|"!' for char in value): + return False + path = PurePosixPath(value) + return ( + not path.is_absolute() + and str(path) == value + and all( + part == part.strip() and part.casefold() not in {".", "..", ".git"} + for part in path.parts + ) + and (not root or ".gitignore" not in {part.casefold() for part in path.parts}) + ) + + +def _source_errors(source: Any, *, overlay: bool, profiles: set[str]) -> list[str]: + fields = {"id", "path", "sha256"} | ({"profile"} if overlay else set()) + if not isinstance(source, dict) or set(source) != fields: + return [f"ignore source must declare only {', '.join(sorted(fields))}"] + errors = [] + if not isinstance(source["id"], str) or not IDENTIFIER.fullmatch(source["id"]): + errors.append("ignore source id must be a lowercase identifier") + if not _safe_path(source["path"]): + errors.append("ignore source path must be normalized and repository-relative") + if not isinstance(source["sha256"], str) or not DIGEST.fullmatch(source["sha256"]): + errors.append("ignore source sha256 must be 64 lowercase hexadecimal characters") + if overlay and (not isinstance(source["profile"], str) or source["profile"] not in profiles): + errors.append("ignore overlay must select a known profile") + return errors + + +def validate_definition(definition: Any, profiles: set[str]) -> list[str]: + """Validate the catalog-owned source registry, not consumer paths.""" + if not isinstance(definition, dict) or set(definition) != {"format", "baseline", "overlays"}: + return ["gitignore composition must declare only format, baseline, and overlays"] + errors = [] + if definition["format"] != FORMAT: + errors.append(f"gitignore composition format must be {FORMAT}") + errors.extend(_source_errors(definition["baseline"], overlay=False, profiles=profiles)) + if not isinstance(definition["overlays"], list): + return [*errors, "gitignore overlays must be an array"] + for overlay in definition["overlays"]: + errors.extend(_source_errors(overlay, overlay=True, profiles=profiles)) + if not errors: + sources = [definition["baseline"], *definition["overlays"]] + for field in ("id", "path"): + values = [source[field].casefold() for source in sources] + if len(values) != len(set(values)): + errors.append(f"ignore source {field} values must be unique ignoring case") + return sorted(set(errors)) + + +def _scope_errors(scope: Any, overlays: dict[str, Any], profiles: list[str]) -> list[str]: + if not isinstance(scope, dict) or set(scope) != {"root", "overlays", "local_additions"}: + return ["each gitignore scope must declare only root, overlays, and local_additions"] + errors = [] + if not _safe_path(scope["root"], root=True): + errors.append( + "gitignore scope root must be '.' or a normalized repository-relative directory" + ) + selected = scope["overlays"] + if not isinstance(selected, list) or any(not isinstance(item, str) for item in selected): + errors.append("gitignore scope overlays must be an array of identifiers") + else: + if len(selected) != len(set(selected)): + errors.append("gitignore scope overlays must not contain duplicates") + for identifier in selected: + if identifier not in overlays: + errors.append(f"unknown gitignore overlay: {identifier}") + elif overlays[identifier]["profile"] not in profiles: + errors.append( + f"gitignore overlay {identifier} requires selected profile {overlays[identifier]['profile']}" + ) + local = scope["local_additions"] + if ( + not isinstance(local, str) + or "\r" in local + or "\0" in local + or (local and not local.endswith("\n")) + ): + errors.append( + "gitignore local_additions must be empty or LF-terminated text without CR or NUL" + ) + return errors + + +def validate_selection( + selection: Any, definition: dict[str, Any], profiles: list[str] +) -> list[str]: + """Require explicit project scopes and explicit local text.""" + if not isinstance(selection, dict) or set(selection) != {"scopes"}: + return ["manifest gitignore must declare only scopes"] + scopes = selection["scopes"] + if not isinstance(scopes, list) or not scopes: + return ["gitignore scopes must be a non-empty array"] + overlays = {source["id"]: source for source in definition["overlays"]} + errors = [] + for scope in scopes: + errors.extend(_scope_errors(scope, overlays, profiles)) + if not errors: + roots = [scope["root"].casefold() for scope in scopes] + if len(roots) != len(set(roots)): + errors.append("gitignore scope roots must be unique ignoring case") + if "." not in roots: + errors.append("gitignore scopes must include the repository root '.'") + return sorted(set(errors)) + + +def _read_source(source: dict[str, Any], source_root: Path) -> tuple[str, list[str]]: + path = source["path"] + try: + target = (source_root / path).resolve() + if not target.is_relative_to(source_root.resolve()): + return "", [f"ignore source escapes source root: {path}"] + raw = target.read_bytes() + contents = raw.decode("utf-8") + except (OSError, RuntimeError, UnicodeError) as error: + return "", [f"cannot read ignore source {path}: {error}"] + errors = [] + if hashlib.sha256(raw).hexdigest() != source["sha256"]: + errors.append(f"ignore source digest mismatch: {path}") + if "\r" in contents or "\0" in contents or not contents.endswith("\n"): + errors.append(f"ignore source must be LF-terminated text without CR or NUL: {path}") + rules = [line for line in contents.splitlines() if line and not line.startswith("#")] + if not rules or len(rules) != len(set(rules)): + errors.append(f"ignore source must have nonempty, unique active rules: {path}") + if "profile" in source and any(not rule.removeprefix("!").startswith("/") for rule in rules): + errors.append( + f"ignore overlay rules must be anchored to their selected project root: {path}" + ) + return contents, errors + + +def compose( + catalog: dict[str, Any], resolved: dict[str, Any], source_root: Path +) -> tuple[dict[str, Any] | None, list[str]]: + """Plan validated selections; read canonical sources, never consumer files.""" + if "gitignore" not in resolved: + return None, ["manifest must select gitignore scopes before planning"] + artifact = next(item for item in resolved["artifacts"] if item["id"] == "gitignore") + definition = artifact["composition"] + sources = [definition["baseline"], *definition["overlays"]] + contents_by_id = {} + errors = [] + # Check every registered source, including overlays not selected by this consumer. + for source in sources: + contents, source_errors = _read_source(source, source_root) + contents_by_id[source["id"]] = contents + errors.extend(source_errors) + if errors: + return None, sorted(set(errors)) + sources_by_id = {source["id"]: source for source in sources} + files = [] + for scope in resolved["gitignore"]["scopes"]: + layers = [] + chunks = ["# Composed ignore proposal; see the foundation plan for source hashes.\n"] + for identifier in scope["overlays"]: + source = sources_by_id[identifier] + layers.append({"kind": "overlay", "owner": catalog["owner"], **source}) + chunks.extend([f"\n# Profile: {identifier}\n", contents_by_id[identifier]]) + local = scope["local_additions"] + layers.append({"kind": "local", "owner": resolved["repository"], "sha256": digest(local)}) + chunks.extend(["\n# Repository-owned local additions.\n", local]) + baseline = definition["baseline"] + layers.append({"kind": "baseline", "owner": catalog["owner"], **baseline}) + chunks.extend( + [ + "\n# Universal baseline (last in every declared scope).\n", + contents_by_id[baseline["id"]], + ] + ) + contents = "".join(chunks) + files.append( + { + "path": str(PurePosixPath(scope["root"]) / ".gitignore"), + "ownership": artifact["effective_ownership"], + "override": artifact["override"], + "layers": layers, + "content": contents, + "content_sha256": digest(contents), + } + ) + return { + "format": FORMAT, + "status": "plan-only", + "generator": "tools/foundation.py plan-gitignore", + "foundation": resolved["foundation"], + "repository": resolved["repository"], + "profiles": resolved["profiles"], + "source": { + "owner": catalog["owner"], + "catalog_sha256": object_digest(catalog), + "resolved_manifest_sha256": object_digest(resolved), + }, + "files": files, + }, [] From 8b398b3bff54fb95f181dac20af6342ec3ed4cc4 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 17 Sep 2026 20:31:15 -0400 Subject: [PATCH 2/2] =?UTF-8?q?chore(foundation):=20=F0=9F=93=8C=20pin=20t?= =?UTF-8?q?he=20composed=20contract=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate the EgoLint presence projection against the catalog commit in this PR and align the iteration checklist with Markdownlint and Prettier. Refs #82 Roadmap-Step: EMP-Q04 --- docs/foundation/gitignore/ITERATION-01.md | 9 +++++---- foundation/contracts/empathy.repository-contract.toml | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/foundation/gitignore/ITERATION-01.md b/docs/foundation/gitignore/ITERATION-01.md index a1177311..06953757 100644 --- a/docs/foundation/gitignore/ITERATION-01.md +++ b/docs/foundation/gitignore/ITERATION-01.md @@ -14,19 +14,20 @@ before taking the next part. Do not close an issue from a partial PR. ## Repeatable process + 1. **Inspect:** read live issues/PRs, instructions, architecture, decisions, contracts, roadmap, and any continuity record. Record the source revision and existing failures. Reuse an existing issue when its scope matches. -2. **Define:** record purpose, applicability, canonical owner, content model, +1. **Define:** record purpose, applicability, canonical owner, content model, permitted variation, update behavior, and validation. Choose the smallest reviewable outcome and identify real prerequisites. -3. **Implement and prove:** change the canonical source and add meaningful +1. **Implement and prove:** change the canonical source and add meaningful behavioral checks. Include both required behavior and content that must remain usable. Update affected projections only through their generators. -4. **Review and checkpoint:** inspect the whole diff, run relevant checks, link +1. **Review and checkpoint:** inspect the whole diff, run relevant checks, link the PR, distinguish new failures from existing ones, and record incomplete acceptance criteria and the exact next action. Keep the epic ledger current. -5. **Resume after merge:** reread review decisions and live default branches; +1. **Resume after merge:** reread review decisions and live default branches; confirm what actually landed. Update the checkpoint and continue to the next bounded part. Adopt in Filament only after the upstream contract is ready. diff --git a/foundation/contracts/empathy.repository-contract.toml b/foundation/contracts/empathy.repository-contract.toml index 58dfc0c3..7b427065 100644 --- a/foundation/contracts/empathy.repository-contract.toml +++ b/foundation/contracts/empathy.repository-contract.toml @@ -1,12 +1,12 @@ schema-version = 1 id = "empathy-universal-foundation" -version = "1.0.0" +version = "1.1.0" profile = "empathy/golden-foundation" provisional = false [source] repository = "egohygiene/empathy" -revision = "2bc9d77b23acfeb0235f2fc5deb8d0616ab1ff9a" +revision = "ee93d0bbf7e51aeb856eb0ae40c44d0f3e2edaa1" revision-kind = "git-commit" path = "foundation/catalog.json" decision = "https://github.com/egohygiene/empathy/issues/62"