From a085ea7a4ffbbf4ad2071e6efc4211877e729afe Mon Sep 17 00:00:00 2001 From: Yali Date: Sat, 2 May 2026 23:15:21 +0300 Subject: [PATCH] fix: enforce symlink hashing parity across source modes --- .github/actions/boundver/action.yml | 64 +++++ .github/workflows/publish.yml | 30 ++ README.md | 63 ++++- boundary-lockfile.md | 194 ------------- docs/IMPLEMENTATION_PLAN.md | 17 ++ docs/IMPL_LIST.md | 121 +++++++++ docs/LOCKFILE_MERGE.md | 37 +++ docs/WHY_BOUNDVER.md | 35 +++ scripts/boundver-merge-driver.sh | 18 ++ scripts/boundver-verify.sh | 85 ++++++ spec/HASHING.md | 41 +++ spec/boundary.lock.schema.json | 70 +++++ spec/spec.md | 29 ++ src/boundver/core.py | 407 ++++++++++++++++++++++++---- tests/test_boundary_lock.py | 196 ++++++++++++++ 15 files changed, 1144 insertions(+), 263 deletions(-) create mode 100644 .github/actions/boundver/action.yml create mode 100644 .github/workflows/publish.yml delete mode 100644 boundary-lockfile.md create mode 100644 docs/IMPL_LIST.md create mode 100644 docs/LOCKFILE_MERGE.md create mode 100644 docs/WHY_BOUNDVER.md create mode 100755 scripts/boundver-merge-driver.sh create mode 100755 scripts/boundver-verify.sh create mode 100644 spec/HASHING.md create mode 100644 spec/boundary.lock.schema.json create mode 100644 spec/spec.md diff --git a/.github/actions/boundver/action.yml b/.github/actions/boundver/action.yml new file mode 100644 index 0000000..dfd3569 --- /dev/null +++ b/.github/actions/boundver/action.yml @@ -0,0 +1,64 @@ +name: "boundver verify" +description: "Zero-setup boundver verification with optional diff-on-failure" +inputs: + config: + description: "Path to boundary config" + required: false + default: "boundary.config.json" + lock: + description: "Path to lockfile" + required: false + default: "boundary.lock.json" + source: + description: "Fingerprint source mode" + required: false + default: "head" + components: + description: "Optional comma-separated component subset" + required: false + default: "" + python-version: + description: "Python version used by action" + required: false + default: "3.12" + show-diff-on-failure: + description: "Generate and print lockfile diff on verify failure" + required: false + default: "true" +runs: + using: "composite" + steps: + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install boundver + shell: bash + run: | + python -m pip install --upgrade pip + pip install . + + - name: Verify lockfile + shell: bash + run: | + set -euo pipefail + EXTRA_ARGS=() + if [ -n "${{ inputs.components }}" ]; then + EXTRA_ARGS+=(--components "${{ inputs.components }}") + fi + boundver verify \ + --config "${{ inputs.config }}" \ + --lock "${{ inputs.lock }}" \ + --source "${{ inputs.source }}" \ + "${EXTRA_ARGS[@]}" + + - name: Diff on failure + if: ${{ failure() && inputs.show-diff-on-failure == 'true' }} + shell: bash + run: | + set -euo pipefail + boundver generate --config "${{ inputs.config }}" --out /tmp/boundary.lock.new.json --source "${{ inputs.source }}" || true + if [ -f "/tmp/boundary.lock.new.json" ]; then + boundver diff "${{ inputs.lock }}" /tmp/boundary.lock.new.json || true + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..875bce0 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build package + run: | + python -m pip install --upgrade pip + pip install build + python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/README.md b/README.md index 53442ca..483c745 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ In a repository with many components and layered dependencies, you need differen Traditional version managers require humans to answer these questions via commit messages or changelogs. boundver derives the answers from repo state — deterministically, automatically, and with no runtime dependencies beyond Git and Python 3.8+. +For tool-selection guidance and scope boundaries, see `docs/WHY_BOUNDVER.md`. + ## How it works Each component gets three fingerprints: @@ -42,6 +44,8 @@ pip install boundver # Create a starter config boundver init +# Or auto-discover components from common manifests +boundver init --discover # Custom path / overwrite existing boundver init --out boundary.config.json --force @@ -72,6 +76,9 @@ EOF # Generate the lockfile boundver generate +# Regenerate only selected components (and affected slices) +boundver generate --components auth-service,billing-service + # Deterministic output (omits generated_at) boundver generate --deterministic @@ -84,6 +91,12 @@ boundver status # Verify lockfile matches repo state boundver verify +# Verify only selected components +boundver verify --components auth-service,billing-service + +# Verify only components changed since main +boundver verify --changed-from origin/main + # JSON output for automation boundver verify --json @@ -96,6 +109,9 @@ boundver diff old.lock.json boundary.lock.json # Inspect a specific slice boundver slice auth-api + +# Preview discovered components +boundver discover --json ``` ## Behavior matrix @@ -199,8 +215,32 @@ Short term deliverables: `validate-config`, strict digest selection, explicit so ## CI integration +For lockfile merge conflict handling, see `docs/LOCKFILE_MERGE.md`. + ### GitHub Actions — PR verification +#### Option A: use bundled composite action + +```yaml +name: Boundary check +on: [pull_request] +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/boundver + with: + config: boundary.config.json + lock: boundary.lock.json + source: head + show-diff-on-failure: "true" +``` + +#### Option B: explicit steps + ```yaml name: Boundary check on: [pull_request] @@ -238,6 +278,13 @@ if [ "$NEW_FP" != "$CACHED_FP" ]; then fi ``` +### Shell verifier (portability proof) + +```bash +# Verifies exact/boundary fingerprints against HEAD using git + jq + sha256sum +scripts/boundver-verify.sh boundary.config.json boundary.lock.json +``` + ## Design decisions - **No external dependencies.** Only Git and Python stdlib. Runs anywhere Python 3.8+ and Git are available. @@ -267,18 +314,16 @@ pip install "boundver[yaml]" Without `jsonschema`, boundver still runs and applies built-in semantic validation checks. -## Ignore behavior for `--source=working-tree` +## Release -For working-tree hashing, boundver currently uses a **built-in ignore list** (this is not `.gitignore`-aware yet): +- PyPI publish workflow: `.github/workflows/publish.yml` +- Trigger: push a version tag matching `v*` (for example `v0.3.0`) -- dot-prefixed names (e.g. `.cache`, `.venv`) -- `__pycache__` -- `node_modules` -- `*.pyc` -- `dist` -- `build` +## Ignore behavior for `--source=working-tree` -For `--source=head` and `--source=index`, content and path enumeration are Git-backed and therefore based on Git object state rather than local traversal ignores. +`--source=working-tree` prefers Git-backed tracked-file enumeration (`git ls-files`) when available. +In non-git fallback contexts, local file traversal is used. +Symlinks are hashed as link-target text (not dereferenced bytes) for cross-source consistency. ## Requirements diff --git a/boundary-lockfile.md b/boundary-lockfile.md deleted file mode 100644 index 3e1415e..0000000 --- a/boundary-lockfile.md +++ /dev/null @@ -1,194 +0,0 @@ -# Boundary Lockfile System - -A semantic version manifest for the HSL monorepo that answers three distinct questions per component: - -| Question | Fingerprint | What it hashes | -|---|---|---| -| Did the implementation change? | `exact` | Git tree hash of the entire component directory | -| Did the public API change? | `api` | Hash of only the declared boundary files | -| Is it still compatible? | `compat` | SemVer major (or major.minor) identity | - -Components are grouped into **slices** — named subsets with their own stable fingerprints. Adding an unrelated component changes the full-project hash but leaves existing slice fingerprints untouched. - -## Quick start - -```bash -# 1. Place both files at repo root -cp boundary.config.json /path/to/repo/ -cp boundary_lock.py /path/to/repo/tools/ - -# 2. Generate the lockfile -cd /path/to/repo -python tools/boundary_lock.py generate - -# 3. Check status -python tools/boundary_lock.py status - -# 4. After making changes, verify -python tools/boundary_lock.py verify - -# 5. Diff two lockfiles -python tools/boundary_lock.py diff boundary.lock.json.bak boundary.lock.json - -# 6. Inspect a single slice -python tools/boundary_lock.py slice techscout-bi-pipeline -``` - -## Files - -``` -boundary.config.json ← Component registry + slice definitions (checked in, edited by humans) -boundary.lock.json ← Generated manifest with fingerprints (checked in, generated by tool) -tools/boundary_lock.py ← CLI tool (checked in) -``` - -## How fingerprints work - -### Exact fingerprint - -Uses `git rev-parse HEAD:` — the native Git tree hash. This changes whenever any file in the component directory changes, including comments, formatting, and internal refactors. - -### API fingerprint - -Hashes **only** the files listed in `boundary.paths` in the config. For example, `techscout-kb-api` has `boundary.paths: ["openapi.yaml"]`, so only changes to the OpenAPI spec change the API fingerprint. Internal handler changes are invisible at this level. - -For components with no boundary artifact (marked `"kind": "implicit"`), the API fingerprint is `null`. This is intentional — it flags components that need boundary definitions. - -### Compat fingerprint - -Derived from the SemVer major version: `sha256(component_name + "@compat:" + major)`. Two versions in the same major family produce the same compat fingerprint. - -Under the project's convention where `major.minor.patch` means: -- **patch**: implementation/fix, no API change -- **minor**: API surface addition, backward compatible -- **major**: compatibility-breaking change - -The compat fingerprint uses major only. Adjust `defaults.compat_mode` in the config if you want major.minor. - -### Slice fingerprints - -Each slice selects components and a comparison mode (`exact`, `api`, or `compat`). The slice fingerprint is `sha256(canonical_json({component: selected_digest}))`. - -Example: the `techscout-api` slice uses mode `api` and contains only `techscout-kb-api`. Its fingerprint changes only when `openapi.yaml` changes — not when handler code, tests, or other components change. - -## Behavior matrix - -| Event | full exact | slice exact | full api | slice api | compat | -|---|---|---|---|---|---| -| Bug fix in one component | changes | changes if included | unchanged | unchanged | unchanged | -| New API endpoint added | changes | changes if included | changes | changes if included | unchanged | -| Breaking API change + major bump | changes | changes if included | changes | changes if included | changes | -| New unrelated component added | changes | **unchanged** | changes | **unchanged** | n/a | -| Internal refactor, no API change | changes | changes if included | **unchanged** | **unchanged** | unchanged | - -## Config reference - -### Component fields - -```json -{ - "path": "relative/from/repo/root", - "ecosystem": "python | typescript | cloudformation", - "version_source": { - "file": "package.json", // relative to component path - "field": "version" // dotted path into the file - }, - // OR - "version_source": { - "git_tag_prefix": "data-processing-v" // extracts version from latest matching tag - }, - // OR - "version_source": null, // no version tracked - - "boundary": { - "kind": "openapi | service-definition | python-exports | typescript-exports | sam-routes | leaf | implicit", - "paths": ["openapi.yaml"], // files that constitute the public boundary - "note": "optional explanation" - }, - - "vendored_copies": [ // optional: paths to vendored duplicates - "projects/experts-panel/backend/bi-jobs/vendor/json_heal/" - ] -} -``` - -### Slice fields - -```json -{ - "description": "Human-readable purpose", - "mode": "exact | api | compat", // which fingerprint to use (default: exact) - "components": ["comp-a", "comp-b"] -} -``` - -## CI integration - -### Pre-commit / PR check - -```yaml -# .github/workflows/boundary-check.yml -name: Boundary lockfile check -on: [pull_request] -jobs: - verify: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # need full history for git tree hashes - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Verify lockfile - run: python tools/boundary_lock.py verify - - name: Show diff if stale - if: failure() - run: | - python tools/boundary_lock.py generate --out boundary.lock.new.json - python tools/boundary_lock.py diff boundary.lock.json boundary.lock.new.json -``` - -### Release tagging - -```bash -# After generating, read slice fingerprints for tagging -SLICE_FP=$(python -c " -import json -lock = json.load(open('boundary.lock.json')) -print(lock['slices']['techscout-api']['fingerprint'][:12]) -") -echo "TechScout API slice: $SLICE_FP" -``` - -## Vendored copy detection - -Components with `vendored_copies` are checked for drift. If `api_tracking/` (the source) differs from `platform/api_tracking/` (the vendored copy), the lockfile will include a warning: - -``` -VENDORED DRIFT api-tracking: Vendored copy at platform/api_tracking/ differs from source -``` - -This surfaces the duplication problem without blocking — you decide when to consolidate. - -## Roadmap: strengthening boundaries - -The config marks each component's boundary kind. Components marked `"implicit"` have no machine-readable boundary artifact. Priority order for adding explicit boundaries: - -1. **hsl-platform** — most consumed, highest blast radius. Generate Python type stubs or define explicit `__all__` exports. -2. **Platform API** — generate OpenAPI spec from SAM `main.yaml` route definitions. -3. **@hsl/platform-client** — generate `.d.ts` declarations as the boundary artifact. -4. **BI jobs missing service definitions** — create `.service-definition.json` for `field_tagging`, `in_depth_analysis`, `in_depth_assembly`, `tech_classifier`. -5. **TechScout BI shared/** — define an explicit interface module. - -As each boundary is made explicit, update `boundary.paths` in the config. The API fingerprint will automatically start tracking the right thing. - -## Design decisions - -**Why not Bazel/Nix?** The project doesn't use them and adopting a full build system to get versioning would be disproportionate. This tool uses only Git and Python stdlib. - -**Why canonical JSON, not just `JSON.stringify`?** Property ordering in JSON is not guaranteed. `json.dumps(sort_keys=True, separators=(',',':'))` produces deterministic output per RFC 8785 principles. Two machines generating the lockfile from the same repo state will get the same hashes. - -**Why git tree hashes for exact identity?** Git already content-addresses every directory. `git rev-parse HEAD:path` is fast, built-in, and changes exactly when files change. No need to reinvent this. - -**Why separate config and lockfile?** The config is a human-maintained registry (what components exist, where their boundaries are, what slices matter). The lockfile is machine-generated state (current fingerprints). This mirrors the `package.json` / `package-lock.json` split. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index fdfb54f..439a77c 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -46,6 +46,23 @@ This plan consolidates the two latest review passes into one prioritized, issue- - ✅ Completed: **P3.25+** machine-readable JSON output added for generate/verify/diff/status command flows. - ✅ Completed: **P3.26+** added CLI logging controls (`--quiet`, `--verbose`). - ✅ Completed: **P3.30** added large-repo hash guardrails (max files + max file size) with explicit component errors. +- ✅ Completed: **Spec-first milestone** added `spec/boundary.lock.schema.json`. +- ✅ Completed: **Spec-first milestone** added `spec/HASHING.md` determinism contract. +- ✅ Completed: **Spec-first milestone** added `spec/spec.md` overview and v1 terminology contract. +- ✅ Completed: **P2.23 (partial+)** removed stale legacy `boundary-lockfile.md`. +- ✅ Completed: **P2 item from reviews** added `docs/WHY_BOUNDVER.md` tool-selection decision guide. +- ✅ Completed: **P2.11 (initial)** added `explain` command to surface component and boundary-relevant changed files. +- ✅ Completed: **P2.13 (partial)** verify now supports component-scoped checks via `--components`. +- ✅ Completed: **P2.13 (partial+)** generate now supports component-scoped refresh via `--components`. +- ✅ Completed: **P2.10 (initial)** documented lockfile merge resolution + added merge-driver regeneration script. +- ✅ Completed: **P2.14 (initial)** added manifest-based discovery via `discover` and `init --discover`. +- ✅ Completed: **P1.5 (initial)** added composite GitHub Action for verify + optional diff-on-failure. +- ✅ Completed: **P3.30/PyPI path** added tag-triggered PyPI publish workflow (`publish.yml`). +- ✅ Completed: **P1.7 from impl list** added shell-based HEAD verifier (`scripts/boundver-verify.sh`). +- ✅ Completed: **P0.4 (partial+)** working-tree/index path enumeration now uses git-native tracked-file listing. +- ✅ Completed: **P2.12 (partial)** boundary digest computation now de-duplicates overlapping path expansions. +- ✅ Completed: **P2.15 (initial)** verify now supports auto-scoping by changed files via `--changed-from`. +- ✅ Completed: **P0.4 (partial++)** symlink hashing now uses link-target text for cross-source parity. - ✅ Completed: **Core split (partial)** extracted version parsing/extraction into `src/boundver/versions.py`. - ✅ Completed: **Hidden failure-mode fix** verify now short-circuits on malformed lockfile structure issues to avoid secondary crashes. - ✅ Completed: **Hidden failure-mode fix** `versions.extract_version` now safely handles git-tag sources when resolver is unavailable. diff --git a/docs/IMPL_LIST.md b/docs/IMPL_LIST.md new file mode 100644 index 0000000..2505b5d --- /dev/null +++ b/docs/IMPL_LIST.md @@ -0,0 +1,121 @@ +# Implementation List (Consolidated Reviews) + +## Goal +Turn boundver from a useful Python CLI into a portable, spec-driven contract system that teams can reliably adopt in CI. + +## Progress update (2026-05-02) +- ✅ Completed: `spec/boundary.lock.schema.json`. +- ✅ Completed: `spec/HASHING.md`. +- ✅ Completed: `spec/spec.md`. +- ✅ Completed: removed legacy `boundary-lockfile.md`. +- ✅ Completed: added `docs/WHY_BOUNDVER.md` positioning/decision guide. +- ✅ Completed: added `boundver explain ` for boundary-relevant change visibility. +- ✅ Completed: added scoped verification via `boundver verify --components ...`. +- ✅ Completed: added partial scoped generation via `boundver generate --components ...`. +- ✅ Completed: added lockfile merge strategy doc + merge-driver script (`docs/LOCKFILE_MERGE.md`, `scripts/boundver-merge-driver.sh`). +- ✅ Completed: added component discovery (`discover` command + `init --discover`). +- ✅ Completed: added bundled GitHub composite action (`.github/actions/boundver/action.yml`) for verify + diff-on-failure. +- ✅ Completed: added tag-triggered PyPI publish workflow (`.github/workflows/publish.yml`). +- ✅ Completed: added portability shell verifier (`scripts/boundver-verify.sh`). +- ✅ Completed: switched working-tree/index enumeration to git-native tracked-file listing (`git ls-files`). +- ✅ Completed: reduced boundary hashing overhead by de-duplicating overlapping boundary path expansions. +- ✅ Completed: added CI-race mitigation helper via `verify --changed-from `. +- ✅ Completed: defined and implemented symlink hashing policy (hash link-target text, not dereferenced bytes). + +## Consolidated priorities (captures both review sets) + +### P0 — Spec is the product (do first) +1. Publish **lockfile schema** (`spec/boundary.lock.schema.json`). +2. Publish **hashing determinism contract** (`spec/HASHING.md`) with precise rules for: + - enumeration by source mode (`head`, `index`, `working-tree`), + - path normalization to POSIX, + - digest input format `file:{posix_path}\n{bytes}`, + - sort order, component digest derivation, boundary/compat derivation, + - slice aggregation + canonical JSON. +3. Publish **core spec doc** (`spec/spec.md`) defining exact/boundary/compat semantics. +4. Standardize file enumeration to git-native commands where possible (especially `index`/`working-tree` consistency), and explicitly document edge cases: + - binary files included, + - symlinks behavior, + - empty directories excluded, + - permission bits excluded, + - Git LFS cross-mode caveat. + +### P1 — Distribution and zero-friction adoption +5. Ship **GitHub Action** (`uses: yzm1/boundver-action@v1`) that hides runtime setup and supports verify + diff-on-failure. +6. Publish to **PyPI** so README install command is true. +7. Add minimal **shell verifier** (`tools/boundver-verify.sh`) as portability proof/spec compliance test. +8. Remove stale legacy doc `boundary-lockfile.md` (or replace with redirect note) to avoid conflicting guidance. +9. Add a clear “**Why boundver**” decision doc (when to use vs Bazel/Nx/Turborepo/Pants). + +### P2 — Day-1 usability blockers (highest real-team pain) +10. Lockfile merge conflict strategy: + - document “never hand-merge lockfile; regenerate,” + - optionally provide merge driver/hook and post-merge regenerate helper. +11. Explainability: + - add `boundver explain` or verbose verify output to show *which files/inputs changed*. +12. Large-repo performance: + - reduce per-file subprocess overhead, + - batch git reads where feasible. +13. Subset operations: + - `generate/verify --components ...` and/or `--slice ...` to avoid full-repo recompute. +14. First-run adoption: + - `init --discover` / `discover` for common ecosystems (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, etc.). +15. CI race/friction mitigation: + - guidance and/or scoped verify so unrelated main-branch lockfile churn does not constantly break PRs. +16. Gradual adoption story: + - document/component-level incremental rollout pattern explicitly. + +### P3 — Product-model maturity (from fingerprint tool to decision tool) +17. Real provider architecture: + - provider interface (`extract`, `normalize`, `digest`, `validate_config`, `explain_diff`). +18. Semantic/canonical providers: + - `json-canonical`, `openapi-canonical`, configurable `openapi-contract`, TS/public API, Python/public symbol providers. +19. Multi-boundary components: + - support multiple named boundaries per component (REST/events/CLI/schema/etc.). +20. Dependency/impact model: + - component dependency graph, + - `impact`, `affected`, `why` style commands. +21. Richer identity model: + - clarify long-term separation among exact/boundary/compat/api-version (and possible behavior identity concept). + +### P4 — Compatibility, contracts, and governance +22. Migration policy for terminology/schema changes: + - explicit `api -> boundary` compatibility window, + - `migrate-config` and `migrate-lock` commands. +23. Stable machine contracts: + - document JSON schemas for `verify/status/diff` outputs, + - define exit code policy and compatibility guarantees. +24. CI quality gates: + - keep manual flow, plus low-cost path-filtered PR checks. +25. Security model (before custom providers become mainstream): + - custom provider execution policy, + - CI defaults/allowlists, + - path/symlink escape constraints. +26. Maintainer sustainability: + - define ownership/triage expectations and “project continuity” docs so bus factor > 1. + +## Strategic checkpoints (non-code but required for correct prioritization) +27. Confirm at least one real team is actively using boundver in CI; treat that team’s friction as top-priority input. +28. Validate target distribution persona: + - if CI/platform users are the main audience, prioritize Action/docs over runtime rewrites. +29. Dogfood boundver on boundver itself: + - track CLI/config/lockfile interfaces as boundary examples. +30. Re-evaluate roadmap quarterly against ecosystem overlap (Nx/Turborepo/Bazel/Pants) and keep positioning “simple declarative boundary fingerprinting” sharp. + +## Explicit non-goals (for now) +- Do **not** do a rewrite-first plan (Go/Rust) before the spec + adoption path are proven. +- Do **not** add plugin-marketplace complexity before provider interfaces and user demand are clear. +- Do **not** over-split modules purely for style before behavior/contracts are stabilized. +- Do **not** build docs-site ceremony before spec + examples + CI path are solid. + +## Suggested execution sequence +- **Phase A (1–2 days):** P0 items 1–4. +- **Phase B (1 day):** P1 items 5–9. +- **Phase C (next sprint):** P2 items 10–16. +- **Phase D (following sprints):** P3/P4 items based on early adopter pain. + +## Definition of “qualitative leap” +Boundver should be perceived as: +- a **portable spec** with a reference implementation, +- a **zero-install CI workflow** for teams, +- an **explainable impact tool** (not just hash mismatch reporter). diff --git a/docs/LOCKFILE_MERGE.md b/docs/LOCKFILE_MERGE.md new file mode 100644 index 0000000..a0dc48c --- /dev/null +++ b/docs/LOCKFILE_MERGE.md @@ -0,0 +1,37 @@ +# Lockfile Merge Strategy + +`boundary.lock.json` is a generated artifact. When parallel branches both regenerate it, Git conflicts are expected. + +## Future-proof rule +Do **not** hand-edit lockfile conflict hunks. +Always regenerate from `boundary.config.json`. + +## Manual resolution +```bash +# After merge conflict appears +boundver generate --deterministic + +git add boundary.lock.json +``` + +## Optional Git merge driver (recommended) + +1. Add to `.gitattributes`: + +```gitattributes +boundary.lock.json merge=boundver-lock +``` + +2. Register merge driver locally: + +```bash +git config merge.boundver-lock.name "boundver lockfile regenerate" +git config merge.boundver-lock.driver "scripts/boundver-merge-driver.sh %A" +``` + +3. Ensure `boundver` is available in your environment. + +Now when `boundary.lock.json` conflicts, Git invokes the driver, which regenerates deterministic lock output and writes `%A`. + +## CI note +If your CI runs `boundver verify`, merge-driver output is naturally validated during PR checks. diff --git a/docs/WHY_BOUNDVER.md b/docs/WHY_BOUNDVER.md new file mode 100644 index 0000000..9394eff --- /dev/null +++ b/docs/WHY_BOUNDVER.md @@ -0,0 +1,35 @@ +# Why boundver? + +Use boundver when you want **deterministic component and boundary fingerprints** in a monorepo without adopting a full build system. + +## Quick decision guide + +### You probably **do not** need boundver if… +- You already use Bazel/Pants/Nx/Turborepo for end-to-end affected graph, caching, and orchestration. +- Your existing build system already provides the dependency-impact and cache-key granularity you need. + +### You probably **do** need boundver if… +- You have a multi-component repo and need a lightweight, declarative contract for: + - implementation identity (`exact`), + - declared boundary identity (`boundary`), + - compatibility family identity (`compat`). +- You want stable CI cache keys and verification signals without migrating your entire build stack. +- You need a portable, source-controlled lockfile contract (`boundary-lock/v1`) that can be consumed by scripts/tools. + +## Positioning vs larger build tools +- **Bazel/Pants**: broad build + dependency graph platforms. High power, higher adoption cost. +- **Nx/Turborepo**: task graph + affected/caching ecosystems, primarily JS/TS-centric workflows. +- **boundver**: narrow scope; deterministic fingerprints/spec-first contract for component boundaries. + +boundver is best when you want a small primitive that can plug into existing CI/CD, not a full workflow replacement. + +## Current reality +- Boundver today is strongest as a deterministic fingerprint/lockfile tool. +- Semantic boundary understanding (e.g., canonical OpenAPI/TS/Python contract diffs) is on the roadmap. +- Dependency impact graph features are roadmap work; current slices are explicit sets. + +## Adoption pattern +1. Start with one slice that maps to one deployable unit. +2. Gate PRs with `verify` and use slice fingerprint for cache keying. +3. Expand component coverage incrementally. +4. Add semantic providers later as they mature. diff --git a/scripts/boundver-merge-driver.sh b/scripts/boundver-merge-driver.sh new file mode 100755 index 0000000..3b5a1f8 --- /dev/null +++ b/scripts/boundver-merge-driver.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Git merge driver interface: +# %O = ancestor file, %A = current file (must write result), %B = other file +# We intentionally ignore file inputs and regenerate canonical lockfile from config. + +LOCKFILE_PATH="${1:-boundary.lock.json}" +CONFIG_PATH="${BOUNDVER_CONFIG:-boundary.config.json}" +SOURCE_MODE="${BOUNDVER_SOURCE:-head}" + +boundver generate \ + --config "$CONFIG_PATH" \ + --out "$LOCKFILE_PATH" \ + --source "$SOURCE_MODE" \ + --deterministic + +exit 0 diff --git a/scripts/boundver-verify.sh b/scripts/boundver-verify.sh new file mode 100755 index 0000000..6b10f44 --- /dev/null +++ b/scripts/boundver-verify.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_PATH="${1:-boundary.config.json}" +LOCK_PATH="${2:-boundary.lock.json}" + +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq is required" >&2 + exit 2 +fi +if ! command -v sha256sum >/dev/null 2>&1; then + echo "ERROR: sha256sum is required" >&2 + exit 2 +fi + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +hash_from_paths() { + local -a paths=("$@") + if [ ${#paths[@]} -eq 0 ]; then + printf "" + return 0 + fi + local tmp + tmp="$(mktemp)" + : > "$tmp" + for rel in "${paths[@]}"; do + local content + content="$(git show "HEAD:${rel}" 2>/dev/null || true)" + if [ -z "$content" ] && ! git cat-file -e "HEAD:${rel}" 2>/dev/null; then + continue + fi + printf "file:%s\n" "${rel}" >> "$tmp" + git show "HEAD:${rel}" >> "$tmp" + done + if [ ! -s "$tmp" ]; then + rm -f "$tmp" + printf "" + return 0 + fi + sha256sum "$tmp" | awk '{print $1}' + rm -f "$tmp" +} + +status=0 + +mapfile -t component_names < <(jq -r '.components | keys[]' "$CONFIG_PATH") + +for name in "${component_names[@]}"; do + comp_path="$(jq -r --arg n "$name" '.components[$n].path' "$CONFIG_PATH")" + mapfile -t exact_files < <(git ls-tree -r --name-only HEAD "$comp_path" | sed '/^$/d') + exact_hash="$(hash_from_paths "${exact_files[@]}")" + + mapfile -t boundary_rel < <(jq -r --arg n "$name" '.components[$n].boundary.paths[]? // empty' "$CONFIG_PATH") + boundary_files=() + for rel in "${boundary_rel[@]}"; do + joined="${comp_path%/}/${rel}" + while IFS= read -r f; do + [ -n "$f" ] && boundary_files+=("$f") + done < <(git ls-tree -r --name-only HEAD "$joined") + if [ ${#boundary_files[@]} -eq 0 ] && git cat-file -e "HEAD:${joined}" 2>/dev/null; then + boundary_files+=("$joined") + fi + done + boundary_hash="$(hash_from_paths "${boundary_files[@]}")" + + lock_exact="$(jq -r --arg n "$name" '.components[$n].fingerprints.exact // ""' "$LOCK_PATH")" + lock_boundary="$(jq -r --arg n "$name" '.components[$n].fingerprints.boundary // ""' "$LOCK_PATH")" + + if [ "$exact_hash" != "$lock_exact" ]; then + echo "MISMATCH ${name}.exact" + status=1 + fi + if [ "$boundary_hash" != "$lock_boundary" ]; then + echo "MISMATCH ${name}.boundary" + status=1 + fi + +done + +if [ $status -eq 0 ]; then + echo "OK: lockfile matches HEAD for exact/boundary" +fi +exit $status diff --git a/spec/HASHING.md b/spec/HASHING.md new file mode 100644 index 0000000..f37e788 --- /dev/null +++ b/spec/HASHING.md @@ -0,0 +1,41 @@ +# HASHING Contract (v1) + +This document defines the deterministic hashing contract for `boundary-lock/v1`. + +## Core rules +- Hash algorithm: SHA-256 (hex lowercase output). +- Content basis: raw bytes. +- Path basis: POSIX-normalized path separators (`\\` -> `/`). +- Per-file contribution bytes: + + `b"file:{posix_path}\\n" + file_bytes` + +- Component digest input is the concatenation of all per-file contribution bytes in lexicographic path order. + +## File enumeration by source mode +- `head`: files from `git ls-tree -r --name-only HEAD `. +- `index`: tracked file set for the path from index (current implementation reads blob bytes from `git show :`). +- `working-tree`: tracked files in the repository for the path (excluding untracked files). + +> Note: this document defines the target deterministic contract. Any implementation detail drift should be treated as a bug. + +## Derived digests +- `exact`: digest over all tracked files under component path. +- `boundary`: same digest algorithm, but restricted to declared `boundary.paths` expansion. +- `compat`: SHA-256 of UTF-8 text identity string: + + `{component_name}@compat:{compat_identity}` + +- `slice`: SHA-256 over canonical JSON of `{component_name: selected_digest}` where `selected_digest` is chosen by slice mode (`exact`, `boundary`, `compat`). + +## Canonical JSON +- UTF-8 encoded. +- Object keys sorted. +- Compact separators: `,` and `:` (no insignificant whitespace). + +## Edge-case contract +- Binary files: included (raw bytes). +- Empty directories: excluded (not tracked by Git). +- File permissions/mode bits: excluded from digest input. +- Symlinks: hashed as symlink link-target text (not dereferenced file contents). +- Git LFS: pointer blobs are hashed from the selected source state; cross-mode parity can differ when working tree is smudged. diff --git a/spec/boundary.lock.schema.json b/spec/boundary.lock.schema.json new file mode 100644 index 0000000..28fb553 --- /dev/null +++ b/spec/boundary.lock.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/yzm1/boundver/main/spec/boundary.lock.schema.json", + "title": "boundver lockfile schema", + "type": "object", + "required": ["schema", "project", "components", "slices"], + "properties": { + "schema": {"const": "boundary-lock/v1"}, + "project": {"type": "string"}, + "generated_at": {"type": "string", "format": "date-time"}, + "components": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["version", "path", "boundary_provider", "boundary_status", "fingerprints", "semver"], + "properties": { + "version": {"type": "string"}, + "path": {"type": "string"}, + "boundary_provider": {"type": "string"}, + "boundary_status": {"type": "string", "enum": ["ok", "partial", "error"]}, + "fingerprints": { + "type": "object", + "required": ["exact", "boundary", "compat"], + "properties": { + "exact": {"type": ["string", "null"]}, + "boundary": {"type": ["string", "null"]}, + "compat": {"type": ["string", "null"]} + }, + "additionalProperties": false + }, + "semver": { + "type": "object", + "required": ["compat_family", "api_surface", "exact_version"], + "properties": { + "compat_family": {"type": ["integer", "null"]}, + "api_surface": {"type": ["string", "null"]}, + "exact_version": {"type": "string"} + }, + "additionalProperties": false + }, + "boundary_errors": {"type": "array", "items": {"type": "string"}}, + "exact_errors": {"type": "array", "items": {"type": "string"}}, + "warnings": {"type": "array", "items": {"type": "string"}}, + "vendored_copies": {"type": "array", "items": {"type": "string"}}, + "vendored_digests": {"type": "object", "additionalProperties": {"type": "string"}} + }, + "additionalProperties": true + } + }, + "slices": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["description", "mode", "components", "fingerprint", "component_digests"], + "properties": { + "description": {"type": "string"}, + "mode": {"type": "string", "enum": ["exact", "boundary", "compat"]}, + "components": {"type": "array", "items": {"type": "string"}}, + "fingerprint": {"type": "string"}, + "component_digests": { + "type": "object", + "additionalProperties": {"type": ["string", "null"]} + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/spec/spec.md b/spec/spec.md new file mode 100644 index 0000000..ff4b0bf --- /dev/null +++ b/spec/spec.md @@ -0,0 +1,29 @@ +# boundver Spec Overview (v1) + +## Lockfile schema +- Lockfile schema identifier: `boundary-lock/v1`. +- Canonical schema file: `spec/boundary.lock.schema.json`. + +## Component facets +Each component includes three fingerprints: +- `exact`: all tracked files in component path. +- `boundary`: declared boundary subset via configured provider + paths. +- `compat`: digest derived from compatibility identity (SemVer family mode). + +## Slice model +- A slice is a named set of components plus a mode (`exact`, `boundary`, `compat`). +- Slice fingerprint is stable for unchanged selected component digests. +- Adding unrelated components does not change existing slice digests unless slice membership changes. + +## Determinism +- Hashing/ordering/path/canonicalization rules are defined in `spec/HASHING.md`. +- Any implementation that follows this contract should produce matching digests for equivalent repo/source state. + +## Source modes +- `head`: committed state at `HEAD`. +- `index`: staged/index state. +- `working-tree`: current checked-out tracked files. + +## Forward approach +- No legacy alias expansion in spec language; `boundary` terminology is canonical. +- Future schema changes should use new schema identifiers instead of overloading v1 behavior. diff --git a/src/boundver/core.py b/src/boundver/core.py index e3c2751..47384b1 100644 --- a/src/boundver/core.py +++ b/src/boundver/core.py @@ -110,32 +110,12 @@ def _is_within(base: Path, candidate: Path) -> bool: def source_tree_digest(repo_root: Path, path: str, source: str = "head") -> Optional[str]: """Canonical SHA-256 digest for a path from HEAD, index, or working tree.""" - target = repo_root / path content_parts: List[bytes] = [] file_count = 0 - if source == "head": - files = list_head_files(repo_root, path) - for rel in files: - posix_rel = _to_posix(rel) - content_parts.append(f"file:{posix_rel}\n".encode("utf-8")) - file_count += 1 - _enforce_hash_guardrails(repo_root / rel, file_count) - content = _read_path_content(repo_root, repo_root / rel, source) - _enforce_content_size(content, posix_rel) - content_parts.append(content) - return hashlib.sha256(b"".join(content_parts)).hexdigest() if content_parts else None - - if not target.exists(): + files = _list_files_for_source(repo_root, path, source) + if not files: return None - if target.is_file(): - files = [_to_posix(str(target.relative_to(repo_root)))] - else: - files = [ - _to_posix(str(f.relative_to(repo_root))) - for f in sorted(target.rglob("*")) - if f.is_file() and not _is_ignored(f) - ] for rel in files: content_parts.append(f"file:{rel}\n".encode("utf-8")) @@ -178,6 +158,36 @@ def _head_entries_for_path(repo_root: Path, base_path: str) -> List[str]: return [] +def _list_files_for_source(repo_root: Path, repo_rel_path: str, source: str) -> List[str]: + if source == "head": + return list_head_files(repo_root, repo_rel_path) + args = ["ls-files"] + if source == "index": + args.append("--cached") + args.extend(["--", repo_rel_path]) + try: + result = _git_run(repo_root, args) + except subprocess.CalledProcessError: + result_files: List[str] = [] + else: + result_files = [_to_posix(line.strip()) for line in result.stdout.splitlines() if line.strip()] + + if result_files: + return result_files + + # Fallback for non-git test/runtime environments: local filesystem enumeration. + target = repo_root / repo_rel_path + if not target.exists(): + return [] + if target.is_file(): + return [_to_posix(str(target.relative_to(repo_root)))] + return [ + _to_posix(str(f.relative_to(repo_root))) + for f in sorted(target.rglob("*")) + if f.is_file() and not _is_ignored(f) + ] + + def boundary_paths_digest( repo_root: Path, component_path: str, relative_paths: List[str], source: str = "working-tree" ) -> Optional[str]: @@ -187,40 +197,24 @@ def boundary_paths_digest( """ content_parts: List[bytes] = [] file_count = 0 + seen_entries = set() for rel in sorted(relative_paths): rel = rel.strip() - if source == "head": - base = _to_posix(str(Path(component_path) / rel)) - head_entries = _head_entries_for_path(repo_root, base) - for entry in head_entries: - child_rel = _to_posix(str(Path(entry).relative_to(component_path))) - content_parts.append(f"file:{child_rel}\n".encode("utf-8")) - file_count += 1 - _enforce_hash_guardrails(repo_root / entry, file_count) - content = _read_path_content(repo_root, repo_root / entry, source) - _enforce_content_size(content, child_rel) - content_parts.append(content) - continue - - full = repo_root / component_path / rel - if full.is_file(): - norm_rel = _to_posix(rel) - content_parts.append(f"file:{norm_rel}\n".encode("utf-8")) + base = _to_posix(str(Path(component_path) / rel)) + entries = _list_files_for_source(repo_root, base, source) + if source == "head" and not entries: + entries = _head_entries_for_path(repo_root, base) + for entry in entries: + if entry in seen_entries: + continue + seen_entries.add(entry) + child_rel = _to_posix(str(Path(entry).relative_to(component_path))) + content_parts.append(f"file:{child_rel}\n".encode("utf-8")) file_count += 1 - _enforce_hash_guardrails(full, file_count) - content = _read_path_content(repo_root, full, source) - _enforce_content_size(content, norm_rel) + _enforce_hash_guardrails(repo_root / entry, file_count) + content = _read_path_content(repo_root, repo_root / entry, source) + _enforce_content_size(content, child_rel) content_parts.append(content) - elif full.is_dir(): - for child in sorted(full.rglob("*")): - if child.is_file() and not _is_ignored(child): - child_rel = _to_posix(str(child.relative_to(repo_root / component_path))) - content_parts.append(f"file:{child_rel}\n".encode("utf-8")) - file_count += 1 - _enforce_hash_guardrails(child, file_count) - content = _read_path_content(repo_root, child, source) - _enforce_content_size(content, child_rel) - content_parts.append(content) if not content_parts: return None return hashlib.sha256(b"".join(content_parts)).hexdigest() @@ -234,6 +228,9 @@ def _read_path_content(repo_root: Path, full_path: Path, source: str) -> bytes: if source == "head": result = _git_run(repo_root, ["show", f"HEAD:{rel}"]) return result.stdout.encode("utf-8") + if full_path.is_symlink(): + # Hash symlink link-target text (matches what Git stores for symlink blobs). + return os.readlink(full_path).encode("utf-8") return full_path.read_bytes() @@ -417,6 +414,96 @@ def generate_lockfile( return lockfile +def _recompute_slice_entry( + slice_name: str, + slice_def: dict, + components_map: Dict[str, dict], + strict: bool = True, +) -> dict: + mode = slice_def.get("mode", "exact") + component_names = slice_def["components"] + digest_parts: Dict[str, Optional[str]] = {} + for cname in sorted(component_names): + comp_entry = components_map.get(cname) + if comp_entry is None: + digest_parts[cname] = None + continue + fp = comp_entry.get("fingerprints", {}) + if mode == "exact": + digest_parts[cname] = fp.get("exact") + elif mode == "boundary": + if fp.get("boundary") is None and strict: + raise ValueError(f"Slice '{slice_name}' requires boundary digest for component '{cname}'") + digest_parts[cname] = fp.get("boundary") + elif mode == "compat": + if fp.get("compat") is None and strict: + raise ValueError(f"Slice '{slice_name}' requires compat digest for component '{cname}'") + digest_parts[cname] = fp.get("compat") + else: + raise ValueError(f"Unknown slice mode: {mode}") + return { + "description": slice_def.get("description", ""), + "mode": mode, + "components": sorted(component_names), + "fingerprint": sha256_hex(canonical_json(digest_parts)), + "component_digests": digest_parts, + } + + +def generate_lockfile_for_components( + config: dict, + repo_root: Path, + selected_components: List[str], + out_path: Path, + source: str = "head", + strict: bool = True, + deterministic: bool = False, +) -> dict: + """Generate/update lockfile only for selected components and impacted slices.""" + selected = sorted(set(selected_components)) + components_cfg = config.get("components", {}) + missing = [n for n in selected if n not in components_cfg] + if missing: + raise ValueError(f"Unknown component(s): {', '.join(missing)}") + + subset_config = dict(config) + subset_config["components"] = {n: components_cfg[n] for n in selected} + subset_config["slices"] = {} + subset_lock = generate_lockfile( + subset_config, repo_root, source=source, strict=strict, deterministic=deterministic + ) + + if out_path.exists(): + merged = json.loads(out_path.read_text()) + else: + merged = { + "schema": "boundary-lock/v1", + "project": config.get("project", "unknown"), + "components": {}, + "slices": {}, + } + if not deterministic: + merged["generated_at"] = datetime.now(timezone.utc).isoformat() + elif "generated_at" in merged: + del merged["generated_at"] + merged["schema"] = "boundary-lock/v1" + merged["project"] = config.get("project", "unknown") + merged.setdefault("components", {}) + merged.setdefault("slices", {}) + + for name in selected: + merged["components"][name] = subset_lock["components"][name] + + for sname, sdef in config.get("slices", {}).items(): + slice_components = sdef.get("components", []) + if any(c in selected for c in slice_components): + merged["slices"][sname] = _recompute_slice_entry( + sname, sdef, merged["components"], strict=strict + ) + + return merged + + def _load_config_schema(repo_root: Path) -> Optional[dict]: @@ -688,7 +775,13 @@ def _lockfile_structure_issues(lockfile: dict) -> List[str]: return issues -def verify_lockfile(config: dict, lockfile: dict, repo_root: Path, source: str = "head") -> List[str]: +def verify_lockfile( + config: dict, + lockfile: dict, + repo_root: Path, + source: str = "head", + components_filter: Optional[List[str]] = None, +) -> List[str]: """Check if the lockfile matches current repo state. Returns list of mismatches.""" current = generate_lockfile(config, repo_root, source=source) issues = _lockfile_schema_issues(lockfile) @@ -696,7 +789,12 @@ def verify_lockfile(config: dict, lockfile: dict, repo_root: Path, source: str = if issues: return issues + selected = set(components_filter or []) + use_filter = len(selected) > 0 + for name, current_comp in current["components"].items(): + if use_filter and name not in selected: + continue locked_comp = lockfile.get("components", {}).get(name) if locked_comp is None: issues.append(f"NEW component not in lockfile: {name}") @@ -711,11 +809,15 @@ def verify_lockfile(config: dict, lockfile: dict, repo_root: Path, source: str = ) for name in lockfile.get("components", {}): + if use_filter and name not in selected: + continue if name not in current["components"]: issues.append(f"REMOVED component still in lockfile: {name}") # Check for vendored copy drift for name, comp in current["components"].items(): + if use_filter and name not in selected: + continue for warning in comp.get("warnings", []): issues.append(f"VENDORED DRIFT {name}: {warning}") @@ -819,6 +921,81 @@ def print_status(lockfile: dict) -> None: print(f" {sname} [{mode}] ({count} components) = {fp}") +def explain_component_changes(config: dict, repo_root: Path, component_name: str, base_ref: str = "HEAD") -> int: + """Explain changed tracked files for one component and its boundary subset.""" + comp = config.get("components", {}).get(component_name) + if not comp: + print(f"ERROR: unknown component '{component_name}'", file=sys.stderr) + known = sorted(config.get("components", {}).keys()) + if known: + print(f"Known components: {', '.join(known)}", file=sys.stderr) + return 2 + + component_path = str(comp.get("path", "")).rstrip("/") + boundary = comp.get("boundary", {}) + boundary_paths = boundary.get("paths", []) if isinstance(boundary, dict) else [] + + try: + diff = _git_run(repo_root, ["diff", "--name-status", base_ref, "--", component_path]) + except subprocess.CalledProcessError as exc: + print(f"ERROR: failed to diff '{component_name}' against {base_ref}: {exc}", file=sys.stderr) + return 2 + + changed: List[Tuple[str, str]] = [] + for line in diff.stdout.splitlines(): + parts = line.split("\t", 1) + if len(parts) != 2: + continue + changed.append((parts[0].strip(), _to_posix(parts[1].strip()))) + + print(f"Component: {component_name}") + print(f"Path: {component_path}") + print(f"Base ref: {base_ref}") + + if not changed: + print("\nNo tracked file changes detected for this component path.") + return 0 + + print(f"\nChanged files ({len(changed)}):") + for status, rel in changed: + print(f" {status:>2} {rel}") + + if not boundary_paths: + print("\nBoundary paths: none declared") + return 0 + + component_prefix = f"{_to_posix(component_path)}/" + normalized_boundary_paths = [] + for p in boundary_paths: + rp = _to_posix(str(p).strip().rstrip("/")) + if rp: + normalized_boundary_paths.append(rp) + + boundary_changed: List[Tuple[str, str]] = [] + for status, rel in changed: + component_relative = rel + if component_relative.startswith(component_prefix): + component_relative = component_relative[len(component_prefix):] + for bp in normalized_boundary_paths: + if component_relative == bp or component_relative.startswith(f"{bp}/"): + boundary_changed.append((status, rel)) + break + + print(f"\nBoundary provider: {boundary_provider_name(boundary)}") + print("Boundary paths:") + for bp in normalized_boundary_paths: + print(f" - {bp}") + + if boundary_changed: + print(f"\nBoundary-relevant changed files ({len(boundary_changed)}):") + for status, rel in boundary_changed: + print(f" {status:>2} {rel}") + else: + print("\nBoundary-relevant changed files: none") + + return 0 + + def _print_json(data: Any) -> None: print(json.dumps(data, indent=2, sort_keys=True)) @@ -828,6 +1005,56 @@ def _log(msg: str, quiet: bool = False) -> None: print(msg) +def _parse_components_arg(raw: Optional[str]) -> List[str]: + if not raw: + return [] + names = [n.strip() for n in raw.split(",") if n.strip()] + return sorted(set(names)) + + +def discover_components(repo_root: Path) -> Dict[str, dict]: + """Best-effort component discovery from common manifest files.""" + manifests = ("package.json", "pyproject.toml", "Cargo.toml", "go.mod") + found: Dict[str, dict] = {} + for manifest in manifests: + for mf in sorted(repo_root.rglob(manifest)): + if ".git" in mf.parts: + continue + rel_dir = mf.parent.relative_to(repo_root) + if str(rel_dir) == ".": + continue + comp_name = rel_dir.name + base_name = comp_name + idx = 2 + while comp_name in found: + comp_name = f"{base_name}-{idx}" + idx += 1 + found[comp_name] = { + "path": _to_posix(str(rel_dir)), + "version_source": {"file": _to_posix(str(mf.relative_to(repo_root))), "field": "version"}, + "boundary": {"provider": "implicit", "paths": []}, + } + return found + + +def changed_components_since_ref(config: dict, repo_root: Path, base_ref: str) -> List[str]: + """Return component names with tracked changes since `base_ref`.""" + try: + result = _git_run(repo_root, ["diff", "--name-only", base_ref, "--"]) + except subprocess.CalledProcessError: + return [] + changed_files = [_to_posix(line.strip()) for line in result.stdout.splitlines() if line.strip()] + changed: List[str] = [] + for cname, comp in config.get("components", {}).items(): + cpath = _to_posix(str(comp.get("path", "")).rstrip("/")) + if not cpath: + continue + prefix = f"{cpath}/" + if any(f == cpath or f.startswith(prefix) for f in changed_files): + changed.append(cname) + return sorted(changed) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -851,6 +1078,7 @@ def main(): gen.add_argument("--allow-partial", action="store_true", help="Allow missing boundary/compat digests in slices") gen.add_argument("--deterministic", action="store_true", help="Omit generated_at for idempotent lockfile output") gen.add_argument("--dry-run", action="store_true", help="Compute lockfile and print status without writing output") + gen.add_argument("--components", default="", help="Comma-separated component names to regenerate") gen.add_argument("--json", action="store_true", help="Print JSON output") # verify @@ -858,6 +1086,8 @@ def main(): ver.add_argument("--config", default="boundary.config.json") ver.add_argument("--lock", default="boundary.lock.json") ver.add_argument("--source", choices=["head", "index", "working-tree"], default="head") + ver.add_argument("--components", default="", help="Comma-separated component names to verify") + ver.add_argument("--changed-from", default="", help="Auto-select changed components since git ref") ver.add_argument("--json", action="store_true", help="Print JSON output") # diff @@ -882,6 +1112,7 @@ def main(): init = sub.add_parser("init", help="Create a starter boundary.config.json") init.add_argument("--out", default="boundary.config.json", help="Output config file path") init.add_argument("--force", action="store_true", help="Overwrite existing file") + init.add_argument("--discover", action="store_true", help="Auto-discover components from common manifests") # status st = sub.add_parser("status", help="Show lockfile summary and warnings") @@ -890,6 +1121,15 @@ def main(): st.add_argument("--source", choices=["head", "index", "working-tree"], default="head") st.add_argument("--json", action="store_true", help="Print JSON output") + # explain + ex = sub.add_parser("explain", help="Explain changed files for a component") + ex.add_argument("component", help="Component name from config") + ex.add_argument("--config", default="boundary.config.json") + ex.add_argument("--base-ref", default="HEAD", help="Git ref to diff against (default: HEAD)") + + disc = sub.add_parser("discover", help="Print discovered components as JSON") + disc.add_argument("--json", action="store_true", help="Print JSON output") + args = parser.parse_args() if args.command is None: @@ -908,10 +1148,22 @@ def main(): print(f"ERROR: Config file not found: {config_path}", file=sys.stderr) sys.exit(1) config = json.loads(config_path.read_text()) + components_filter = _parse_components_arg(args.components) try: - lockfile = generate_lockfile( - config, repo_root, source=args.source, strict=(not args.allow_partial), deterministic=args.deterministic - ) + if components_filter: + lockfile = generate_lockfile_for_components( + config, + repo_root, + selected_components=components_filter, + out_path=repo_root / args.out, + source=args.source, + strict=(not args.allow_partial), + deterministic=args.deterministic, + ) + else: + lockfile = generate_lockfile( + config, repo_root, source=args.source, strict=(not args.allow_partial), deterministic=args.deterministic + ) except ValueError as exc: print(f"ERROR: {exc}", file=sys.stderr) print("Run: boundver validate-config", file=sys.stderr) @@ -932,9 +1184,23 @@ def main(): elif args.command == "verify": config = json.loads((repo_root / args.config).read_text()) lockfile = json.loads((repo_root / args.lock).read_text()) - issues = verify_lockfile(config, lockfile, repo_root, source=args.source) + components_filter = _parse_components_arg(args.components) + if args.changed_from: + auto = changed_components_since_ref(config, repo_root, args.changed_from) + if components_filter: + auto_set = set(auto) + components_filter = [c for c in components_filter if c in auto_set] + else: + components_filter = auto + unknown = [n for n in components_filter if n not in config.get("components", {})] + if unknown: + print(f"ERROR: unknown --components entries: {', '.join(unknown)}", file=sys.stderr) + sys.exit(2) + issues = verify_lockfile( + config, lockfile, repo_root, source=args.source, components_filter=components_filter + ) if args.json: - _print_json({"ok": len(issues) == 0, "issues": issues}) + _print_json({"ok": len(issues) == 0, "issues": issues, "components_filter": components_filter}) if issues: if not args.json and not args.quiet: print(f"LOCKFILE OUT OF DATE ({len(issues)} issues):\n") @@ -992,11 +1258,12 @@ def main(): if config_path.exists() and not args.force: print(f"ERROR: Config already exists: {config_path}", file=sys.stderr) sys.exit(1) + discovered = discover_components(repo_root) if args.discover else {} starter = { "$schema": "https://raw.githubusercontent.com/yzm1/boundver/main/boundary.config.schema.json", "project": repo_root.name, "defaults": {"compat_mode": "major"}, - "components": { + "components": discovered or { "example-component": { "path": "src", "version_source": None, @@ -1004,12 +1271,26 @@ def main(): } }, "slices": { - "default": {"description": "Default exact slice", "mode": "exact", "components": ["example-component"]} + "default": { + "description": "Default exact slice", + "mode": "exact", + "components": sorted((discovered or {"example-component": {}}).keys()), + } }, } config_path.write_text(json.dumps(starter, indent=2) + "\n") print(f"Created starter config: {config_path}") + elif args.command == "discover": + discovered = discover_components(repo_root) + payload = {"count": len(discovered), "components": discovered} + if args.json: + _print_json(payload) + else: + print(f"Discovered {len(discovered)} components:") + for name, comp in discovered.items(): + print(f" - {name}: {comp['path']}") + elif args.command == "status": lock_path = repo_root / args.lock if lock_path.exists(): @@ -1034,6 +1315,12 @@ def main(): print(f"No lockfile found at {lock_path}. Run 'generate' first.") sys.exit(1) + elif args.command == "explain": + config = json.loads((repo_root / args.config).read_text()) + rc = explain_component_changes(config, repo_root, args.component, base_ref=args.base_ref) + if rc != 0: + sys.exit(rc) + if __name__ == "__main__": main() diff --git a/tests/test_boundary_lock.py b/tests/test_boundary_lock.py index d44d839..d24a759 100644 --- a/tests/test_boundary_lock.py +++ b/tests/test_boundary_lock.py @@ -4,6 +4,8 @@ import subprocess import os import json +import io +from contextlib import redirect_stdout import boundver.core as boundary_lock import boundver @@ -208,6 +210,158 @@ def test_generate_lockfile_marks_partial_when_no_boundary_paths(self): self.assertEqual(entry["boundary_status"], "partial") self.assertIn("No boundary paths declared for implicit boundary", entry.get("boundary_errors", [])) + def test_explain_component_changes_reports_boundary_relevant_files(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + self._init_git_repo(root) + comp = root / "svc" + comp.mkdir(parents=True) + (comp / "openapi.yaml").write_text("openapi: 3.0.0\n") + (comp / "impl.py").write_text("print('ok')\n") + subprocess.run(["git", "add", "."], cwd=root, check=True, capture_output=True, text=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=root, check=True, capture_output=True, text=True) + + (comp / "openapi.yaml").write_text("openapi: 3.1.0\n") + (comp / "impl.py").write_text("print('changed')\n") + cfg = { + "project": "p", + "components": { + "svc": { + "path": "svc", + "boundary": {"provider": "openapi", "paths": ["openapi.yaml"]}, + } + }, + "slices": {}, + } + out = io.StringIO() + with redirect_stdout(out): + rc = boundary_lock.explain_component_changes(cfg, root, "svc") + self.assertEqual(rc, 0) + text = out.getvalue() + self.assertIn("Changed files (2):", text) + self.assertIn("svc/openapi.yaml", text) + self.assertIn("Boundary-relevant changed files (1):", text) + + def test_explain_component_changes_unknown_component(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + cfg = {"project": "p", "components": {}, "slices": {}} + rc = boundary_lock.explain_component_changes(cfg, root, "missing") + self.assertEqual(rc, 2) + + def test_verify_lockfile_components_filter_scopes_mismatches(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + a_dir = root / "a" + b_dir = root / "b" + a_dir.mkdir(parents=True) + b_dir.mkdir(parents=True) + (a_dir / "api.yaml").write_text("v1") + (b_dir / "api.yaml").write_text("v1") + cfg = { + "project": "p", + "components": { + "a": {"path": "a", "boundary": {"provider": "openapi", "paths": ["api.yaml"]}}, + "b": {"path": "b", "boundary": {"provider": "openapi", "paths": ["api.yaml"]}}, + }, + "slices": {}, + } + lock = boundary_lock.generate_lockfile(cfg, root, source="working-tree", deterministic=True) + (b_dir / "api.yaml").write_text("v2") + + issues_all = boundary_lock.verify_lockfile(cfg, lock, root, source="working-tree") + self.assertTrue(any(i.startswith("MISMATCH b.") for i in issues_all), issues_all) + + issues_a_only = boundary_lock.verify_lockfile( + cfg, lock, root, source="working-tree", components_filter=["a"] + ) + self.assertEqual(issues_a_only, []) + + def test_generate_lockfile_for_components_updates_only_selected(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + a_dir = root / "a" + b_dir = root / "b" + a_dir.mkdir(parents=True) + b_dir.mkdir(parents=True) + (a_dir / "api.yaml").write_text("a-v1") + (b_dir / "api.yaml").write_text("b-v1") + cfg = { + "project": "p", + "components": { + "a": {"path": "a", "boundary": {"provider": "openapi", "paths": ["api.yaml"]}}, + "b": {"path": "b", "boundary": {"provider": "openapi", "paths": ["api.yaml"]}}, + }, + "slices": { + "only-a": {"mode": "boundary", "components": ["a"]}, + "only-b": {"mode": "boundary", "components": ["b"]}, + }, + } + out = root / "boundary.lock.json" + full = boundary_lock.generate_lockfile(cfg, root, source="working-tree", deterministic=True) + out.write_text(json.dumps(full, indent=2)) + old_b_exact = full["components"]["b"]["fingerprints"]["exact"] + old_b_slice = full["slices"]["only-b"]["fingerprint"] + + (a_dir / "api.yaml").write_text("a-v2") + partial = boundary_lock.generate_lockfile_for_components( + cfg, root, selected_components=["a"], out_path=out, source="working-tree", deterministic=True + ) + + self.assertNotEqual( + partial["components"]["a"]["fingerprints"]["exact"], + full["components"]["a"]["fingerprints"]["exact"], + ) + self.assertEqual(partial["components"]["b"]["fingerprints"]["exact"], old_b_exact) + self.assertEqual(partial["slices"]["only-b"]["fingerprint"], old_b_slice) + + def test_discover_components_finds_common_manifests(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "services" / "auth").mkdir(parents=True) + (root / "libs" / "core").mkdir(parents=True) + (root / "services" / "auth" / "package.json").write_text('{"version":"1.0.0"}') + (root / "libs" / "core" / "pyproject.toml").write_text("[project]\nversion='0.1.0'\n") + comps = boundary_lock.discover_components(root) + self.assertIn("auth", comps) + self.assertIn("core", comps) + self.assertEqual(comps["auth"]["path"], "services/auth") + self.assertEqual(comps["core"]["path"], "libs/core") + + def test_init_discover_creates_config_with_discovered_components(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + self._init_git_repo(root) + (root / "svc").mkdir(parents=True) + (root / "svc" / "go.mod").write_text("module example.com/svc\n") + proc = self._run_cli(root, "init", "--discover") + self.assertEqual(proc.returncode, 0, proc.stderr) + cfg = json.loads((root / "boundary.config.json").read_text()) + self.assertIn("svc", cfg["components"]) + self.assertEqual(cfg["slices"]["default"]["components"], ["svc"]) + + def test_changed_components_since_ref_detects_modified_component_paths(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + self._init_git_repo(root) + (root / "a").mkdir() + (root / "b").mkdir() + (root / "a" / "x.txt").write_text("a1") + (root / "b" / "y.txt").write_text("b1") + subprocess.run(["git", "add", "."], cwd=root, check=True, capture_output=True, text=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=root, check=True, capture_output=True, text=True) + cfg = { + "project": "p", + "components": { + "a": {"path": "a", "boundary": {"provider": "implicit", "paths": []}}, + "b": {"path": "b", "boundary": {"provider": "implicit", "paths": []}}, + }, + "slices": {}, + } + (root / "b" / "y.txt").write_text("b2") + changed = boundary_lock.changed_components_since_ref(cfg, root, "HEAD") + self.assertEqual(changed, ["b"]) + def test_generate_lockfile_deterministic_omits_generated_at(self): with tempfile.TemporaryDirectory() as td: root = Path(td) @@ -269,6 +423,17 @@ def test_generate_lockfile_boundary_slice_mode(self): lock = boundary_lock.generate_lockfile(cfg, root, source="working-tree") self.assertEqual(lock["slices"]["s1"]["mode"], "boundary") + def test_boundary_digest_deduplicates_overlapping_paths(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + comp = root / "svc" + comp.mkdir(parents=True) + (comp / "api").mkdir() + (comp / "api" / "openapi.yaml").write_text("openapi: 3.0.0\n") + d1 = boundary_lock.boundary_paths_digest(root, "svc", ["api"], source="working-tree") + d2 = boundary_lock.boundary_paths_digest(root, "svc", ["api", "api/openapi.yaml"], source="working-tree") + self.assertEqual(d1, d2) + def test_schema_accepts_boundary_slice_mode(self): with tempfile.TemporaryDirectory() as td: root = Path(td) @@ -839,6 +1004,37 @@ def test_boundary_change_updates_exact_and_api_not_compat(self): self.assertNotEqual(before["boundary"], after["boundary"]) self.assertEqual(before["compat"], after["compat"]) + def test_symlink_content_matches_between_head_and_working_tree(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + self._init_git_repo(root) + comp = root / "svc" + comp.mkdir(parents=True) + target = comp / "target.txt" + target.write_text("payload\n") + link = comp / "link.txt" + try: + os.symlink("target.txt", link) + except (OSError, NotImplementedError): + self.skipTest("symlink not supported in this environment") + subprocess.run(["git", "add", "."], cwd=root, check=True, capture_output=True, text=True) + subprocess.run(["git", "commit", "-m", "symlink"], cwd=root, check=True, capture_output=True, text=True) + cfg = { + "project": "p", + "components": {"svc": {"path": "svc", "boundary": {"provider": "openapi", "paths": ["link.txt"]}}}, + "slices": {}, + } + head_lock = boundary_lock.generate_lockfile(cfg, root, source="head", deterministic=True) + wt_lock = boundary_lock.generate_lockfile(cfg, root, source="working-tree", deterministic=True) + self.assertEqual( + head_lock["components"]["svc"]["fingerprints"]["exact"], + wt_lock["components"]["svc"]["fingerprints"]["exact"], + ) + self.assertEqual( + head_lock["components"]["svc"]["fingerprints"]["boundary"], + wt_lock["components"]["svc"]["fingerprints"]["boundary"], + ) + def test_major_version_bump_updates_compat(self): with tempfile.TemporaryDirectory() as td: root = Path(td)