From 6c50978a243606ed3c7b15743d6b11847ac3c28c Mon Sep 17 00:00:00 2001 From: Gabriel Wu <13583761+lucifer1004@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:51:44 +0800 Subject: [PATCH] feat(release): prepare 0.17.0 --- .claude-plugin/marketplace.json | 2 +- .claude/.claude-plugin/plugin.json | 2 +- .claude/skills/gov/SKILL.md | 5 + .github/workflows/release.yml | 261 +++++++++-- CHANGELOG.md | 63 +++ Cargo.lock | 67 ++- Cargo.toml | 22 +- README.md | 9 +- docs/guide/conformance-cases.md | 4 +- docs/guide/rfcs.md | 8 +- docs/guide/validation.md | 36 +- docs/rfc/RFC-0002.md | 93 +++- docs/rfc/RFC-0009.md | 121 +++++ ...urable-source-code-reference-scanning.toml | 3 +- ...nd-cargo-binstall-binary-distribution.toml | 3 +- ...t-selection-with-layered-ignore-rules.toml | 40 ++ ...-with-zig-and-preserve-target-aliases.toml | 48 ++ gov/config.toml | 3 +- gov/releases.toml | 13 + .../clauses/C-COMPATIBILITY-BOUNDARY.toml | 3 +- .../RFC-0002/clauses/C-GLOBAL-COMMANDS.toml | 7 +- .../C-PRE-1-RELEASE-TARGET-COMPATIBILITY.toml | 37 ++ gov/rfc/RFC-0002/rfc.toml | 39 +- .../RFC-0009/clauses/C-IGNORE-MIGRATION.toml | 20 + gov/rfc/RFC-0009/clauses/C-IGNORE-RULES.toml | 18 + .../clauses/C-REFERENCE-REPORTING.toml | 16 + .../RFC-0009/clauses/C-SOURCE-SELECTION.toml | 18 + gov/rfc/RFC-0009/clauses/C-SUMMARY.toml | 11 + gov/rfc/RFC-0009/clauses/C-TRAVERSAL.toml | 20 + gov/rfc/RFC-0009/rfc.toml | 51 +++ gov/schema/SCHEMA.md | 2 +- ...rce-scan-directories-during-traversal.toml | 45 ++ ...g-release-builds-with-pre-1-0-aliases.toml | 54 +++ ...authenticate-self-update-api-requests.toml | 31 ++ ...30-close-0-17-release-review-findings.toml | 34 ++ ...inal-0-17-source-scan-review-findings.toml | 35 ++ ...diagnostics-precise-and-deterministic.toml | 41 ++ ...ke-validation-line-ending-independent.toml | 34 ++ src/cli/resources/rfc.rs | 3 +- src/cmd/check.rs | 34 +- src/cmd/edit/delete_referrers.rs | 22 +- src/cmd/migrate/mod.rs | 128 ++++-- src/cmd/migrate/ops.rs | 128 ++++-- src/cmd/migrate/ops_tests.rs | 47 ++ src/cmd/self_update.rs | 28 +- src/cmd/self_update_tests.rs | 82 ++-- src/config/mod.rs | 12 +- src/config/runtime.rs | 22 + src/config/template.rs | 3 +- src/main.rs | 30 +- src/reference_pattern.rs | 101 +++++ src/render/links.rs | 6 +- src/scan.rs | 390 +++++++++++++--- src/schema.rs | 20 +- src/signature/canonical_json.rs | 12 +- src/signature/tests.rs | 24 + src/validate/bracket_refs.rs | 97 +++- src/verification/runner/process_group.rs | 1 + src/write/artifact.rs | 22 +- src/write/artifact_io.rs | 19 +- tests/edit_tests/clause.rs | 19 + tests/snapshots/test_help__rfc_bump_help.snap | 3 +- ...t_scan__scan_deprecated_rfc_reference.snap | 2 +- ...__scan_mixed_valid_invalid_references.snap | 2 +- ...t_scan__scan_unknown_clause_reference.snap | 2 +- ...test_scan__scan_unknown_rfc_reference.snap | 2 +- tests/test_conformance.rs | 2 +- tests/test_migrate.rs | 209 +++++++++ tests/test_scan.rs | 415 +++++++++++++++++- 69 files changed, 2874 insertions(+), 332 deletions(-) create mode 100644 docs/rfc/RFC-0009.md create mode 100644 gov/adr/ADR-0059-use-project-root-selection-with-layered-ignore-rules.toml create mode 100644 gov/adr/ADR-0060-build-releases-with-zig-and-preserve-target-aliases.toml create mode 100644 gov/rfc/RFC-0002/clauses/C-PRE-1-RELEASE-TARGET-COMPATIBILITY.toml create mode 100644 gov/rfc/RFC-0009/clauses/C-IGNORE-MIGRATION.toml create mode 100644 gov/rfc/RFC-0009/clauses/C-IGNORE-RULES.toml create mode 100644 gov/rfc/RFC-0009/clauses/C-REFERENCE-REPORTING.toml create mode 100644 gov/rfc/RFC-0009/clauses/C-SOURCE-SELECTION.toml create mode 100644 gov/rfc/RFC-0009/clauses/C-SUMMARY.toml create mode 100644 gov/rfc/RFC-0009/clauses/C-TRAVERSAL.toml create mode 100644 gov/rfc/RFC-0009/rfc.toml create mode 100644 gov/work/2026-07-29-prune-excluded-source-scan-directories-during-traversal.toml create mode 100644 gov/work/2026-07-30-adopt-zig-release-builds-with-pre-1-0-aliases.toml create mode 100644 gov/work/2026-07-30-authenticate-self-update-api-requests.toml create mode 100644 gov/work/2026-07-30-close-0-17-release-review-findings.toml create mode 100644 gov/work/2026-07-30-close-final-0-17-source-scan-review-findings.toml create mode 100644 gov/work/2026-07-30-make-source-reference-diagnostics-precise-and-deterministic.toml create mode 100644 gov/work/2026-07-30-make-validation-line-ending-independent.toml create mode 100644 src/reference_pattern.rs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 694380bb..285c4784 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "govctl", "description": "Governed workflow skills, reviewer agents, and enforcement hooks for govctl", - "version": "0.16.0", + "version": "0.17.0", "source": "./.claude", "author": { "name": "govctl-org" diff --git a/.claude/.claude-plugin/plugin.json b/.claude/.claude-plugin/plugin.json index 0f7355b4..f81eceaa 100644 --- a/.claude/.claude-plugin/plugin.json +++ b/.claude/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "govctl", - "version": "0.16.0", + "version": "0.17.0", "description": "Governed workflow skills, reviewer agents, and enforcement hooks for govctl" } diff --git a/.claude/skills/gov/SKILL.md b/.claude/skills/gov/SKILL.md index a4ea4390..75cbfec4 100644 --- a/.claude/skills/gov/SKILL.md +++ b/.claude/skills/gov/SKILL.md @@ -95,6 +95,11 @@ Keep implementation scoped to the Work Item and governing artifacts. When work reveals a specification defect, repair the specification through the authorized lifecycle rather than silently deviating. +When source reference scanning is enabled, express its positive domain in +`source_scan.include`. Project `.gitignore` files provide baseline exclusions; +put governance-specific exclusions and re-inclusions in `.govignore`. A custom +`source_scan.pattern` uses capture group 1 as the artifact ID for every match. + Run the narrowest useful checks while developing. Before closing the Work Item, do not manually repeat guards that `govctl work move done` is about to run. Standalone verification is for diagnosis or evidence while the item remains diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 217e562c..6db87f12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,31 +88,38 @@ jobs: path: release-notes.md retention-days: 1 - # Build binaries for all platforms build: name: Build ${{ matrix.target }} needs: verify - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest + container: + image: ghcr.io/rust-cross/cargo-zigbuild@sha256:b8f4954230e4393922593838728213f54557207df9cc06f4063005268fbc0da3 strategy: fail-fast: false matrix: include: - - target: x86_64-unknown-linux-gnu - os: ubuntu-latest + - target: x86_64-unknown-linux-musl + binary: govctl archive: tar.gz - - target: aarch64-unknown-linux-gnu - os: ubuntu-latest + alias: x86_64-unknown-linux-gnu + - target: aarch64-unknown-linux-musl + binary: govctl archive: tar.gz - cross: true + alias: aarch64-unknown-linux-gnu - target: x86_64-apple-darwin - os: macos-latest + binary: govctl archive: tar.gz - target: aarch64-apple-darwin - os: macos-latest + binary: govctl archive: tar.gz - - target: x86_64-pc-windows-msvc - os: windows-latest + - target: x86_64-pc-windows-gnu + binary: govctl.exe archive: zip + alias: x86_64-pc-windows-msvc + - target: aarch64-pc-windows-gnullvm + binary: govctl.exe + archive: zip + alias: aarch64-pc-windows-msvc steps: - name: Checkout @@ -123,60 +130,220 @@ jobs: with: targets: ${{ matrix.target }} - - name: Install cross (for cross-compilation) - if: matrix.cross - run: cargo install cross --git https://github.com/cross-rs/cross + - name: Build + shell: bash + run: cargo zigbuild --release --locked --target "${{ matrix.target }}" + + - name: Install ZIP packaging tool + if: matrix.archive == 'zip' + shell: bash + run: | + apt-get update + apt-get install --yes --no-install-recommends zip - - name: Build binary + - name: Package shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + TARGET: ${{ matrix.target }} + BINARY: ${{ matrix.binary }} + ARCHIVE: ${{ matrix.archive }} + ALIAS: ${{ matrix.alias }} run: | - if [ "${{ matrix.cross }}" = "true" ]; then - cross build --release --target ${{ matrix.target }} - else - cargo build --release --target ${{ matrix.target }} + package_target() { + local distribution_target="$1" + local name="govctl-v${VERSION}-${distribution_target}" + + mkdir -p "dist/${name}" + cp "target/${TARGET}/release/${BINARY}" "dist/${name}/" + cp README.md LICENSE* "dist/${name}/" 2>/dev/null || true + + if [ "${ARCHIVE}" = "zip" ]; then + (cd dist && zip -qr "${name}.zip" "${name}") + else + tar -C dist -czf "dist/${name}.tar.gz" "${name}" + fi + } + + package_target "${TARGET}" + if [ "${VERSION%%.*}" = "0" ] && [ -n "${ALIAS}" ]; then + package_target "${ALIAS}" fi - - name: Prepare artifacts (Unix) - if: runner.os != 'Windows' + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.target }} + path: | + dist/*.tar.gz + dist/*.zip + if-no-files-found: error + retention-days: 1 + + smoke-cross: + name: Smoke ${{ matrix.target }} + needs: [verify, build] + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + alias: x86_64-unknown-linux-gnu + os: ubuntu-latest + archive: tar.gz + binary: govctl + - target: aarch64-unknown-linux-musl + alias: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + archive: tar.gz + binary: govctl + - target: x86_64-pc-windows-gnu + alias: x86_64-pc-windows-msvc + os: windows-latest + archive: zip + binary: govctl.exe + - target: aarch64-pc-windows-gnullvm + alias: aarch64-pc-windows-msvc + os: windows-11-arm + archive: zip + binary: govctl.exe + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Download artifact + uses: actions/download-artifact@v8 + with: + name: ${{ matrix.target }} + path: artifact + + - name: Extract and verify archive layouts shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + TARGET: ${{ matrix.target }} + ALIAS: ${{ matrix.alias }} + ARCHIVE: ${{ matrix.archive }} + BINARY: ${{ matrix.binary }} run: | - VERSION="${GITHUB_REF#refs/tags/}" - BINARY="govctl" - ARCHIVE_NAME="govctl-${VERSION}-${{ matrix.target }}" + extract_target() { + local distribution_target="$1" + local name="govctl-v${VERSION}-${distribution_target}" + if [ "${ARCHIVE}" = "zip" ]; then + unzip -q "artifact/${name}.zip" -d extracted + else + tar -xzf "artifact/${name}.tar.gz" -C extracted + fi + } - mkdir -p "dist/${ARCHIVE_NAME}" - cp "target/${{ matrix.target }}/release/${BINARY}" "dist/${ARCHIVE_NAME}/" - cp README.md LICENSE* "dist/${ARCHIVE_NAME}/" 2>/dev/null || true + mkdir extracted + extract_target "${TARGET}" + if [ "${VERSION%%.*}" = "0" ]; then + extract_target "${ALIAS}" + cmp \ + "extracted/govctl-v${VERSION}-${TARGET}/${BINARY}" \ + "extracted/govctl-v${VERSION}-${ALIAS}/${BINARY}" + elif compgen -G "artifact/*-${ALIAS}.*" >/dev/null; then + echo "Compatibility alias ${ALIAS} must not be published for ${VERSION}" >&2 + exit 1 + fi - cd dist - tar -czvf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}" - echo "ASSET=${ARCHIVE_NAME}.tar.gz" >> "$GITHUB_ENV" + - name: Smoke compatibility + shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + TARGET: ${{ matrix.target }} + BINARY: ${{ matrix.binary }} + run: | + BINARY_PATH="extracted/govctl-v${VERSION}-${TARGET}/${BINARY}" + if [ "${RUNNER_OS}" != "Windows" ]; then + chmod +x "${BINARY_PATH}" + fi + "${BINARY_PATH}" --version + "${BINARY_PATH}" check - - name: Prepare artifacts (Windows) - if: runner.os == 'Windows' - shell: pwsh + - name: Probe self-update API + shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + TARGET: ${{ matrix.target }} + BINARY: ${{ matrix.binary }} + GITHUB_TOKEN: ${{ github.token }} run: | - $VERSION = $env:GITHUB_REF -replace 'refs/tags/', '' - $ARCHIVE_NAME = "govctl-${VERSION}-${{ matrix.target }}" + BINARY_PATH="extracted/govctl-v${VERSION}-${TARGET}/${BINARY}" + "${BINARY_PATH}" self-update --check - New-Item -ItemType Directory -Force -Path "dist\${ARCHIVE_NAME}" - Copy-Item "target\${{ matrix.target }}\release\govctl.exe" "dist\${ARCHIVE_NAME}\" - Copy-Item README.md, LICENSE* -Destination "dist\${ARCHIVE_NAME}\" -ErrorAction SilentlyContinue + smoke-macos: + name: Smoke macOS artifacts + needs: [verify, build] + runs-on: macos-latest + permissions: + contents: read - Compress-Archive -Path "dist\${ARCHIVE_NAME}" -DestinationPath "dist\${ARCHIVE_NAME}.zip" - echo "ASSET=${ARCHIVE_NAME}.zip" >> $env:GITHUB_ENV + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false - - name: Upload artifact - uses: actions/upload-artifact@v7 + - name: Download x86_64 artifact + uses: actions/download-artifact@v8 with: - name: ${{ env.ASSET }} - path: dist/${{ env.ASSET }} - retention-days: 1 + name: x86_64-apple-darwin + path: artifact + + - name: Download aarch64 artifact + uses: actions/download-artifact@v8 + with: + name: aarch64-apple-darwin + path: artifact + + - name: Extract artifacts + shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + mkdir extracted + for TARGET in x86_64-apple-darwin aarch64-apple-darwin; do + NAME="govctl-v${VERSION}-${TARGET}" + tar -xzf "artifact/${NAME}.tar.gz" -C extracted + chmod +x "extracted/${NAME}/govctl" + done + + - name: Smoke compatibility + shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + ARM_BINARY="extracted/govctl-v${VERSION}-aarch64-apple-darwin/govctl" + X86_BINARY="extracted/govctl-v${VERSION}-x86_64-apple-darwin/govctl" + + "${ARM_BINARY}" --version + "${ARM_BINARY}" check + arch -x86_64 "${X86_BINARY}" --version + arch -x86_64 "${X86_BINARY}" check + + - name: Probe self-update API + shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + GITHUB_TOKEN: ${{ github.token }} + run: | + ARM_BINARY="extracted/govctl-v${VERSION}-aarch64-apple-darwin/govctl" + X86_BINARY="extracted/govctl-v${VERSION}-x86_64-apple-darwin/govctl" + + "${ARM_BINARY}" self-update --check + arch -x86_64 "${X86_BINARY}" self-update --check - # Create GitHub release with all artifacts release: name: Create Release - needs: [verify, build] + needs: [verify, build, smoke-cross, smoke-macos] runs-on: ubuntu-latest permissions: contents: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e79c90c..2e8b7a49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,69 @@ Release entries are curated summaries for readers. Work item traceability remain ## [Unreleased] +## [0.17.0] - 2026-07-30 + +0.17.0 makes source-reference scanning scale with repository shape by pruning +excluded paths during traversal, and replaces platform-specific release builds +with one validated Zig pipeline for six canonical targets. Governance-specific +ignore rules, precise diagnostics, and compatibility packaging keep both large +repositories and existing pre-1.0 installations on a direct upgrade path. + +### Changed + +- Source scanning now walks the project root using `source_scan.include` as its + positive domain, project `.gitignore` files as baseline exclusions, and + higher-priority `.govignore` files for governance-specific exclusions and + re-inclusions. +- Custom `source_scan.pattern` expressions now use one validated contract: + capture group 1 must produce a non-empty artifact ID for every match. +- Unknown and outdated source references now report normalized + `path:line:byte-column` locations in deterministic order. +- Release binaries are built in one pinned Zig environment for x86_64 and ARM64 + Linux, macOS, and Windows targets, then exercised on matching native runners + before publication. +- Linux releases now use static musl binaries. Windows releases use GNU on + x86_64 and gnullvm on ARM64, while cargo-binstall maps Windows hosts to the + corresponding canonical archive. + +### Fixed + +- Excluded directories are pruned before their contents are visited, avoiding + the previous cost of walking large generated or dependency trees and + filtering them afterward. +- Git metadata directories are pruned before traversal, so source scanning does + not enumerate repository object storage. +- Source scanning no longer silently skips invalid include or ignore rules, + traversal failures, unreadable or undecodable selected files, or malformed + reference captures. Duplicate diagnostics for the same occurrence are + collapsed. +- Invalid pattern matches in governed text retain field and occurrence + positions instead of collapsing distinct diagnostics. +- Bundled-schema freshness checks and RFC content signatures now treat LF and + CRLF source text consistently across platforms. +- Schema migration now preserves comments and unrelated formatting in + `gov/config.toml` while applying required field changes. +- Self-update API requests use `GITHUB_TOKEN` when available, avoiding + unauthenticated GitHub rate limits in automated environments. + +### Security + +- Cross-platform release smoke jobs now use read-only repository permissions + and discard checkout credentials after fetching source. + +### Upgrade Notes + +- Project schema version 5 removes `source_scan.exclude`. Run `govctl migrate` + from schema version 4; existing entries are converted, in order, into root + `.govignore` rules. +- `.govignore` follows normal gitignore ordering and `!` re-inclusion semantics + and takes precedence over `.gitignore`. Re-including a descendant requires + re-including each excluded parent directory. +- Every remaining 0.x release publishes legacy Linux GNU and Windows MSVC asset + aliases containing the canonical binary, so older self-update clients can + cross the target transition. These aliases end at `1.0.0`, including its + prereleases. + ## [0.16.0] - 2026-07-29 0.16.0 makes indexed scalar-list editing uniform across the canonical `edit` diff --git a/Cargo.lock b/Cargo.lock index 05d95657..bc488e1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,6 +553,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crossterm" version = "0.29.0" @@ -1142,7 +1167,7 @@ dependencies = [ [[package]] name = "govctl" -version = "0.16.0" +version = "0.17.0" dependencies = [ "ansi-to-tui", "chrono", @@ -1151,7 +1176,7 @@ dependencies = [ "comfy-table", "crossterm", "fs2", - "globset", + "ignore", "insta", "jsonschema", "markdown-to-ansi", @@ -1174,7 +1199,7 @@ dependencies = [ "terminal_size", "thiserror 2.0.18", "toml", - "walkdir", + "toml_edit", "winnow 0.7.15", ] @@ -1476,6 +1501,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -3482,6 +3523,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 1.0.1", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -3493,9 +3547,9 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -4257,6 +4311,9 @@ name = "winnow" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" diff --git a/Cargo.toml b/Cargo.toml index 288b5107..7a21dc9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "govctl" -version = "0.16.0" +version = "0.17.0" edition = "2024" rust-version = "1.96" description = "Project governance CLI for RFC, ADR, and Work Item management" @@ -46,6 +46,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" toml = "1" +toml_edit = "0.25.13" # Validation and versioning jsonschema = "0.45" @@ -72,8 +73,7 @@ pulldown-cmark = "0.13" rusqlite = { version = "0.40", features = ["bundled"] } # File system traversal -walkdir = "2" -globset = "0.4" +ignore = "0.4" tempfile = "3" # Process-level file locking (for concurrent write safety per RFC-0004) @@ -100,13 +100,27 @@ insta = { version = "1", features = ["yaml"] } regex = "1" chrono = "0.4" -# [[ADR-0041]] governs package.metadata.binstall archive naming, including the Windows override in package.metadata.binstall.overrides.x86_64-pc-windows-msvc. +# [[ADR-0060]] governs the canonical Zig targets and pre-1.0 compatibility aliases used by cargo-binstall. [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/govctl-v{ version }-{ target }.tar.gz" bin-dir = "govctl-v{ version }-{ target }/{ bin }{ binary-ext }" pkg-fmt = "tgz" [package.metadata.binstall.overrides.x86_64-pc-windows-msvc] +pkg-url = "{ repo }/releases/download/v{ version }/govctl-v{ version }-x86_64-pc-windows-gnu.zip" +bin-dir = "govctl-v{ version }-x86_64-pc-windows-gnu/{ bin }{ binary-ext }" +pkg-fmt = "zip" + +[package.metadata.binstall.overrides.aarch64-pc-windows-msvc] +pkg-url = "{ repo }/releases/download/v{ version }/govctl-v{ version }-aarch64-pc-windows-gnullvm.zip" +bin-dir = "govctl-v{ version }-aarch64-pc-windows-gnullvm/{ bin }{ binary-ext }" +pkg-fmt = "zip" + +[package.metadata.binstall.overrides.x86_64-pc-windows-gnu] +pkg-url = "{ repo }/releases/download/v{ version }/govctl-v{ version }-{ target }.zip" +pkg-fmt = "zip" + +[package.metadata.binstall.overrides.aarch64-pc-windows-gnullvm] pkg-url = "{ repo }/releases/download/v{ version }/govctl-v{ version }-{ target }.zip" pkg-fmt = "zip" diff --git a/README.md b/README.md index 7f4482f3..eaa36378 100644 --- a/README.md +++ b/README.md @@ -274,10 +274,11 @@ govctl migrate govctl check ``` -Schema version 3 is the minimum supported repository format; schema version 4 -enables Conformance Cases. Use a compatible earlier govctl version to migrate -older repositories before upgrading. Legacy RFC and clause JSON storage is -rejected explicitly. +Schema version 3 is the minimum supported repository format. Schema version 5 +uses project `.gitignore` files plus `.govignore` for source-scan exclusions and +re-inclusions; `govctl migrate` upgrades supported repositories. Use a +compatible earlier govctl version for repositories below schema 3. Legacy RFC +and clause JSON storage is rejected explicitly. ### Interactive TUI diff --git a/docs/guide/conformance-cases.md b/docs/guide/conformance-cases.md index 57c2e880..dfe0cafd 100644 --- a/docs/guide/conformance-cases.md +++ b/docs/guide/conformance-cases.md @@ -65,8 +65,8 @@ Cases also participate in `govctl search`, controlled-vocabulary tags, ## Schema Migration -Conformance Cases require project schema version 4. Upgrade an existing project -with: +Conformance Cases were introduced in project schema version 4. Upgrade an +existing project to the current schema with: ```bash govctl migrate diff --git a/docs/guide/rfcs.md b/docs/guide/rfcs.md index caf8eaaa..f2984d27 100644 --- a/docs/guide/rfcs.md +++ b/docs/guide/rfcs.md @@ -251,9 +251,15 @@ a new version lifecycle: # Bump version with changelog entry govctl rfc bump RFC-0010 --patch -m "Fix typo in clause C-SCOPE" govctl rfc bump RFC-0010 --minor -m "Add new clause for edge case" -govctl rfc bump RFC-0010 --major -m "Breaking change to API contract" +govctl rfc bump RFC-0010 --major -m "Publish the stable 1.0 contract" ``` +The bump flags select literal SemVer components; they are not remapped impact +labels. For an RFC at `0.y.z`, use `--minor` for a breaking pre-1.0 amendment +that starts the next `0.(y+1).0` line, and reserve `--major` for the deliberate +transition to `1.0.0`. Use `--patch` for amendments that remain compatible +within the current `0.y` line. + A content-changing bump starts the new version in `spec` only from `impl`, `test`, or `stable`. RFC and Clause content can continue changing during that `spec` phase without another version bump; a second version-changing bump is rejected. diff --git a/docs/guide/validation.md b/docs/guide/validation.md index ec729cc9..2e559410 100644 --- a/docs/guide/validation.md +++ b/docs/guide/validation.md @@ -41,9 +41,31 @@ Configure scanning in `gov/config.toml`: [source_scan] enabled = true include = ["src/**/*.rs"] -exclude = [] ``` +`include` is the positive scan domain and uses Git gitignore path-pattern +semantics. Project `.gitignore` files provide the baseline exclusions. +Governance-specific exclusions and re-inclusions belong in `.govignore` files: + +```gitignore +# Skip generated source evidence +generated/ + +# Restore one governed subtree excluded by .gitignore +!fixtures/ +!fixtures/governed/ +``` + +`.govignore` uses normal gitignore ordering and `!` re-inclusion, and its rules +take precedence over `.gitignore`. Re-including a descendant requires +re-including each excluded parent directory. govctl prunes excluded directories +before reading their contents. + +An optional `source_scan.pattern` override must be a valid regular expression +whose capture group 1 returns the complete artifact ID for every match. Unknown +and outdated reference diagnostics report the normalized source path followed +by a one-based line and byte column. + ## Controlled-Vocabulary Tags Tags provide cross-cutting categorization across all governance artifacts. Every tag must be registered in a project-level allow list before use. @@ -350,12 +372,12 @@ This upgrades TOML artifact file formats (e.g., adding `#:schema` headers or nor These are related but serve different purposes: -| | `govctl migrate` | `/migrate` skill | -| ---------- | -------------------------------------------------------------------------- | ----------------------------------------------------- | -| **What** | Upgrade existing govctl artifacts to current format | Adopt govctl in an existing project | -| **When** | After updating govctl version | When starting governance in a brownfield repo | -| **Effect** | Syncs TOML artifacts, schemas, and govctl local-state `.gitignore` entries | Discovers decisions, backfills ADRs, annotates source | -| **Risk** | Low — transactional, reversible | Medium — requires human review of generated ADRs | +| | `govctl migrate` | `/migrate` skill | +| ---------- | ---------------------------------------------------------------------------- | ----------------------------------------------------- | +| **What** | Upgrade existing govctl artifacts to current format | Adopt govctl in an existing project | +| **When** | After updating govctl version | When starting governance in a brownfield repo | +| **Effect** | Syncs TOML artifacts, schemas, ignore configuration, and local support files | Discovers decisions, backfills ADRs, annotates source | +| **Risk** | Low — transactional, reversible | Medium — requires human review of generated ADRs | Run `govctl migrate` when govctl reports an outdated schema version, missing or stale bundled schema files, or missing govctl-managed local-state `.gitignore` diff --git a/docs/rfc/RFC-0002.md b/docs/rfc/RFC-0002.md index 0ea26a64..17fc4856 100644 --- a/docs/rfc/RFC-0002.md +++ b/docs/rfc/RFC-0002.md @@ -1,9 +1,9 @@ - + # RFC-0002: CLI Resource Model and Command Architecture -> **Version:** 2.2.0 | **Status:** normative | **Phase:** impl +> **Version:** 3.1.0 | **Status:** normative | **Phase:** impl > **Owners:** @govctl-org > **Tags:** `cli`, `editing`, `lifecycle`, `validation`, `release` @@ -471,7 +471,7 @@ Syntax: `govctl init [--force]` Behavior: - Creates `gov/` directory structure -- Generates `gov/config.toml` declaring project schema version 4 +- Generates `gov/config.toml` declaring project schema version 5 - Creates subdirectories for RFCs, ADRs, Work Items, verification guards, and Conformance Cases - Installs bundled JSON Schema files under `gov/schema/` - MUST error if already initialized (unless `--force`) @@ -556,8 +556,11 @@ Behavior: - Enforces the minimum supported schema version in [RFC-0002:C-COMPATIBILITY-BOUNDARY](../rfc/RFC-0002.md#rfc-0002c-compatibility-boundary) before planning file operations - Runs all pending migration steps from a supported version to the latest - Each step produces a set of file operations executed transactionally +- With `--dry-run`, MUST perform the same schema checks, repository validation, project-support discovery, and migration planning as execution; MUST report every planned file operation; and MUST leave persistent project and local state unchanged - Bumps `[schema] version` in `gov/config.toml` after successful migration -- MUST leave the repository unchanged if any step fails +- If a migration operation fails after changing any target, implementation MUST attempt rollback before returning +- When rollback succeeds, implementation MUST leave every migration target file unchanged and return the original operation failure +- If rollback fails, implementation MUST return E0903, identify that restoration may be incomplete and where recovery backups were retained, MUST NOT report migration success, and MUST NOT discard the recovery backups - MUST be safe to run on an already-migrated repository and report a no-op result - MUST NOT perform heuristic project discovery or broad adoption tasks - MUST ensure all bundled JSON Schema files exist in `gov/schema/`, overwriting with the latest version regardless of schema version @@ -817,7 +820,8 @@ This clause defines the compatibility boundary for the canonical-only breaking r **Repository boundary:** - Project schema version 3 MUST be the minimum supported project schema version, and the running binary’s current schema version MUST be the maximum supported project schema version. -- Project schema version 4 MUST be the current schema version for implementations that support Conformance Cases. Version 4 adds Conformance Case storage and validation. Normal commands on a version 3 repository containing prospective Case files MUST reject the repository without mutation and instruct the user to run `govctl migrate`; migration MAY inspect those files under version 4 rules before updating the declared version. +- Project schema version 4 introduced Conformance Case storage and validation. Normal commands on a version 3 repository containing prospective Case files MUST reject the repository without mutation and instruct the user to run `govctl migrate`; migration MAY inspect those files under version 4 rules before updating the declared version. +- Project schema version 5 MUST be the current schema version. Version 5 removes `source_scan.exclude` and establishes the source-selection and ignore contract in [RFC-0009](../rfc/RFC-0009.md). Normal commands on a version 4 repository MUST reject the repository without mutation and instruct the user to run `govctl migrate`. Migration MUST apply the version 4 to version 5 behavior defined by [RFC-0009:C-IGNORE-MIGRATION](../rfc/RFC-0009.md#rfc-0009c-ignore-migration). - Any command that loads an existing governance project MUST validate that the declared project schema version is within the supported inclusive range before reading or writing governed artifacts, generated documentation, or `.govctl` local state. - A repository whose `gov/config.toml` declares a schema version below 3 MUST be rejected. The rejection MUST leave `gov/`, generated documentation, and `.govctl` unchanged. The diagnostic MUST instruct the user to migrate the repository with a compatible earlier govctl version before upgrading. - A repository whose `gov/config.toml` declares a schema version newer than the running binary’s current schema version MUST be rejected without mutation. The diagnostic MUST instruct the user to upgrade govctl to a version that supports the repository schema. @@ -920,6 +924,37 @@ Recovery guidance MUST NOT make an invalid invocation an alias, rewrite it into *Since: v1.0.1* +### [RFC-0002:C-PRE-1-RELEASE-TARGET-COMPATIBILITY] Pre-1.0 Release Target Compatibility (Normative) + +For govctl release versions at or after 0.17.0 whose SemVer major component is zero, the canonical prebuilt-binary target set is: + +- `x86_64-unknown-linux-musl` +- `aarch64-unknown-linux-musl` +- `x86_64-apple-darwin` +- `aarch64-apple-darwin` +- `x86_64-pc-windows-gnu` +- `aarch64-pc-windows-gnullvm` + +Every release in that scope MUST also publish compatibility alias archives with these mappings: + +- `x86_64-unknown-linux-gnu` -> `x86_64-unknown-linux-musl` +- `aarch64-unknown-linux-gnu` -> `aarch64-unknown-linux-musl` +- `x86_64-pc-windows-msvc` -> `x86_64-pc-windows-gnu` +- `aarch64-pc-windows-msvc` -> `aarch64-pc-windows-gnullvm` + +`{version}` below denotes the complete SemVer release version without a leading `v`; the `v` shown in each template is literal. For every canonical or alias target identifier listed by this Clause, release archives MUST use this layout: + +- Linux and macOS targets: asset filename `govctl-v{version}-{target}.tar.gz`, gzip-compressed tar format, one root directory named `govctl-v{version}-{target}`, and executable path `govctl-v{version}-{target}/govctl`. +- Windows targets: asset filename `govctl-v{version}-{target}.zip`, ZIP format, one root directory named `govctl-v{version}-{target}`, and executable path `govctl-v{version}-{target}/govctl.exe`. + +For each compatibility mapping, the executable bytes at the alias executable path MUST equal the executable bytes at the mapped canonical executable path for the same release version. + +The alias set applies to every release in this pre-1.0 transition window so legacy-target self-update clients and package installers can cross the target transition even when versions are skipped. + +A release version whose SemVer major component is one or greater, including a prerelease such as `1.0.0-rc.1`, MUST NOT publish the four compatibility alias target archives defined by this Clause. A pre-1.0 installation that has not crossed the target transition before that breaking boundary may require reinstallation through another distribution channel. + +*Since: v3.1.0* + --- ## 3. Global Commands @@ -998,6 +1033,54 @@ A small versioned command map lets agents discover the running binary without lo ## Changelog +### v3.1.0 (2026-07-30) + +Define pre-1.0 release target compatibility + +#### Added + +- Define canonical prebuilt targets and compatibility aliases through 0.x + +#### Removed + +- End legacy target alias publication at 1.0.0 + +### v3.0.3 (2026-07-30) + +Clarify rollback target boundary + +#### Fixed + +- Separate target restoration from best-effort temporary-state cleanup + +### v3.0.2 (2026-07-30) + +Narrow recovery boundary to rollback failure + +#### Fixed + +- Keep temporary transaction cleanup best-effort without redefining migration results + +### v3.0.1 (2026-07-30) + +Define migration recovery failure boundary + +#### Fixed + +- Preserve recovery backups and report E0903 when rollback or cleanup fails + +### v3.0.0 (2026-07-30) + +Adopt schema v5 source scan ignore semantics + +#### Changed + +- Define schema v5 as the current repository format + +#### Removed + +- Replace source_scan.exclude with gitignore-compatible .govignore rules + ### v2.2.0 (2026-07-29) Define uniform indexed replacement for scalar-valued lists diff --git a/docs/rfc/RFC-0009.md b/docs/rfc/RFC-0009.md new file mode 100644 index 00000000..c5cd0bbe --- /dev/null +++ b/docs/rfc/RFC-0009.md @@ -0,0 +1,121 @@ + + + +# RFC-0009: Source Scan Selection and Ignore Semantics + +> **Version:** 0.3.0 | **Status:** normative | **Phase:** stable +> **Owners:** @govctl-org + +**References:** [RFC-0002](../rfc/RFC-0002.md) + +--- + +## 1. Summary + +### [RFC-0009:C-SUMMARY] Source Scan Model (Informative) + +Source reference scanning has two independent path-selection layers. The source-scan include list defines the positive scan domain. Repository `.gitignore` files and governance-specific `.govignore` files define traversal exclusions and explicit re-inclusions. Keeping these layers separate lets projects reuse established ignore policy, override it for governance evidence, and avoid enumerating excluded directory trees. + +*Since: v0.1.0* + +--- + +## 2. Specification + +### [RFC-0009:C-SOURCE-SELECTION] Source Selection (Normative) + +When `source_scan.enabled` is false, source reference validation MUST NOT enumerate project source paths. + +When source reference validation is enabled, a regular file is eligible for content scanning only when its normalized project-relative path matches at least one `source_scan.include` entry and its final ignore decision is not excluded. Normalized paths MUST use `/` as the separator, MUST omit a leading `./`, and MUST be matched case-sensitively on every platform. + +Each include entry represents one positive Git gitignore path pattern evaluated from the project root. A leading `/` anchors the pattern to the project root. A pattern with no `/` MUST match at any depth. A trailing `/` MUST select regular-file descendants of a matching directory. An empty entry or an unescaped leading `!` MUST be rejected. Because include entries are configuration values rather than ignore-file lines, a leading `#` MUST be treated as a pattern character rather than a comment marker. Git escaping and wildcard forms, including `*`, `?`, character classes, and `**`, MUST retain their gitignore meanings. + +An empty include list MUST select no files. An ignore re-inclusion MUST NOT admit a file that is outside the positive include domain. + +*Since: v0.1.0* + +### [RFC-0009:C-IGNORE-RULES] Ignore Sources and Precedence (Normative) + +Source traversal MUST evaluate `.gitignore` and `.govignore` files located at the project root and in reached descendant directories. Both file types MUST use Git gitignore pattern syntax, including comments, escaping, directory-only patterns, ordered matches, and `!` re-inclusion. Matching MUST use normalized project-relative paths and MUST NOT depend on whether a path is tracked by Git or whether the project root is a Git repository. + +When matching rules conflict, a matching `.govignore` rule MUST take precedence over a matching `.gitignore` rule. Within one file type, a rule in a deeper directory MUST take precedence over a rule inherited from an ancestor directory. Within one ignore file, the last matching rule MUST win. + +When the winning rule is a re-inclusion, the path MUST become non-excluded unless an ancestor remains excluded. Otherwise, a winning exclusion MUST keep the path excluded. After ignore resolution, `source_scan.include` MUST be applied only to decide whether a non-excluded regular file is eligible for content scanning; it MUST NOT prevent traversal through a non-excluded directory. + +Source traversal MUST NOT consult ignore rules outside the project root, user-level or global Git ignore configuration, `.git/info/exclude`, or generic `.ignore` files. A path MUST NOT be excluded solely because its name is hidden. + +*Since: v0.1.0* + +### [RFC-0009:C-TRAVERSAL] Traversal and Pruning (Normative) + +The final ignore decision for a reached directory MUST be evaluated before enumerating its child entries. When the final decision excludes a directory, source traversal MUST prune the directory without enumerating its descendants. + +A re-inclusion that targets a descendant of an excluded directory MUST have no effect unless every excluded ancestor is also re-included by rules available from reached directories. Ignore files beneath a pruned directory MUST NOT be read and MUST NOT re-include that directory or its descendants. + +Source traversal MUST NOT follow symbolic links. + +While source scanning is enabled, an invalid include rule, an unreadable or invalid ignore file discovered in a reached directory, an error while traversing a directory that was not pruned, or a read or decoding error for a selected regular file MUST produce a validation diagnostic. Source reference validation MUST NOT report success after such an error. + +A directory named `.git` MUST be treated as repository metadata and pruned before its child entries are enumerated, regardless of `source_scan.include` or ignore-file rules. + +*Since: v0.1.0* + +### [RFC-0009:C-IGNORE-MIGRATION] Ignore Configuration Migration (Normative) + +Project schema version 5 MUST establish the source-selection and ignore contract in this RFC. Normal project commands other than `govctl migrate` that load a version 4 project MUST reject the project without mutation and instruct the user to run `govctl migrate`. Project-independent commands MUST NOT require repository migration. Migration from version 4 to version 5 MUST follow the transactional and dry-run behavior in [RFC-0002:C-GLOBAL-COMMANDS](../rfc/RFC-0002.md#rfc-0002c-global-commands). + +The version 5 configuration MUST remove `source_scan.exclude`. When that legacy list is non-empty, migration MUST place each entry as one root `.govignore` rule, preserving array order and placing the migrated rules before any pre-existing `.govignore` content. A leading `!` or `#` that was a literal character in a legacy entry MUST be escaped when written as an ignore-file rule. A legacy entry containing a carriage return or line feed MUST cause migration to fail without mutation and identify that entry. + +Pre-existing `.govignore` content MUST remain byte-for-byte unchanged after the inserted migration block. The version 5 source-scan migration step MUST NOT modify `.gitignore`; independent project-support synchronization required by [RFC-0002:C-GLOBAL-COMMANDS](../rfc/RFC-0002.md#rfc-0002c-global-commands) MAY modify `.gitignore` in the same `govctl migrate` invocation. + +Migrated entries adopt Git gitignore matching semantics in version 5. Migration MUST NOT claim that their match sets remain identical to the legacy glob matcher. `govctl migrate --dry-run` MUST show the resulting configuration and `.govignore` changes before they are applied. + +When the legacy list is empty, migration MUST remove the field without creating `.govignore`. A version 5 project without a `.govignore` file MUST be valid and MUST behave as though it has no governance-specific ignore rules. A version 5 configuration that contains `source_scan.exclude` MUST be rejected without mutation, and the diagnostic MUST identify the unsupported field. + +*Since: v0.1.0* + +### [RFC-0009:C-REFERENCE-REPORTING] Source Reference Reporting (Normative) + +A configured `source_scan.pattern` MUST compile successfully and MUST define capture group 1 as the artifact-reference target. `govctl check` MUST report E0501 when either condition is not met, whether or not source-file traversal is enabled. Every full match MUST produce a present, non-empty capture group 1; otherwise source scanning MUST report E0501 and MUST NOT treat that match as a reference. + +A diagnostic about an unknown or outdated detected source reference MUST identify the normalized project-relative source path and the one-based line and byte-column position at which capture group 1 starts. + +Source-reference diagnostics MUST be ordered by ascending normalized path, line, and byte column, with diagnostic code and captured target as ascending tie-breakers. Diagnostics with the same code, normalized path, capture-start position, and captured target MUST be emitted once. + +*Since: v0.2.0* + +--- + +## Changelog + +### v0.3.0 (2026-07-30) + +Exclude Git metadata from source traversal + +#### Changed + +- Prune .git directories before enumerating their contents + +### v0.2.0 (2026-07-30) + +Make source-reference diagnostics precise and deterministic + +#### Added + +- Define reference-pattern validation and source locations + +#### Changed + +- Order and deduplicate source-reference diagnostics + +### v0.1.1 (2026-07-30) + +Close source traversal failure gaps + +#### Fixed + +- Treat selected source read and decoding failures as validation errors + +### v0.1.0 (2026-07-29) + +Initial draft diff --git a/gov/adr/ADR-0009-configurable-source-code-reference-scanning.toml b/gov/adr/ADR-0009-configurable-source-code-reference-scanning.toml index a5349742..341af74e 100644 --- a/gov/adr/ADR-0009-configurable-source-code-reference-scanning.toml +++ b/gov/adr/ADR-0009-configurable-source-code-reference-scanning.toml @@ -3,8 +3,9 @@ [govctl] id = "ADR-0009" title = "Configurable source code reference scanning" -status = "accepted" +status = "superseded" date = "2026-01-17" +superseded_by = "ADR-0059" refs = ["RFC-0000"] tags = ["validation"] diff --git a/gov/adr/ADR-0041-self-update-and-cargo-binstall-binary-distribution.toml b/gov/adr/ADR-0041-self-update-and-cargo-binstall-binary-distribution.toml index 55453f7e..4f76f859 100644 --- a/gov/adr/ADR-0041-self-update-and-cargo-binstall-binary-distribution.toml +++ b/gov/adr/ADR-0041-self-update-and-cargo-binstall-binary-distribution.toml @@ -3,8 +3,9 @@ [govctl] id = "ADR-0041" title = "Self-update and cargo-binstall binary distribution" -status = "accepted" +status = "superseded" date = "2026-04-13" +superseded_by = "ADR-0060" refs = [ "RFC-0002", "ADR-0018", diff --git a/gov/adr/ADR-0059-use-project-root-selection-with-layered-ignore-rules.toml b/gov/adr/ADR-0059-use-project-root-selection-with-layered-ignore-rules.toml new file mode 100644 index 00000000..ee418a2a --- /dev/null +++ b/gov/adr/ADR-0059-use-project-root-selection-with-layered-ignore-rules.toml @@ -0,0 +1,40 @@ +#:schema ../schema/adr.schema.json + +[govctl] +id = "ADR-0059" +title = "Use project-root selection with layered ignore rules" +status = "accepted" +date = "2026-07-30" +refs = [ + "RFC-0009", + "ADR-0009", +] +tags = ["validation"] + +[content] +context = "[[ADR-0009]] selected source files through configured roots and extensions. Large repositories exposed the cost of enumerating excluded subtrees before applying exclusions, while users also needed familiar precedence and re-inclusion semantics across project defaults and governance-specific overrides. [[RFC-0009]] defines the replacement selection contract." +decision = "Replace [[ADR-0009]] with project-root traversal constrained by positive include patterns and layered gitignore-compatible ignore files. This direction was chosen because it applies exclusion during traversal and gives projects one established rule model for baseline and governance-specific selection." +consequences = "Positive: excluded subtrees can be pruned before enumeration, and repository conventions remain reusable. Negative: selection behavior spans configuration plus ignore files, so debugging precedence requires considering both. Replacing roots, extensions, and exclusions changes the schema and can change the selected file set; [[RFC-0009:C-IGNORE-MIGRATION]] owns that compatibility boundary. Neutral: source-reference extraction and validation remain separate from file selection." + +[[content.alternatives]] +text = "Walk the project root using positive include patterns and layered gitignore-compatible rules." +status = "accepted" +pros = [ + "Prunes irrelevant directories before enumeration.", + "Reuses a familiar precedence and re-inclusion model.", +] +cons = ["Selection now depends on ordered ignore files in addition to configuration."] + +[[content.alternatives]] +text = "Retain configured roots and extensions while applying layered gitignore-compatible rules during traversal." +status = "rejected" +pros = ["Keeps explicit coarse traversal bounds and reduces configuration migration."] +cons = ["Maintains separate roots, extensions, positive selection, and ignore concepts for one file-selection decision."] +rejection_reason = "Traversal-time pruning solves the performance issue, but retaining independent roots and extension axes adds configuration surface that positive path patterns already express." + +[[content.alternatives]] +text = "Use project-root positive include patterns with a dedicated .govignore only." +status = "rejected" +pros = ["Keeps governance selection isolated from Git configuration."] +cons = ["Duplicates repository-wide exclusions and lets source scanning drift from established project boundaries."] +rejection_reason = "Reusing .gitignore as a baseline avoids duplicating the dominant repository exclusion policy while .govignore remains an explicit override layer." diff --git a/gov/adr/ADR-0060-build-releases-with-zig-and-preserve-target-aliases.toml b/gov/adr/ADR-0060-build-releases-with-zig-and-preserve-target-aliases.toml new file mode 100644 index 00000000..e23b5baa --- /dev/null +++ b/gov/adr/ADR-0060-build-releases-with-zig-and-preserve-target-aliases.toml @@ -0,0 +1,48 @@ +#:schema ../schema/adr.schema.json + +[govctl] +id = "ADR-0060" +title = "Build releases with Zig and preserve target aliases" +status = "accepted" +date = "2026-07-30" +refs = [ + "RFC-0002:C-SELF-UPDATE", + "ADR-0041", + "RFC-0002:C-PRE-1-RELEASE-TARGET-COMPATIBILITY", +] +tags = ["release"] + +[content] +context = "[[ADR-0041]] aligned self-update and cargo-binstall with five target-specific assets built on native and cross runners. Cross-built candidates for the six targets now governed by [[RFC-0002:C-PRE-1-RELEASE-TARGET-COMPATIBILITY]] ran successfully on matching native OS and architecture runners during evaluation. The remaining design question is how to produce those binaries and preserve the pre-1.0 target aliases without retaining duplicate toolchains or obscuring canonical compiler provenance." +decision = "Replace [[ADR-0041]]'s build and distribution choice with one pinned Linux Zig build environment for the canonical targets defined by [[RFC-0002:C-PRE-1-RELEASE-TARGET-COMPATIBILITY]]. Produce each compatibility alias by repackaging its mapped canonical executable rather than compiling a legacy variant. This direction keeps canonical artifact names aligned with compilation targets while confining pre-1.0 compatibility names to the packaging boundary. Because cross-compilation alone does not establish runtime compatibility, retain native OS and architecture coverage as a risk mitigation." +consequences = "Positive: one build environment reduces runner and toolchain divergence, Linux binaries become portable musl executables, Windows ARM gains a release path, and the RFC-governed aliases preserve pre-1.0 update paths without duplicate builds. Negative: 0.x releases carry four duplicate archives and depend on a pinned Zig container plus an auditable macOS SDK source; native smoke runners remain necessary. Alias filenames describe compatibility identity rather than compiler provenance, which can confuse SBOM, attestation, and debugging consumers; the canonical archive remains the provenance source and byte equality keeps the mapping verifiable. Neutral: macOS target names remain unchanged, and the alias mechanism ends at the RFC's 1.0 boundary." + +[[content.alternatives]] +text = "Build six canonical Zig targets and publish compatibility alias archives from the same binaries." +status = "accepted" +pros = [ + "Preserves old updater and cargo-binstall asset resolution without retaining duplicate toolchains.", + "Keeps canonical artifact names truthful to their compilation targets.", +] +cons = ["Adds four duplicate archives to every release."] + +[[content.alternatives]] +text = "Replace legacy GNU and MSVC assets with only the new Zig target assets." +status = "rejected" +pros = ["Publishes the smallest possible asset set."] +cons = ["Breaks updates from binaries that still request legacy target names."] +rejection_reason = "An immutable older updater cannot discover a renamed asset in the latest release, so a one-time bridge release is insufficient for users who skip versions." + +[[content.alternatives]] +text = "Retain the existing native and cross-runner release build matrix." +status = "rejected" +pros = ["Preserves the current five asset targets without compatibility packaging."] +cons = ["Retains divergent toolchains, scarce macOS build usage, and no Windows ARM artifact."] +rejection_reason = "The spike demonstrated that one pinned Linux build environment can produce all required binaries while native smoke jobs preserve runtime evidence." + +[[content.alternatives]] +text = "Publish only the established GNU and MSVC asset names and map newly built updaters to those distribution identifiers." +status = "rejected" +pros = ["Avoids duplicate archives while preserving filenames known to older clients."] +cons = ["Makes canonical asset names misrepresent the compilation target and requires permanent updater target mapping."] +rejection_reason = "Keeping truthful canonical target archives makes build provenance inspectable, while temporary duplicate packaging is bounded to pre-1.0 releases by the governing RFC." diff --git a/gov/config.toml b/gov/config.toml index 55595bf0..31b857e6 100644 --- a/gov/config.toml +++ b/gov/config.toml @@ -6,11 +6,10 @@ default_owner = "@govctl-org" name = "govctl" [schema] -version = 4 +version = 5 [source_scan] enabled = true -exclude = [] include = ["src/**/*.rs"] [tags] diff --git a/gov/releases.toml b/gov/releases.toml index d27c5827..701b719f 100644 --- a/gov/releases.toml +++ b/gov/releases.toml @@ -2,6 +2,19 @@ [govctl] +[[releases]] +version = "0.17.0" +date = "2026-07-30" +refs = [ + "WI-2026-07-29-004", + "WI-2026-07-30-001", + "WI-2026-07-30-002", + "WI-2026-07-30-003", + "WI-2026-07-30-004", + "WI-2026-07-30-005", + "WI-2026-07-30-006", +] + [[releases]] version = "0.16.0" date = "2026-07-29" diff --git a/gov/rfc/RFC-0002/clauses/C-COMPATIBILITY-BOUNDARY.toml b/gov/rfc/RFC-0002/clauses/C-COMPATIBILITY-BOUNDARY.toml index e48519d2..843a797b 100644 --- a/gov/rfc/RFC-0002/clauses/C-COMPATIBILITY-BOUNDARY.toml +++ b/gov/rfc/RFC-0002/clauses/C-COMPATIBILITY-BOUNDARY.toml @@ -22,7 +22,8 @@ This clause defines the compatibility boundary for the canonical-only breaking r **Repository boundary:** - Project schema version 3 MUST be the minimum supported project schema version, and the running binary’s current schema version MUST be the maximum supported project schema version. -- Project schema version 4 MUST be the current schema version for implementations that support Conformance Cases. Version 4 adds Conformance Case storage and validation. Normal commands on a version 3 repository containing prospective Case files MUST reject the repository without mutation and instruct the user to run `govctl migrate`; migration MAY inspect those files under version 4 rules before updating the declared version. +- Project schema version 4 introduced Conformance Case storage and validation. Normal commands on a version 3 repository containing prospective Case files MUST reject the repository without mutation and instruct the user to run `govctl migrate`; migration MAY inspect those files under version 4 rules before updating the declared version. +- Project schema version 5 MUST be the current schema version. Version 5 removes `source_scan.exclude` and establishes the source-selection and ignore contract in [[RFC-0009]]. Normal commands on a version 4 repository MUST reject the repository without mutation and instruct the user to run `govctl migrate`. Migration MUST apply the version 4 to version 5 behavior defined by [[RFC-0009:C-IGNORE-MIGRATION]]. - Any command that loads an existing governance project MUST validate that the declared project schema version is within the supported inclusive range before reading or writing governed artifacts, generated documentation, or `.govctl` local state. - A repository whose `gov/config.toml` declares a schema version below 3 MUST be rejected. The rejection MUST leave `gov/`, generated documentation, and `.govctl` unchanged. The diagnostic MUST instruct the user to migrate the repository with a compatible earlier govctl version before upgrading. - A repository whose `gov/config.toml` declares a schema version newer than the running binary’s current schema version MUST be rejected without mutation. The diagnostic MUST instruct the user to upgrade govctl to a version that supports the repository schema. diff --git a/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml b/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml index 0585806d..9807f7b4 100644 --- a/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml +++ b/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml @@ -20,7 +20,7 @@ Syntax: `govctl init [--force]` Behavior: - Creates `gov/` directory structure -- Generates `gov/config.toml` declaring project schema version 4 +- Generates `gov/config.toml` declaring project schema version 5 - Creates subdirectories for RFCs, ADRs, Work Items, verification guards, and Conformance Cases - Installs bundled JSON Schema files under `gov/schema/` - MUST error if already initialized (unless `--force`) @@ -105,8 +105,11 @@ Behavior: - Enforces the minimum supported schema version in [[RFC-0002:C-COMPATIBILITY-BOUNDARY]] before planning file operations - Runs all pending migration steps from a supported version to the latest - Each step produces a set of file operations executed transactionally +- With `--dry-run`, MUST perform the same schema checks, repository validation, project-support discovery, and migration planning as execution; MUST report every planned file operation; and MUST leave persistent project and local state unchanged - Bumps `[schema] version` in `gov/config.toml` after successful migration -- MUST leave the repository unchanged if any step fails +- If a migration operation fails after changing any target, implementation MUST attempt rollback before returning +- When rollback succeeds, implementation MUST leave every migration target file unchanged and return the original operation failure +- If rollback fails, implementation MUST return E0903, identify that restoration may be incomplete and where recovery backups were retained, MUST NOT report migration success, and MUST NOT discard the recovery backups - MUST be safe to run on an already-migrated repository and report a no-op result - MUST NOT perform heuristic project discovery or broad adoption tasks - MUST ensure all bundled JSON Schema files exist in `gov/schema/`, overwriting with the latest version regardless of schema version diff --git a/gov/rfc/RFC-0002/clauses/C-PRE-1-RELEASE-TARGET-COMPATIBILITY.toml b/gov/rfc/RFC-0002/clauses/C-PRE-1-RELEASE-TARGET-COMPATIBILITY.toml new file mode 100644 index 00000000..671b7c6d --- /dev/null +++ b/gov/rfc/RFC-0002/clauses/C-PRE-1-RELEASE-TARGET-COMPATIBILITY.toml @@ -0,0 +1,37 @@ +#:schema ../../../schema/clause.schema.json + +[govctl] +id = "C-PRE-1-RELEASE-TARGET-COMPATIBILITY" +title = "Pre-1.0 Release Target Compatibility" +kind = "normative" +status = "active" +since = "3.1.0" + +[content] +text = """ +For govctl release versions at or after 0.17.0 whose SemVer major component is zero, the canonical prebuilt-binary target set is: + +- `x86_64-unknown-linux-musl` +- `aarch64-unknown-linux-musl` +- `x86_64-apple-darwin` +- `aarch64-apple-darwin` +- `x86_64-pc-windows-gnu` +- `aarch64-pc-windows-gnullvm` + +Every release in that scope MUST also publish compatibility alias archives with these mappings: + +- `x86_64-unknown-linux-gnu` -> `x86_64-unknown-linux-musl` +- `aarch64-unknown-linux-gnu` -> `aarch64-unknown-linux-musl` +- `x86_64-pc-windows-msvc` -> `x86_64-pc-windows-gnu` +- `aarch64-pc-windows-msvc` -> `aarch64-pc-windows-gnullvm` + +`{version}` below denotes the complete SemVer release version without a leading `v`; the `v` shown in each template is literal. For every canonical or alias target identifier listed by this Clause, release archives MUST use this layout: + +- Linux and macOS targets: asset filename `govctl-v{version}-{target}.tar.gz`, gzip-compressed tar format, one root directory named `govctl-v{version}-{target}`, and executable path `govctl-v{version}-{target}/govctl`. +- Windows targets: asset filename `govctl-v{version}-{target}.zip`, ZIP format, one root directory named `govctl-v{version}-{target}`, and executable path `govctl-v{version}-{target}/govctl.exe`. + +For each compatibility mapping, the executable bytes at the alias executable path MUST equal the executable bytes at the mapped canonical executable path for the same release version. + +The alias set applies to every release in this pre-1.0 transition window so legacy-target self-update clients and package installers can cross the target transition even when versions are skipped. + +A release version whose SemVer major component is one or greater, including a prerelease such as `1.0.0-rc.1`, MUST NOT publish the four compatibility alias target archives defined by this Clause. A pre-1.0 installation that has not crossed the target transition before that breaking boundary may require reinstallation through another distribution channel.""" diff --git a/gov/rfc/RFC-0002/rfc.toml b/gov/rfc/RFC-0002/rfc.toml index 815fa10d..32408315 100644 --- a/gov/rfc/RFC-0002/rfc.toml +++ b/gov/rfc/RFC-0002/rfc.toml @@ -3,12 +3,12 @@ [govctl] id = "RFC-0002" title = "CLI Resource Model and Command Architecture" -version = "2.2.0" +version = "3.1.0" status = "normative" phase = "impl" owners = ["@govctl-org"] created = "2026-01-19" -updated = "2026-07-29" +updated = "2026-07-30" tags = [ "cli", "editing", @@ -16,7 +16,7 @@ tags = [ "validation", "release", ] -signature = "edd8c1e14485390c6a990d9f0f2dd422ba4b68938e99560abed4a0154d25f5c5" +signature = "b4da6e112950da68e7db1aa874aa125ac2694455f306a0726746a0ed35e1220f" [[sections]] title = "Summary" @@ -38,12 +38,45 @@ clauses = [ "clauses/C-COMPATIBILITY-BOUNDARY.toml", "clauses/C-EDIT-FIELD-CONTRACT.toml", "clauses/C-NAMESPACE-RECOVERY.toml", + "clauses/C-PRE-1-RELEASE-TARGET-COMPATIBILITY.toml", ] [[sections]] title = "Global Commands" clauses = ["clauses/C-DESCRIBE-COMMAND.toml"] +[[changelog]] +version = "3.1.0" +date = "2026-07-30" +notes = "Define pre-1.0 release target compatibility" +added = ["Define canonical prebuilt targets and compatibility aliases through 0.x"] +removed = ["End legacy target alias publication at 1.0.0"] + +[[changelog]] +version = "3.0.3" +date = "2026-07-30" +notes = "Clarify rollback target boundary" +fixed = ["Separate target restoration from best-effort temporary-state cleanup"] + +[[changelog]] +version = "3.0.2" +date = "2026-07-30" +notes = "Narrow recovery boundary to rollback failure" +fixed = ["Keep temporary transaction cleanup best-effort without redefining migration results"] + +[[changelog]] +version = "3.0.1" +date = "2026-07-30" +notes = "Define migration recovery failure boundary" +fixed = ["Preserve recovery backups and report E0903 when rollback or cleanup fails"] + +[[changelog]] +version = "3.0.0" +date = "2026-07-30" +notes = "Adopt schema v5 source scan ignore semantics" +changed = ["Define schema v5 as the current repository format"] +removed = ["Replace source_scan.exclude with gitignore-compatible .govignore rules"] + [[changelog]] version = "2.2.0" date = "2026-07-29" diff --git a/gov/rfc/RFC-0009/clauses/C-IGNORE-MIGRATION.toml b/gov/rfc/RFC-0009/clauses/C-IGNORE-MIGRATION.toml new file mode 100644 index 00000000..b4a5d67d --- /dev/null +++ b/gov/rfc/RFC-0009/clauses/C-IGNORE-MIGRATION.toml @@ -0,0 +1,20 @@ +#:schema ../../../schema/clause.schema.json + +[govctl] +id = "C-IGNORE-MIGRATION" +title = "Ignore Configuration Migration" +kind = "normative" +status = "active" +since = "0.1.0" + +[content] +text = """ +Project schema version 5 MUST establish the source-selection and ignore contract in this RFC. Normal project commands other than `govctl migrate` that load a version 4 project MUST reject the project without mutation and instruct the user to run `govctl migrate`. Project-independent commands MUST NOT require repository migration. Migration from version 4 to version 5 MUST follow the transactional and dry-run behavior in [[RFC-0002:C-GLOBAL-COMMANDS]]. + +The version 5 configuration MUST remove `source_scan.exclude`. When that legacy list is non-empty, migration MUST place each entry as one root `.govignore` rule, preserving array order and placing the migrated rules before any pre-existing `.govignore` content. A leading `!` or `#` that was a literal character in a legacy entry MUST be escaped when written as an ignore-file rule. A legacy entry containing a carriage return or line feed MUST cause migration to fail without mutation and identify that entry. + +Pre-existing `.govignore` content MUST remain byte-for-byte unchanged after the inserted migration block. The version 5 source-scan migration step MUST NOT modify `.gitignore`; independent project-support synchronization required by [[RFC-0002:C-GLOBAL-COMMANDS]] MAY modify `.gitignore` in the same `govctl migrate` invocation. + +Migrated entries adopt Git gitignore matching semantics in version 5. Migration MUST NOT claim that their match sets remain identical to the legacy glob matcher. `govctl migrate --dry-run` MUST show the resulting configuration and `.govignore` changes before they are applied. + +When the legacy list is empty, migration MUST remove the field without creating `.govignore`. A version 5 project without a `.govignore` file MUST be valid and MUST behave as though it has no governance-specific ignore rules. A version 5 configuration that contains `source_scan.exclude` MUST be rejected without mutation, and the diagnostic MUST identify the unsupported field.""" diff --git a/gov/rfc/RFC-0009/clauses/C-IGNORE-RULES.toml b/gov/rfc/RFC-0009/clauses/C-IGNORE-RULES.toml new file mode 100644 index 00000000..b9a1fe9a --- /dev/null +++ b/gov/rfc/RFC-0009/clauses/C-IGNORE-RULES.toml @@ -0,0 +1,18 @@ +#:schema ../../../schema/clause.schema.json + +[govctl] +id = "C-IGNORE-RULES" +title = "Ignore Sources and Precedence" +kind = "normative" +status = "active" +since = "0.1.0" + +[content] +text = """ +Source traversal MUST evaluate `.gitignore` and `.govignore` files located at the project root and in reached descendant directories. Both file types MUST use Git gitignore pattern syntax, including comments, escaping, directory-only patterns, ordered matches, and `!` re-inclusion. Matching MUST use normalized project-relative paths and MUST NOT depend on whether a path is tracked by Git or whether the project root is a Git repository. + +When matching rules conflict, a matching `.govignore` rule MUST take precedence over a matching `.gitignore` rule. Within one file type, a rule in a deeper directory MUST take precedence over a rule inherited from an ancestor directory. Within one ignore file, the last matching rule MUST win. + +When the winning rule is a re-inclusion, the path MUST become non-excluded unless an ancestor remains excluded. Otherwise, a winning exclusion MUST keep the path excluded. After ignore resolution, `source_scan.include` MUST be applied only to decide whether a non-excluded regular file is eligible for content scanning; it MUST NOT prevent traversal through a non-excluded directory. + +Source traversal MUST NOT consult ignore rules outside the project root, user-level or global Git ignore configuration, `.git/info/exclude`, or generic `.ignore` files. A path MUST NOT be excluded solely because its name is hidden.""" diff --git a/gov/rfc/RFC-0009/clauses/C-REFERENCE-REPORTING.toml b/gov/rfc/RFC-0009/clauses/C-REFERENCE-REPORTING.toml new file mode 100644 index 00000000..851b10f8 --- /dev/null +++ b/gov/rfc/RFC-0009/clauses/C-REFERENCE-REPORTING.toml @@ -0,0 +1,16 @@ +#:schema ../../../schema/clause.schema.json + +[govctl] +id = "C-REFERENCE-REPORTING" +title = "Source Reference Reporting" +kind = "normative" +status = "active" +since = "0.2.0" + +[content] +text = """ +A configured `source_scan.pattern` MUST compile successfully and MUST define capture group 1 as the artifact-reference target. `govctl check` MUST report E0501 when either condition is not met, whether or not source-file traversal is enabled. Every full match MUST produce a present, non-empty capture group 1; otherwise source scanning MUST report E0501 and MUST NOT treat that match as a reference. + +A diagnostic about an unknown or outdated detected source reference MUST identify the normalized project-relative source path and the one-based line and byte-column position at which capture group 1 starts. + +Source-reference diagnostics MUST be ordered by ascending normalized path, line, and byte column, with diagnostic code and captured target as ascending tie-breakers. Diagnostics with the same code, normalized path, capture-start position, and captured target MUST be emitted once.""" diff --git a/gov/rfc/RFC-0009/clauses/C-SOURCE-SELECTION.toml b/gov/rfc/RFC-0009/clauses/C-SOURCE-SELECTION.toml new file mode 100644 index 00000000..cdf6276d --- /dev/null +++ b/gov/rfc/RFC-0009/clauses/C-SOURCE-SELECTION.toml @@ -0,0 +1,18 @@ +#:schema ../../../schema/clause.schema.json + +[govctl] +id = "C-SOURCE-SELECTION" +title = "Source Selection" +kind = "normative" +status = "active" +since = "0.1.0" + +[content] +text = """ +When `source_scan.enabled` is false, source reference validation MUST NOT enumerate project source paths. + +When source reference validation is enabled, a regular file is eligible for content scanning only when its normalized project-relative path matches at least one `source_scan.include` entry and its final ignore decision is not excluded. Normalized paths MUST use `/` as the separator, MUST omit a leading `./`, and MUST be matched case-sensitively on every platform. + +Each include entry represents one positive Git gitignore path pattern evaluated from the project root. A leading `/` anchors the pattern to the project root. A pattern with no `/` MUST match at any depth. A trailing `/` MUST select regular-file descendants of a matching directory. An empty entry or an unescaped leading `!` MUST be rejected. Because include entries are configuration values rather than ignore-file lines, a leading `#` MUST be treated as a pattern character rather than a comment marker. Git escaping and wildcard forms, including `*`, `?`, character classes, and `**`, MUST retain their gitignore meanings. + +An empty include list MUST select no files. An ignore re-inclusion MUST NOT admit a file that is outside the positive include domain.""" diff --git a/gov/rfc/RFC-0009/clauses/C-SUMMARY.toml b/gov/rfc/RFC-0009/clauses/C-SUMMARY.toml new file mode 100644 index 00000000..5e003b8d --- /dev/null +++ b/gov/rfc/RFC-0009/clauses/C-SUMMARY.toml @@ -0,0 +1,11 @@ +#:schema ../../../schema/clause.schema.json + +[govctl] +id = "C-SUMMARY" +title = "Source Scan Model" +kind = "informative" +status = "active" +since = "0.1.0" + +[content] +text = "Source reference scanning has two independent path-selection layers. The source-scan include list defines the positive scan domain. Repository `.gitignore` files and governance-specific `.govignore` files define traversal exclusions and explicit re-inclusions. Keeping these layers separate lets projects reuse established ignore policy, override it for governance evidence, and avoid enumerating excluded directory trees." diff --git a/gov/rfc/RFC-0009/clauses/C-TRAVERSAL.toml b/gov/rfc/RFC-0009/clauses/C-TRAVERSAL.toml new file mode 100644 index 00000000..022590bf --- /dev/null +++ b/gov/rfc/RFC-0009/clauses/C-TRAVERSAL.toml @@ -0,0 +1,20 @@ +#:schema ../../../schema/clause.schema.json + +[govctl] +id = "C-TRAVERSAL" +title = "Traversal and Pruning" +kind = "normative" +status = "active" +since = "0.1.0" + +[content] +text = """ +The final ignore decision for a reached directory MUST be evaluated before enumerating its child entries. When the final decision excludes a directory, source traversal MUST prune the directory without enumerating its descendants. + +A re-inclusion that targets a descendant of an excluded directory MUST have no effect unless every excluded ancestor is also re-included by rules available from reached directories. Ignore files beneath a pruned directory MUST NOT be read and MUST NOT re-include that directory or its descendants. + +Source traversal MUST NOT follow symbolic links. + +While source scanning is enabled, an invalid include rule, an unreadable or invalid ignore file discovered in a reached directory, an error while traversing a directory that was not pruned, or a read or decoding error for a selected regular file MUST produce a validation diagnostic. Source reference validation MUST NOT report success after such an error. + +A directory named `.git` MUST be treated as repository metadata and pruned before its child entries are enumerated, regardless of `source_scan.include` or ignore-file rules.""" diff --git a/gov/rfc/RFC-0009/rfc.toml b/gov/rfc/RFC-0009/rfc.toml new file mode 100644 index 00000000..bba498a7 --- /dev/null +++ b/gov/rfc/RFC-0009/rfc.toml @@ -0,0 +1,51 @@ +#:schema ../../schema/rfc.schema.json + +[govctl] +id = "RFC-0009" +title = "Source Scan Selection and Ignore Semantics" +version = "0.3.0" +status = "normative" +phase = "stable" +owners = ["@govctl-org"] +created = "2026-07-29" +updated = "2026-07-30" +refs = ["RFC-0002"] +signature = "60a6abd5423ef994ba30eafe545328d28b80f996f1804dc9adc78c059bea00a1" + +[[sections]] +title = "Summary" +clauses = ["clauses/C-SUMMARY.toml"] + +[[sections]] +title = "Specification" +clauses = [ + "clauses/C-SOURCE-SELECTION.toml", + "clauses/C-IGNORE-RULES.toml", + "clauses/C-TRAVERSAL.toml", + "clauses/C-IGNORE-MIGRATION.toml", + "clauses/C-REFERENCE-REPORTING.toml", +] + +[[changelog]] +version = "0.3.0" +date = "2026-07-30" +notes = "Exclude Git metadata from source traversal" +changed = ["Prune .git directories before enumerating their contents"] + +[[changelog]] +version = "0.2.0" +date = "2026-07-30" +notes = "Make source-reference diagnostics precise and deterministic" +added = ["Define reference-pattern validation and source locations"] +changed = ["Order and deduplicate source-reference diagnostics"] + +[[changelog]] +version = "0.1.1" +date = "2026-07-30" +notes = "Close source traversal failure gaps" +fixed = ["Treat selected source read and decoding failures as validation errors"] + +[[changelog]] +version = "0.1.0" +date = "2026-07-29" +notes = "Initial draft" diff --git a/gov/schema/SCHEMA.md b/gov/schema/SCHEMA.md index 18ba0212..1587e999 100644 --- a/gov/schema/SCHEMA.md +++ b/gov/schema/SCHEMA.md @@ -431,7 +431,7 @@ The project schema version is declared once in `gov/config.toml`: ```toml [schema] -version = 3 +version = 5 ``` Artifact TOML files do not contain their own schema-version field. Schema diff --git a/gov/work/2026-07-29-prune-excluded-source-scan-directories-during-traversal.toml b/gov/work/2026-07-29-prune-excluded-source-scan-directories-during-traversal.toml new file mode 100644 index 00000000..808f9289 --- /dev/null +++ b/gov/work/2026-07-29-prune-excluded-source-scan-directories-during-traversal.toml @@ -0,0 +1,45 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-29-004" +title = "Adopt gitignore-compatible source scanning" +status = "done" +created = "2026-07-29" +started = "2026-07-29" +completed = "2026-07-30" +refs = [ + "RFC-0002", + "RFC-0009", + "ADR-0059", +] + +[content] +description = "Replace post-walk source exclusions with pruned ignore-aware traversal, migrate schema 4 source-scan configuration to schema 5, and update the associated tests and guidance under the governing RFCs." + +[[content.acceptance_criteria]] +text = "Select source files through positive include patterns and merged .gitignore/.govignore traversal with excluded-directory pruning" +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "Migrate schema 4 source_scan.exclude entries into root .govignore rules and establish schema 5 configuration" +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "Report invalid include rules, reached ignore-file failures, unpruned traversal failures, and selected-file read or decoding failures without following symbolic links" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Cover ignore precedence, re-inclusion, pruning, migration, and dry-run behavior with focused tests" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "Update configuration examples, user documentation, and bundled agent guidance for schema 5 source scanning" +status = "done" +category = "chore" + +[verification] +required_guards = ["GUARD-CARGO-TEST"] diff --git a/gov/work/2026-07-30-adopt-zig-release-builds-with-pre-1-0-aliases.toml b/gov/work/2026-07-30-adopt-zig-release-builds-with-pre-1-0-aliases.toml new file mode 100644 index 00000000..77639109 --- /dev/null +++ b/gov/work/2026-07-30-adopt-zig-release-builds-with-pre-1-0-aliases.toml @@ -0,0 +1,54 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-30-004" +title = "Adopt Zig release builds with pre-1.0 aliases" +status = "done" +created = "2026-07-30" +started = "2026-07-30" +completed = "2026-07-30" +refs = [ + "RFC-0002:C-PRE-1-RELEASE-TARGET-COMPATIBILITY", + "ADR-0060", +] + +[content] +description = "Implement [[RFC-0002:C-PRE-1-RELEASE-TARGET-COMPATIBILITY]] and [[ADR-0060]] in the formal release workflow, replacing the validated spike while preserving the bounded pre-1.0 update path." + +[[content.acceptance_criteria]] +text = "The formal release workflow builds all six canonical targets from the pinned Linux Zig environment." +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "Every 0.x release packages the four compatibility aliases with the governed layout and byte-identical executables." +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "Releases at 1.0.0 and later, including prereleases, omit the pre-1.0 compatibility aliases." +status = "done" +category = "removed" + +[[content.acceptance_criteria]] +text = "Native Linux, Windows, and macOS smoke jobs gate release creation on canonical artifact execution and authenticated self-update checks." +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "Cargo-binstall metadata resolves canonical and compatibility Windows targets to ZIP assets." +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "The temporary Zig release spike workflow is removed after the formal path is validated." +status = "done" +category = "removed" + +[[content.acceptance_criteria]] +text = "Release workflow lint and focused metadata tests pass." +status = "done" +category = "chore" + +[verification] +required_guards = ["GUARD-CARGO-TEST"] diff --git a/gov/work/2026-07-30-authenticate-self-update-api-requests.toml b/gov/work/2026-07-30-authenticate-self-update-api-requests.toml new file mode 100644 index 00000000..0ac5c485 --- /dev/null +++ b/gov/work/2026-07-30-authenticate-self-update-api-requests.toml @@ -0,0 +1,31 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-30-003" +title = "Authenticate self-update API requests" +status = "done" +created = "2026-07-30" +started = "2026-07-30" +completed = "2026-07-30" +refs = ["RFC-0002:C-SELF-UPDATE"] + +[content] +description = "Complete the authenticated GitHub API path already defined by [[RFC-0002:C-SELF-UPDATE]] and exercise it in release-platform smoke validation." + +[[content.acceptance_criteria]] +text = "Self-update check and update requests use a non-empty GITHUB_TOKEN when available" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Release-platform self-update probes authenticate with the job token and fail on API errors" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "Focused tests, project guards, and cross-platform smoke validation pass" +status = "done" +category = "chore" + +[verification] +required_guards = ["GUARD-CARGO-TEST"] diff --git a/gov/work/2026-07-30-close-0-17-release-review-findings.toml b/gov/work/2026-07-30-close-0-17-release-review-findings.toml new file mode 100644 index 00000000..82bd4e74 --- /dev/null +++ b/gov/work/2026-07-30-close-0-17-release-review-findings.toml @@ -0,0 +1,34 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-30-005" +title = "Close 0.17 release review findings" +status = "done" +created = "2026-07-30" +started = "2026-07-30" +completed = "2026-07-30" +refs = [ + "RFC-0002:C-GLOBAL-COMMANDS", + "ADR-0060", +] + +[content] +description = "Close the valid findings from the 0.17 release review by preserving hand-authored configuration layout during schema migration and tightening release smoke-job permissions." + +[[content.acceptance_criteria]] +text = "Schema migration preserves comments and unrelated formatting in gov/config.toml while applying required field changes" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Cross-platform release smoke jobs use read-only repository permissions and do not persist checkout credentials" +status = "done" +category = "security" + +[[content.acceptance_criteria]] +text = "Focused migration tests and workflow lint validate the review fixes" +status = "done" +category = "chore" + +[verification] +required_guards = ["GUARD-CARGO-TEST"] diff --git a/gov/work/2026-07-30-close-final-0-17-source-scan-review-findings.toml b/gov/work/2026-07-30-close-final-0-17-source-scan-review-findings.toml new file mode 100644 index 00000000..21172cc8 --- /dev/null +++ b/gov/work/2026-07-30-close-final-0-17-source-scan-review-findings.toml @@ -0,0 +1,35 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-30-006" +title = "Close final 0.17 source scan review findings" +status = "done" +created = "2026-07-30" +started = "2026-07-30" +completed = "2026-07-30" +refs = [ + "RFC-0009:C-TRAVERSAL", + "RFC-0009:C-IGNORE-RULES", + "RFC-0009:C-REFERENCE-REPORTING", +] + +[content] +description = "Close the remaining valid source-scan review findings for 0.17 through metadata pruning, occurrence-specific diagnostics, and focused regression coverage." + +[[content.acceptance_criteria]] +text = "Source scanning prunes .git metadata directories before enumerating their contents" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Invalid governed-text pattern matches retain occurrence-specific diagnostic positions" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Tests cover cross-file ignore precedence, symlink non-following, and .git pruning without vacuous scan counts" +status = "done" +category = "chore" + +[verification] +required_guards = ["GUARD-CARGO-TEST"] diff --git a/gov/work/2026-07-30-make-source-reference-diagnostics-precise-and-deterministic.toml b/gov/work/2026-07-30-make-source-reference-diagnostics-precise-and-deterministic.toml new file mode 100644 index 00000000..b7a02963 --- /dev/null +++ b/gov/work/2026-07-30-make-source-reference-diagnostics-precise-and-deterministic.toml @@ -0,0 +1,41 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-30-001" +title = "Make source reference diagnostics precise and deterministic" +status = "done" +created = "2026-07-30" +started = "2026-07-30" +completed = "2026-07-30" +refs = ["RFC-0009"] + +[content] +description = "Consolidate source-reference pattern validation and retain precise, deterministic occurrence locations for validation diagnostics under [[RFC-0009]]." + +[[content.acceptance_criteria]] +text = "Validate the configured reference pattern through one shared contract and reject a missing capture group with E0501" +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "Report unknown and outdated source references with normalized one-based line and byte-column locations" +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "Sort source-reference diagnostics deterministically and collapse duplicate diagnostics at the same location" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Cover pattern validation, Unicode-aware byte positions, ordering, and duplicate suppression with focused tests" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "Update source-scan guidance for precise diagnostic locations and pattern requirements" +status = "done" +category = "chore" + +[verification] +required_guards = ["GUARD-CARGO-TEST"] diff --git a/gov/work/2026-07-30-make-validation-line-ending-independent.toml b/gov/work/2026-07-30-make-validation-line-ending-independent.toml new file mode 100644 index 00000000..1ecdc84d --- /dev/null +++ b/gov/work/2026-07-30-make-validation-line-ending-independent.toml @@ -0,0 +1,34 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-30-002" +title = "Make validation line-ending independent" +status = "done" +created = "2026-07-30" +started = "2026-07-30" +completed = "2026-07-30" +refs = [ + "RFC-0002:C-GLOBAL-COMMANDS", + "ADR-0003", +] + +[content] +description = "Make bundled-schema freshness checks and deterministic RFC projection signatures insensitive to repository line-ending conversion, preserving cross-platform validation under [[RFC-0002:C-GLOBAL-COMMANDS]] and [[ADR-0003]]." + +[[content.acceptance_criteria]] +text = "Bundled schema freshness validation treats LF and CRLF copies as equivalent" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Deterministic RFC signatures are identical for semantically equivalent LF and CRLF source text" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Focused regression tests and native Windows smoke validation pass" +status = "done" +category = "chore" + +[verification] +required_guards = ["GUARD-CARGO-TEST"] diff --git a/src/cli/resources/rfc.rs b/src/cli/resources/rfc.rs index 2fa5b5ac..b67dfbc0 100644 --- a/src/cli/resources/rfc.rs +++ b/src/cli/resources/rfc.rs @@ -87,7 +87,8 @@ EXAMPLES: NOTES: - Version-changing bumps require a normative RFC in impl, test, or stable with a sealed signature. - While an RFC is in spec, continue authoring the current version candidate instead of bumping again. - - Choose one of `--patch`, `--minor`, or `--major` when releasing a content amendment. + - Bump flags select literal SemVer components; they are not remapped impact labels. + - For 0.y.z, use `--minor` for a breaking pre-1.0 amendment; `--major` deliberately advances to 1.0.0. - `--change` without a bump level updates the current changelog entry without changing version. - Use `-m/--summary` for a release summary and `-c/--change` for detailed entries. ")] diff --git a/src/cmd/check.rs b/src/cmd/check.rs index 4191d79b..85838f1c 100644 --- a/src/cmd/check.rs +++ b/src/cmd/check.rs @@ -6,6 +6,7 @@ use crate::diagnostic::{ }; use crate::load::load_project_with_warnings; use crate::parse::{load_guards_with_warnings, load_releases}; +use crate::reference_pattern; use crate::scan::scan_source_refs; use crate::schema::installed_schema_diagnostics; use crate::ui; @@ -87,6 +88,15 @@ pub(crate) fn collect_diagnostics( } all_diagnostics.extend(installed_schema_diagnostics(config)); all_diagnostics.extend(crate::cmd::project_support::local_state_gitignore_diagnostics(config)); + if let Err(diagnostic) = reference_pattern::compile( + &config.source_scan.pattern, + config + .display_path(&config.gov_root.join("config.toml")) + .display() + .to_string(), + ) { + all_diagnostics.push(diagnostic); + } // Load project (with warnings for parse errors) let load_result = match load_project_with_warnings(config) { @@ -108,7 +118,7 @@ pub(crate) fn collect_diagnostics( summary.adr_count = result.adr_count; summary.work_count = result.work_count; summary.conformance_count = result.conformance_count; - all_diagnostics.extend(result.diagnostics); + extend_with_pattern_dedup(&mut all_diagnostics, result.diagnostics); match load_guards_with_warnings(config) { Ok(result) => { @@ -137,7 +147,27 @@ pub(crate) fn collect_diagnostics( let scan_result = scan_source_refs(config, &index); summary.files_scanned = scan_result.files_scanned; summary.refs_found = scan_result.refs_found; - all_diagnostics.extend(scan_result.diagnostics); + extend_with_pattern_dedup(&mut all_diagnostics, scan_result.diagnostics); Ok((all_diagnostics, summary)) } + +fn extend_with_pattern_dedup( + diagnostics: &mut Diagnostics, + additional: impl IntoIterator, +) { + for diagnostic in additional { + let duplicate_pattern_error = diagnostic.code == DiagnosticCode::E0501ConfigInvalid + && diagnostic + .message + .starts_with("Invalid source_scan.pattern") + && diagnostics.iter().any(|existing| { + existing.code == diagnostic.code + && existing.file == diagnostic.file + && existing.message == diagnostic.message + }); + if !duplicate_pattern_error { + diagnostics.push(diagnostic); + } + } +} diff --git a/src/cmd/edit/delete_referrers.rs b/src/cmd/edit/delete_referrers.rs index 1ccd6abc..dd53a850 100644 --- a/src/cmd/edit/delete_referrers.rs +++ b/src/cmd/edit/delete_referrers.rs @@ -1,6 +1,7 @@ use crate::config::Config; -use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult}; +use crate::diagnostic::DiagnosticResult; use crate::model::ProjectIndex; +use crate::reference_pattern; use regex::Regex; pub(super) fn clause_deletion_referrers( @@ -10,16 +11,13 @@ pub(super) fn clause_deletion_referrers( ) -> DiagnosticResult> { let mut referrers = project_referrers(index, clause_id, None, false); referrers.extend(clause_supersession_referrers(index, clause_id)); - let inline_re = Regex::new(&config.source_scan.pattern).map_err(|err| { - Diagnostic::new( - DiagnosticCode::E0501ConfigInvalid, - format!("Invalid source_scan.pattern regex: {err}"), - config - .display_path(&config.gov_root.join("config.toml")) - .display() - .to_string(), - ) - })?; + let inline_re = reference_pattern::compile( + &config.source_scan.pattern, + config + .display_path(&config.gov_root.join("config.toml")) + .display() + .to_string(), + )?; referrers.extend(inline_clause_referrers(index, &inline_re, clause_id)); referrers.extend(guard_referrers(config, clause_id)?); referrers.extend( @@ -127,7 +125,7 @@ fn inline_clause_referrers( fn text_references(inline_re: &Regex, text: &str, target_id: &str) -> bool { inline_re .captures_iter(text) - .filter_map(|captures| captures.get(1)) + .filter_map(|captures| reference_pattern::target_capture(&captures).ok()) .any(|target| target.as_str() == target_id) } diff --git a/src/cmd/migrate/mod.rs b/src/cmd/migrate/mod.rs index 2c68fd49..5e7c1c83 100644 --- a/src/cmd/migrate/mod.rs +++ b/src/cmd/migrate/mod.rs @@ -15,7 +15,7 @@ mod ops; use ops::{FileOp, execute_ops, preview_ops}; /// Latest schema version. Bump when adding a new migration step. -pub const CURRENT_SCHEMA_VERSION: u32 = 4; +pub const CURRENT_SCHEMA_VERSION: u32 = 5; /// Oldest project schema accepted by this binary. pub const MIN_SUPPORTED_SCHEMA_VERSION: u32 = 3; @@ -53,12 +53,20 @@ struct MigrationStep { } /// All registered migrations, ordered by version. -const MIGRATIONS: &[MigrationStep] = &[MigrationStep { - from: 3, - to: 4, - name: "enable Conformance Case resources", - plan_fn: plan_v3_to_v4, -}]; +const MIGRATIONS: &[MigrationStep] = &[ + MigrationStep { + from: 3, + to: 4, + name: "enable Conformance Case resources", + plan_fn: plan_v3_to_v4, + }, + MigrationStep { + from: 4, + to: 5, + name: "adopt source scan ignore files", + plan_fn: plan_v4_to_v5, + }, +]; fn plan_v3_to_v4(config: &Config) -> DiagnosticResult> { validate_prospective_conformance_cases(config)?; @@ -76,6 +84,54 @@ fn validate_prospective_conformance_cases(config: &Config) -> DiagnosticResult<( .map_or(Ok(()), Err) } +fn plan_v4_to_v5(config: &Config) -> DiagnosticResult> { + let Some(patterns) = config.source_scan.legacy_exclude.as_ref() else { + return Ok(vec![]); + }; + if patterns.is_empty() { + return Ok(vec![]); + } + + let mut migrated = String::new(); + for (index, pattern) in patterns.iter().enumerate() { + if pattern.contains(['\r', '\n']) { + return Err(Diagnostic::new( + crate::diagnostic::DiagnosticCode::E0501ConfigInvalid, + format!( + "Cannot migrate source_scan.exclude[{index}]: ignore rules cannot contain carriage returns or line feeds" + ), + config + .display_path(&config.gov_root.join("config.toml")) + .display() + .to_string(), + )); + } + if pattern.starts_with(['!', '#']) { + migrated.push('\\'); + } + migrated.push_str(pattern); + migrated.push('\n'); + } + + let path = config.project_root().join(".govignore"); + let existing = match fs::read_to_string(&path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => { + return Err(Diagnostic::io_error( + "read .govignore for migration", + error, + config.display_path(&path).display().to_string(), + )); + } + }; + migrated.push_str(&existing); + Ok(vec![FileOp::Write { + path, + content: migrated, + }]) +} + // ============================================================================= // Public API // ============================================================================= @@ -248,35 +304,41 @@ fn plan_config_version_bump(config: &Config, new_version: u32) -> DiagnosticResu let display_path = config.display_path(&path).display().to_string(); let content = fs::read_to_string(&path) .map_err(|err| Diagnostic::io_error("read config for migration", err, &display_path))?; - - let mut lines: Vec = content.lines().map(String::from).collect(); - let mut in_schema = false; - let mut found = false; - - for line in &mut lines { - let trimmed = line.trim(); - if trimmed.starts_with('[') { - in_schema = trimmed == "[schema]"; - } - if in_schema && trimmed.starts_with("version") && trimmed.contains('=') { - *line = format!("version = {new_version}"); - found = true; - break; - } + let mut document = content.parse::().map_err(|error| { + Diagnostic::new( + crate::diagnostic::DiagnosticCode::E0501ConfigInvalid, + format!("Failed to parse config for migration: {error}"), + &display_path, + ) + })?; + let schema = document + .entry("schema") + .or_insert(toml_edit::table()) + .as_table_mut() + .ok_or_else(|| { + Diagnostic::new( + crate::diagnostic::DiagnosticCode::E0501ConfigInvalid, + "Config field schema must be a table", + &display_path, + ) + })?; + let version = schema + .entry("version") + .or_insert(toml_edit::value(i64::from(new_version))); + let decor = version.as_value().map(|value| value.decor().clone()); + *version = toml_edit::value(i64::from(new_version)); + if let (Some(decor), Some(value)) = (decor, version.as_value_mut()) { + *value.decor_mut() = decor; } - - if !found { - lines.push(String::new()); - lines.push("[schema]".to_string()); - lines.push(format!("version = {new_version}")); - } - - let mut output = lines.join("\n"); - if !output.ends_with('\n') { - output.push('\n'); + if new_version >= 5 + && let Some(source_scan) = document + .get_mut("source_scan") + .and_then(toml_edit::Item::as_table_mut) + { + source_scan.remove("exclude"); } Ok(FileOp::Write { path, - content: output, + content: document.to_string(), }) } diff --git a/src/cmd/migrate/ops.rs b/src/cmd/migrate/ops.rs index 3d785aab..81d57135 100644 --- a/src/cmd/migrate/ops.rs +++ b/src/cmd/migrate/ops.rs @@ -59,23 +59,40 @@ pub(super) fn execute_ops(config: &Config, ops: &[FileOp]) -> DiagnosticResult<( fs::create_dir_all(&stage_root) .map_err(|err| io_error(&stage_root, "create migration stage directory", err))?; - fs::create_dir_all(&backup_root) - .map_err(|err| io_error(&backup_root, "create migration backup directory", err))?; + if let Err(err) = fs::create_dir_all(&backup_root) { + let operation_error = io_error(&backup_root, "create migration backup directory", err); + cleanup_dir(&stage_root); + return Err(operation_error); + } // Stage: write all new content to staging area if let Err(err) = materialize_stage(&stage_root, ops) { - let _ = fs::remove_dir_all(&stage_root); - let _ = fs::remove_dir_all(&backup_root); + cleanup_transaction_state(&stage_root, &backup_root); return Err(err); } // Commit: backup originals then apply staged content let result = commit_ops(&stage_root, &backup_root, ops); - let _ = fs::remove_dir_all(&stage_root); - if result.is_ok() { - let _ = fs::remove_dir_all(&backup_root); + match result { + Ok(_) => { + cleanup_transaction_state(&stage_root, &backup_root); + Ok(()) + } + Err(CommitFailure::RolledBack(err)) => { + cleanup_transaction_state(&stage_root, &backup_root); + Err(err) + } + Err(CommitFailure::RollbackFailed { + operation_error, + rollback_error, + }) => Err(retain_recovery_error( + config, + operation_error, + rollback_error, + &stage_root, + &backup_root, + )), } - result } fn materialize_stage(stage_root: &Path, ops: &[FileOp]) -> DiagnosticResult<()> { @@ -89,7 +106,11 @@ fn materialize_stage(stage_root: &Path, ops: &[FileOp]) -> DiagnosticResult<()> Ok(()) } -fn commit_ops(stage_root: &Path, backup_root: &Path, ops: &[FileOp]) -> DiagnosticResult<()> { +fn commit_ops( + stage_root: &Path, + backup_root: &Path, + ops: &[FileOp], +) -> Result, CommitFailure> { let mut applied: Vec = Vec::new(); let result = (|| -> DiagnosticResult<()> { @@ -105,8 +126,6 @@ fn commit_ops(stage_root: &Path, backup_root: &Path, ops: &[FileOp]) -> Diagnost })?; } let staged = stage_root.join(format!("{i}")); - fs::copy(&staged, path) - .map_err(|err| io_error(path, "apply migrated file", err))?; if existed { applied.push(AppliedOp::Restore { path: path.clone(), @@ -115,15 +134,17 @@ fn commit_ops(stage_root: &Path, backup_root: &Path, ops: &[FileOp]) -> Diagnost } else { applied.push(AppliedOp::RemoveCreated { path: path.clone() }); } + fs::copy(&staged, path) + .map_err(|err| io_error(path, "apply migrated file", err))?; } FileOp::Delete { path } => { if backup_existing_file(path, &backup_path, "backup file before deletion")? { - fs::remove_file(path) - .map_err(|err| io_error(path, "delete migrated legacy file", err))?; applied.push(AppliedOp::Restore { path: path.clone(), backup_path, }); + fs::remove_file(path) + .map_err(|err| io_error(path, "delete migrated legacy file", err))?; } } } @@ -131,22 +152,69 @@ fn commit_ops(stage_root: &Path, backup_root: &Path, ops: &[FileOp]) -> Diagnost Ok(()) })(); - if result.is_err() { - for op in applied.iter().rev() { - match op { - AppliedOp::Restore { path, backup_path } => { - let _ = fs::copy(backup_path, path); - } - AppliedOp::RemoveCreated { path } => { - if path.exists() { - let _ = fs::remove_file(path); - } - } - } + match result { + Ok(()) => Ok(applied), + Err(operation_error) => match rollback_applied(&applied) { + Ok(()) => Err(CommitFailure::RolledBack(operation_error)), + Err(rollback_error) => Err(CommitFailure::RollbackFailed { + operation_error, + rollback_error, + }), + }, + } +} + +fn retain_recovery_error( + config: &Config, + operation_error: Diagnostic, + rollback_error: Diagnostic, + stage_root: &Path, + backup_root: &Path, +) -> Diagnostic { + cleanup_dir(stage_root); + let backup_display = config.display_path(backup_root).display().to_string(); + Diagnostic::new( + DiagnosticCode::E0903UnexpectedError, + format!( + "{}; transaction rollback failed; repository restoration may be incomplete: {}; recovery backup retained at {}", + operation_error.message, rollback_error.message, backup_display + ), + backup_display, + ) +} + +fn rollback_applied(applied: &[AppliedOp]) -> DiagnosticResult<()> { + let mut first_error = None; + for op in applied.iter().rev() { + let result = match op { + AppliedOp::Restore { path, backup_path } => fs::copy(backup_path, path) + .map(|_| ()) + .map_err(|err| io_error(path, "restore file during migration rollback", err)), + AppliedOp::RemoveCreated { path } => match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(io_error( + path, + "remove created file during migration rollback", + err, + )), + }, + }; + if first_error.is_none() { + first_error = result.err(); } } - result + first_error.map_or(Ok(()), Err) +} + +fn cleanup_transaction_state(stage_root: &Path, backup_root: &Path) { + cleanup_dir(stage_root); + cleanup_dir(backup_root); +} + +fn cleanup_dir(path: &Path) { + let _ = fs::remove_dir_all(path); } fn backup_existing_file(path: &Path, backup_path: &Path, action: &str) -> DiagnosticResult { @@ -162,6 +230,14 @@ enum AppliedOp { RemoveCreated { path: PathBuf }, } +enum CommitFailure { + RolledBack(Diagnostic), + RollbackFailed { + operation_error: Diagnostic, + rollback_error: Diagnostic, + }, +} + fn io_error(path: &Path, action: &str, err: io::Error) -> Diagnostic { Diagnostic::io_error(action, err, path.display().to_string()) } diff --git a/src/cmd/migrate/ops_tests.rs b/src/cmd/migrate/ops_tests.rs index a1c3210d..572595a6 100644 --- a/src/cmd/migrate/ops_tests.rs +++ b/src/cmd/migrate/ops_tests.rs @@ -40,6 +40,8 @@ fn execute_ops_removes_created_files_when_later_apply_fails() !created.exists(), "created migration target should be removed on rollback" ); + assert!(!config.gov_root.join(".migrate-stage").exists()); + assert!(!config.gov_root.join(".migrate-backup").exists()); Ok(()) } @@ -76,5 +78,50 @@ fn execute_ops_restores_modified_and_deleted_files_when_later_apply_fails() assert!(result.is_err()); assert_eq!(fs::read_to_string(modified)?, "old"); assert_eq!(fs::read_to_string(deleted)?, "gone"); + assert!(!config.gov_root.join(".migrate-stage").exists()); + assert!(!config.gov_root.join(".migrate-backup").exists()); + Ok(()) +} + +#[test] +fn execute_ops_retains_backup_and_reports_e0903_when_rollback_fails() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let config = test_config(&temp_dir); + fs::create_dir_all(&config.gov_root)?; + let victim = config.gov_root.join("victim"); + let bad_target = config.gov_root.join("bad-target"); + fs::write(&victim, "old")?; + fs::create_dir(&bad_target)?; + + let error = match execute_ops( + &config, + &[ + FileOp::Write { + path: victim.clone(), + content: "new".to_string(), + }, + FileOp::Delete { + path: victim.clone(), + }, + FileOp::Write { + path: victim.join("child"), + content: "forces victim to become a directory".to_string(), + }, + FileOp::Write { + path: bad_target, + content: "cannot replace directory".to_string(), + }, + ], + ) { + Ok(()) => return Err("rollback unexpectedly succeeded".into()), + Err(error) => error, + }; + + assert_eq!(error.code, DiagnosticCode::E0903UnexpectedError); + assert!(error.message.contains("restoration may be incomplete")); + assert!(error.message.contains("recovery backup retained at")); + assert!(!config.gov_root.join(".migrate-stage").exists()); + assert!(config.gov_root.join(".migrate-backup").exists()); Ok(()) } diff --git a/src/cmd/self_update.rs b/src/cmd/self_update.rs index e717771c..6f7deb68 100644 --- a/src/cmd/self_update.rs +++ b/src/cmd/self_update.rs @@ -62,9 +62,13 @@ pub fn self_update(check_only: bool) -> DiagnosticResult { } fn check_version(current: &str) -> DiagnosticResult { - let releases = self_update::backends::github::ReleaseList::configure() - .repo_owner(REPO_OWNER) - .repo_name(REPO_NAME) + let mut builder = self_update::backends::github::ReleaseList::configure(); + builder.repo_owner(REPO_OWNER).repo_name(REPO_NAME); + if let Some(token) = github_token() { + builder.auth_token(&token); + } + + let releases = builder .build() .map_err(|err| self_update_error("configure GitHub release check", err))? .fetch() @@ -102,13 +106,19 @@ fn check_version(current: &str) -> DiagnosticResult { fn perform_update(current: &str) -> DiagnosticResult { let show_progress = std::io::stdout().is_terminal(); - let status = self_update::backends::github::Update::configure() + let mut builder = self_update::backends::github::Update::configure(); + builder .repo_owner(REPO_OWNER) .repo_name(REPO_NAME) .bin_name(BIN_NAME) .bin_path_in_archive(SELF_UPDATE_BIN_PATH_IN_ARCHIVE) .show_download_progress(show_progress) - .current_version(current) + .current_version(current); + if let Some(token) = github_token() { + builder.auth_token(&token); + } + + let status = builder .build() .map_err(|err| self_update_error("configure self-update", err))? .update() @@ -125,6 +135,14 @@ fn perform_update(current: &str) -> DiagnosticResult { Ok(vec![]) } +fn github_token() -> Option { + usable_github_token(std::env::var("GITHUB_TOKEN").ok()) +} + +fn usable_github_token(token: Option) -> Option { + token.filter(|value| !value.trim().is_empty()) +} + fn self_update_error(action: &str, err: impl std::fmt::Display) -> Diagnostic { Diagnostic::new( DiagnosticCode::E0901IoError, diff --git a/src/cmd/self_update_tests.rs b/src/cmd/self_update_tests.rs index eb549d30..300526c7 100644 --- a/src/cmd/self_update_tests.rs +++ b/src/cmd/self_update_tests.rs @@ -77,6 +77,17 @@ fn test_invalid_latest_version_errors() { assert!(compare_versions("0.8.3", "not-a-version").is_err()); } +#[test] +fn test_usable_github_token_rejects_empty_values() { + assert_eq!(usable_github_token(None), None); + assert_eq!(usable_github_token(Some(String::new())), None); + assert_eq!(usable_github_token(Some(" \t".to_string())), None); + assert_eq!( + usable_github_token(Some("token".to_string())), + Some("token".to_string()) + ); +} + #[test] fn test_unix_archive_bin_path_matches_release_layout() { assert_eq!( @@ -128,36 +139,49 @@ fn test_release_metadata_uses_matching_archive_layout() -> Result<(), Box, - /// Glob patterns for files to exclude (e.g., "**/tests/**") - #[serde(default)] - pub exclude: Vec, + /// Schema-v4 exclusion patterns retained only for migration. + #[serde(default, rename = "exclude", skip_serializing)] + pub(crate) legacy_exclude: Option>, /// Regex pattern with capture group 1 for artifact ID #[serde(default = "default_scan_pattern")] pub pattern: String, @@ -227,7 +227,7 @@ impl Default for SourceScanConfig { Self { enabled: false, include: default_scan_include(), - exclude: vec![], + legacy_exclude: None, pattern: default_scan_pattern(), } } diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 452e172f..13ca38b1 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -8,6 +8,14 @@ impl Config { /// All relative paths in the config are resolved relative to the project root /// (the parent of gov/config.toml), not the current working directory. pub fn load(path: Option<&Path>) -> DiagnosticResult { + Self::load_inner(path, false) + } + + pub fn load_for_migration(path: Option<&Path>) -> DiagnosticResult { + Self::load_inner(path, true) + } + + fn load_inner(path: Option<&Path>, allow_outdated_schema: bool) -> DiagnosticResult { let config_path = if let Some(path) = path { let path = PathBuf::from(path); if !path_entry_exists(&path)? { @@ -46,6 +54,13 @@ impl Config { schema_version, config_path.display().to_string(), )?; + if schema_version == 4 && !allow_outdated_schema { + return Err(Diagnostic::new( + DiagnosticCode::E0505MigrationRequired, + "Schema version 4 is outdated. Run `govctl migrate` to upgrade to schema version 5.", + config_path.display().to_string(), + )); + } let mut config: Config = raw.try_into().map_err(|err| { Diagnostic::new( DiagnosticCode::E0501ConfigInvalid, @@ -53,6 +68,13 @@ impl Config { config_path.display().to_string(), ) })?; + if schema_version >= 5 && config.source_scan.legacy_exclude.is_some() { + return Err(Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + "Unsupported config field for schema version 5: source_scan.exclude", + config_path.display().to_string(), + )); + } resolve_project_paths(&mut config, &config_path); diff --git a/src/config/template.rs b/src/config/template.rs index 1ab3d239..d56e35e3 100644 --- a/src/config/template.rs +++ b/src/config/template.rs @@ -39,7 +39,8 @@ version = {schema_version} # Scan source files for [[artifact-id]] references during `govctl check` # enabled = false # include = ["src/**/*.rs", "crates/**/*.rs", "**/*.md"] -# exclude = [] +# Project .gitignore files are applied by default. Add governance-specific +# exclusions or re-inclusions to .govignore using gitignore syntax. # [concurrency] # Maximum seconds to wait for exclusive lock before failing (default: 30) diff --git a/src/main.rs b/src/main.rs index 7065978e..80a8f99b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod loop_planner; mod loop_state; mod model; mod parse; +mod reference_pattern; mod render; mod resource_plan; mod scan; @@ -89,20 +90,32 @@ fn main() -> ExitCode { fn run(cli: &Cli) -> DiagnosticResult { // Convert parsed CLI command to canonical form let plan = command_router::CommandPlan::from_parsed(&cli.command, cli.dry_run)?; - let config = if cli.config.is_none() { + let project_independent = matches!( + &plan.op, + command_router::Op::Builtin(command_router::BuiltinOp::Completions { .. }) + | command_router::Op::Builtin(command_router::BuiltinOp::SelfUpdate { .. }) + | command_router::Op::Builtin(command_router::BuiltinOp::Describe { context: false }) + ); + let is_migrate = matches!( + &plan.op, + command_router::Op::Builtin(command_router::BuiltinOp::Migrate) + ); + let config = if project_independent { + Config::default() + } else if cli.config.is_none() { match &plan.op { command_router::Op::Builtin(command_router::BuiltinOp::Init { force }) => { Config::for_init(*force)? } + _ if is_migrate => Config::load_for_migration(None)?, _ => Config::load(None)?, } + } else if is_migrate { + Config::load_for_migration(cli.config.as_deref())? } else { Config::load(cli.config.as_deref())? }; - if !matches!( - plan.op, - command_router::Op::Builtin(command_router::BuiltinOp::Migrate) - ) { + if !is_migrate { load::reject_unmigrated_conformance(&config)?; } let op = write::WriteOp::from_dry_run(cli.dry_run); @@ -125,12 +138,7 @@ fn run(cli: &Cli) -> DiagnosticResult { })?; } } - if op.is_preview() - && matches!( - plan.op, - command_router::Op::Builtin(command_router::BuiltinOp::Init { .. }) - ) - { + if op.is_preview() { None } else { Some(lock::acquire_gov_lock(&config)?) diff --git a/src/reference_pattern.rs b/src/reference_pattern.rs new file mode 100644 index 00000000..7fc931af --- /dev/null +++ b/src/reference_pattern.rs @@ -0,0 +1,101 @@ +//! Shared validation for configured inline artifact-reference patterns. + +use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult}; +use regex::{Captures, Match, Regex}; +use std::fmt; + +pub(crate) fn compile(pattern: &str, config_path: impl Into) -> DiagnosticResult { + let config_path = config_path.into(); + let regex = Regex::new(pattern).map_err(|error| { + Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + format!("Invalid source_scan.pattern regex: {error}"), + config_path.clone(), + ) + })?; + + if regex.captures_len() < 2 { + return Err(Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + "Invalid source_scan.pattern: capture group 1 is required", + config_path, + )); + } + + Ok(regex) +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum TargetCaptureError { + Absent, + Empty, +} + +impl fmt::Display for TargetCaptureError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Absent => write!( + formatter, + "capture group 1 did not participate in the match" + ), + Self::Empty => write!(formatter, "capture group 1 matched an empty target"), + } + } +} + +pub(crate) fn target_capture<'text>( + captures: &Captures<'text>, +) -> Result, TargetCaptureError> { + let target = captures.get(1).ok_or(TargetCaptureError::Absent)?; + if target.is_empty() { + return Err(TargetCaptureError::Empty); + } + Ok(target) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compile_requires_capture_group_one() -> DiagnosticResult<()> { + let Err(diagnostic) = compile(r"\[\[RFC-\d{4}\]\]", "config") else { + return Err(Diagnostic::new( + DiagnosticCode::E0903UnexpectedError, + "pattern without capture group 1 compiled", + "test", + )); + }; + + assert_eq!(diagnostic.code, DiagnosticCode::E0501ConfigInvalid); + assert!(diagnostic.message.contains("capture group 1 is required")); + Ok(()) + } + + #[test] + fn target_capture_rejects_absent_and_empty_matches() -> DiagnosticResult<()> { + let optional = compile(r"(?:plain|\[\[([^]]+)\]\])", "config")?; + let absent = optional.captures("plain").ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0903UnexpectedError, + "optional-capture test pattern did not match", + "test", + ) + })?; + assert_eq!(target_capture(&absent), Err(TargetCaptureError::Absent)); + + let empty = compile(r"\[\[([^]]*)\]\]", "config")?; + let empty_capture = empty.captures("[[]]").ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0903UnexpectedError, + "empty-capture test pattern did not match", + "test", + ) + })?; + assert_eq!( + target_capture(&empty_capture), + Err(TargetCaptureError::Empty) + ); + Ok(()) + } +} diff --git a/src/render/links.rs b/src/render/links.rs index ea91e0c9..d324ab00 100644 --- a/src/render/links.rs +++ b/src/render/links.rs @@ -1,4 +1,4 @@ -use regex::Regex; +use crate::reference_pattern; /// Generate a markdown link for an artifact reference. /// @@ -60,12 +60,12 @@ pub(super) fn expand_inline_refs_with_linker(text: &str, pattern: &str, linke where F: Fn(&str) -> String, { - let Ok(re) = Regex::new(pattern) else { + let Ok(re) = reference_pattern::compile(pattern, "gov/config.toml") else { return text.to_string(); }; re.replace_all(text, |caps: ®ex::Captures| { - if let Some(artifact_id) = caps.get(1) { + if let Ok(artifact_id) = reference_pattern::target_capture(caps) { linker(artifact_id.as_str()) } else { caps.get(0).map_or("", |m| m.as_str()).to_string() diff --git a/src/scan.rs b/src/scan.rs index ee22d81d..f76c108b 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -1,18 +1,21 @@ //! Source code reference scanning. //! -//! Implements [[ADR-0009]] configurable source code reference scanning. +//! Implements [[RFC-0009]] source selection and ignore semantics. //! -//! Scans files matching include/exclude glob patterns for references to -//! governance artifacts and validates they exist in the project index. +//! Scans included, non-ignored files for references to governance artifacts +//! and validates they exist in the project index. use crate::artifact_index::{ArtifactRefState, artifact_ref_states}; use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode}; use crate::model::ProjectIndex; -use globset::{Glob, GlobSet, GlobSetBuilder}; -use regex::Regex; +use crate::reference_pattern::{self, TargetCaptureError}; +use ignore::WalkBuilder; +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use std::collections::HashMap; +use std::ffi::OsStr; use std::fs; -use walkdir::WalkDir; +use std::path::Path; /// Result of source scanning #[derive(Debug, Default)] @@ -22,6 +25,61 @@ pub struct ScanResult { pub refs_found: usize, } +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct SourceReference { + path: String, + line: usize, + byte_column: usize, + target: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct InvalidReferenceMatch { + path: String, + line: usize, + byte_column: usize, + error: TargetCaptureError, +} + +struct SourceReferenceDiagnostic { + path: String, + line: usize, + byte_column: usize, + code: DiagnosticCode, + target: String, + diagnostic: Diagnostic, +} + +struct SourceLocator<'content> { + content: &'content [u8], + scanned_to: usize, + line: usize, + line_start: usize, +} + +impl<'content> SourceLocator<'content> { + fn new(content: &'content str) -> Self { + Self { + content: content.as_bytes(), + scanned_to: 0, + line: 1, + line_start: 0, + } + } + + fn position(&mut self, byte_offset: usize) -> (usize, usize) { + debug_assert!(byte_offset >= self.scanned_to); + for index in self.scanned_to..byte_offset { + if self.content[index] == b'\n' { + self.line += 1; + self.line_start = index + 1; + } + } + self.scanned_to = byte_offset; + (self.line, byte_offset - self.line_start + 1) + } +} + /// Scan source files for artifact references pub fn scan_source_refs(config: &Config, index: &ProjectIndex) -> ScanResult { if !config.source_scan.enabled { @@ -34,113 +92,307 @@ pub fn scan_source_refs(config: &Config, index: &ProjectIndex) -> ScanResult { let known_ids = artifact_ref_states(index); // Compile the artifact pattern - let pattern = match Regex::new(&config.source_scan.pattern) { + let pattern = match reference_pattern::compile( + &config.source_scan.pattern, + config + .display_path(&config.gov_root.join("config.toml")) + .display() + .to_string(), + ) { Ok(re) => re, - Err(e) => { - result.diagnostics.push(Diagnostic::new( - DiagnosticCode::E0501ConfigInvalid, - format!("Invalid source_scan.pattern regex: {}", e), - "gov/config.toml".to_string(), - )); - return result; - } - }; - - let include_set = match build_glob_set(&config.source_scan.include, "include") { - Ok(set) => set, Err(diagnostic) => { result.diagnostics.push(diagnostic); return result; } }; - let exclude_set = match build_glob_set(&config.source_scan.exclude, "exclude") { - Ok(set) => set, + + let project_root = config.project_root(); + let include_matcher = match build_include_matcher(project_root, &config.source_scan.include) { + Ok(matcher) => matcher, Err(diagnostic) => { result.diagnostics.push(diagnostic); return result; } }; - let project_root = config.project_root(); - - // Walk from project root, filter by project-relative include/exclude globs. - let files = WalkDir::new(project_root) + let mut builder = WalkBuilder::new(project_root); + builder + .standard_filters(false) + .hidden(false) + .parents(false) + .ignore(false) + .git_ignore(true) + .git_global(false) + .git_exclude(false) + .require_git(false) .follow_links(false) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()); + .add_custom_ignore_filename(".govignore") + .filter_entry(|entry| entry.file_name() != OsStr::new(".git")); + + let mut references = Vec::new(); + let mut invalid_matches = Vec::new(); + for entry in builder.build() { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + result.diagnostics.push(Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + format!("Source scan traversal failed: {error}"), + config.display_path(project_root).display().to_string(), + )); + continue; + } + }; + if let Some(error) = entry.error() { + result.diagnostics.push(Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + format!("Invalid source scan ignore rule: {error}"), + config.display_path(entry.path()).display().to_string(), + )); + } - for entry in files { + let Some(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + validate_reached_ignore_files(config, entry.path(), &mut result); + continue; + } + if !file_type.is_file() { + continue; + } let path = entry.path(); let match_path = path.strip_prefix(project_root).unwrap_or(path); - // Check include/exclude - if !include_set.is_match(match_path) || exclude_set.is_match(match_path) { + if !include_matcher + .matched_path_or_any_parents(path, false) + .is_ignore() + { continue; } - let Ok(content) = fs::read_to_string(path) else { - continue; + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(error) => { + result.diagnostics.push(Diagnostic::io_error( + "read selected source file", + error, + config.display_path(path).display().to_string(), + )); + continue; + } }; result.files_scanned += 1; - let path_str = match_path.to_string_lossy().to_string(); + let path_str = normalized_relative_path(match_path); + let mut locator = SourceLocator::new(&content); // Find all matches for caps in pattern.captures_iter(&content) { - let Some(artifact_id) = caps.get(1).map(|m| m.as_str()) else { - continue; - }; - - result.refs_found += 1; - - // Check if artifact exists - match known_ids.get(artifact_id).copied() { - None => { - result.diagnostics.push(Diagnostic::new( - DiagnosticCode::E0107SourceRefUnknown, - format!("Unknown artifact reference: {}", artifact_id), - path_str.clone(), - )); + match reference_pattern::target_capture(&caps) { + Ok(target) => { + let (line, byte_column) = locator.position(target.start()); + references.push(SourceReference { + path: path_str.clone(), + line, + byte_column, + target: target.as_str().to_string(), + }); + } + Err(error) => { + let offset = caps + .get(1) + .or_else(|| caps.get(0)) + .map_or(0, |matched| matched.start()); + let (line, byte_column) = locator.position(offset); + invalid_matches.push(InvalidReferenceMatch { + path: path_str.clone(), + line, + byte_column, + error, + }); } - Some(ArtifactRefState::Outdated(reason)) => { - result.diagnostics.push(Diagnostic::new( + } + } + } + + append_invalid_match_diagnostics(&mut invalid_matches, &mut result.diagnostics); + append_reference_diagnostics(&mut references, &known_ids, &mut result); + result +} + +fn append_invalid_match_diagnostics( + invalid_matches: &mut Vec, + diagnostics: &mut Vec, +) { + invalid_matches.sort(); + invalid_matches.dedup(); + diagnostics.extend(invalid_matches.iter().map(|invalid| { + Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + format!("Invalid source_scan.pattern match: {}", invalid.error), + source_location(&invalid.path, invalid.line, invalid.byte_column), + ) + })); +} + +fn append_reference_diagnostics( + references: &mut Vec, + known_ids: &HashMap, + result: &mut ScanResult, +) { + references.sort(); + references.dedup(); + result.refs_found = references.len(); + + let mut diagnostics = Vec::new(); + for reference in references { + let location = source_location(&reference.path, reference.line, reference.byte_column); + match known_ids.get(&reference.target).copied() { + None => diagnostics.push(SourceReferenceDiagnostic { + path: reference.path.clone(), + line: reference.line, + byte_column: reference.byte_column, + code: DiagnosticCode::E0107SourceRefUnknown, + target: reference.target.clone(), + diagnostic: Diagnostic::new( + DiagnosticCode::E0107SourceRefUnknown, + format!("Unknown artifact reference: {}", reference.target), + location, + ), + }), + Some(ArtifactRefState::Outdated(reason)) => { + diagnostics.push(SourceReferenceDiagnostic { + path: reference.path.clone(), + line: reference.line, + byte_column: reference.byte_column, + code: DiagnosticCode::W0107SourceRefOutdated, + target: reference.target.clone(), + diagnostic: Diagnostic::new( DiagnosticCode::W0107SourceRefOutdated, format!( "Outdated reference: {} ({}) (hint: update comment or remove [[...]])", - artifact_id, reason + reference.target, reason ), - path_str.clone(), - )); - } - Some(ArtifactRefState::Active) => { - // OK - reference is valid - } + location, + ), + }); } + Some(ArtifactRefState::Active) => {} } } - + diagnostics.sort_by(|left, right| { + ( + &left.path, + left.line, + left.byte_column, + left.code.code(), + &left.target, + ) + .cmp(&( + &right.path, + right.line, + right.byte_column, + right.code.code(), + &right.target, + )) + }); result + .diagnostics + .extend(diagnostics.into_iter().map(|entry| entry.diagnostic)); } -fn build_glob_set(patterns: &[String], label: &str) -> Result { - let mut builder = GlobSetBuilder::new(); - for pat in patterns { - let glob = Glob::new(pat).map_err(|e| { - Diagnostic::new( +fn build_include_matcher(root: &Path, patterns: &[String]) -> Result { + let mut builder = GitignoreBuilder::new(root); + for pattern in patterns { + if pattern.is_empty() { + return Err(Diagnostic::new( DiagnosticCode::E0501ConfigInvalid, - format!("Invalid source_scan.{label} glob '{}': {}", pat, e), + "Invalid source_scan.include pattern: entries cannot be empty", "gov/config.toml".to_string(), - ) - })?; - builder.add(glob); + )); + } + if pattern.starts_with('!') { + return Err(Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + format!( + "Invalid source_scan.include pattern '{pattern}': leading '!' is not allowed" + ), + "gov/config.toml".to_string(), + )); + } + + let gitignore_pattern = if pattern.starts_with('#') { + format!(r"\{pattern}") + } else { + pattern.clone() + }; + builder + .add_line(None, &gitignore_pattern) + .map_err(|error| { + Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + format!("Invalid source_scan.include pattern '{pattern}': {error}"), + "gov/config.toml".to_string(), + ) + })?; } - builder.build().map_err(|e| { + builder.build().map_err(|error| { Diagnostic::new( DiagnosticCode::E0501ConfigInvalid, - format!("Failed to build {label} glob set: {}", e), + format!("Failed to build source_scan.include matcher: {error}"), "gov/config.toml".to_string(), ) }) } + +fn normalized_relative_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn source_location(path: &str, line: usize, byte_column: usize) -> String { + format!("{path}:{line}:{byte_column}") +} + +fn validate_reached_ignore_files(config: &Config, directory: &Path, result: &mut ScanResult) { + for name in [".gitignore", ".govignore"] { + let path = directory.join(name); + match fs::read_to_string(&path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => result.diagnostics.push(Diagnostic::io_error( + "read source scan ignore file", + error, + config.display_path(&path).display().to_string(), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reference_diagnostics_deduplicate_identical_occurrences() { + let duplicate = SourceReference { + path: "src/main.rs".to_string(), + line: 2, + byte_column: 4, + target: "RFC-9999".to_string(), + }; + let mut references = vec![duplicate.clone(), duplicate]; + let mut result = ScanResult::default(); + + append_reference_diagnostics(&mut references, &HashMap::new(), &mut result); + + assert_eq!(result.refs_found, 1); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!( + result.diagnostics[0].code, + DiagnosticCode::E0107SourceRefUnknown + ); + assert_eq!(result.diagnostics[0].file, "src/main.rs:2:4"); + } +} diff --git a/src/schema.rs b/src/schema.rs index 4fed1b57..7816d23a 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -139,7 +139,7 @@ pub fn installed_schema_diagnostics(config: &Config) -> Diagnostics { let path = config.schema_dir().join(template.filename); let display = config.display_path(&path).display().to_string(); match std::fs::read_to_string(&path) { - Ok(existing) if existing == template.content => {} + Ok(existing) if normalized_text_eq(&existing, template.content) => {} Ok(_) => diagnostics.push(stale_schema_diagnostic(&display)), Err(err) if err.kind() == ErrorKind::NotFound => { diagnostics.push(stale_schema_diagnostic(&display)); @@ -150,6 +150,10 @@ pub fn installed_schema_diagnostics(config: &Config) -> Diagnostics { diagnostics } +fn normalized_text_eq(left: &str, right: &str) -> bool { + left == right || left.replace("\r\n", "\n") == right.replace("\r\n", "\n") +} + fn stale_schema_diagnostic(display_path: &str) -> Diagnostic { Diagnostic::new( DiagnosticCode::W0110SchemaOutdated, @@ -250,3 +254,17 @@ fn validate_value( artifact_display, )) } + +#[cfg(test)] +mod tests { + use super::normalized_text_eq; + + #[test] + fn schema_content_comparison_ignores_crlf_conversion() { + assert!(normalized_text_eq( + "line one\nline two\n", + "line one\r\nline two\r\n" + )); + assert!(!normalized_text_eq("line one\n", "line two\r\n")); + } +} diff --git a/src/signature/canonical_json.rs b/src/signature/canonical_json.rs index 153d10ec..399cff7c 100644 --- a/src/signature/canonical_json.rs +++ b/src/signature/canonical_json.rs @@ -1,6 +1,7 @@ //! Deterministic JSON serialization for signature computation. use serde_json::Value; +use std::borrow::Cow; /// Canonicalize a JSON value: /// - Object keys sorted alphabetically, recursively @@ -19,7 +20,8 @@ fn write_canonical_json(value: &Value, out: &mut String) { Value::Bool(false) => out.push_str("false"), Value::Number(num) => out.push_str(&num.to_string()), Value::String(s) => { - if let Ok(escaped) = serde_json::to_string(s) { + let normalized = normalize_line_endings(s); + if let Ok(escaped) = serde_json::to_string(&normalized) { out.push_str(&escaped); } } @@ -51,3 +53,11 @@ fn write_canonical_json(value: &Value, out: &mut String) { } } } + +fn normalize_line_endings(value: &str) -> Cow<'_, str> { + if value.contains("\r\n") { + Cow::Owned(value.replace("\r\n", "\n")) + } else { + Cow::Borrowed(value) + } +} diff --git a/src/signature/tests.rs b/src/signature/tests.rs index 517cc70f..715e1c35 100644 --- a/src/signature/tests.rs +++ b/src/signature/tests.rs @@ -23,6 +23,14 @@ fn test_canonicalize_nested_objects() -> Result<(), Box> Ok(()) } +#[test] +fn test_canonicalize_normalizes_crlf_in_string_values() { + let lf = serde_json::json!({"text": "line one\nline two\n"}); + let crlf = serde_json::json!({"text": "line one\r\nline two\r\n"}); + + assert_eq!(canonicalize_json(&lf), canonicalize_json(&crlf)); +} + #[test] fn test_extract_signature() { let md = r#"--- @@ -79,6 +87,22 @@ fn test_rfc_content_signature_includes_clause_content() -> Result<(), Diagnostic Ok(()) } +#[test] +fn test_rfc_signatures_ignore_crlf_conversion() -> Result<(), Diagnostic> { + let lf = test_rfc_index(); + let mut crlf = lf.clone(); + crlf.clauses[0].spec.text = "Original normative\r\nbehavior.".to_string(); + + let mut normalized_lf = lf; + normalized_lf.clauses[0].spec.text = "Original normative\nbehavior.".to_string(); + + assert_eq!( + compute_rfc_signature(&normalized_lf)?, + compute_rfc_signature(&crlf)? + ); + Ok(()) +} + #[test] fn test_rfc_amended_rejects_legacy_full_signature_baseline() -> Result<(), Diagnostic> { let mut rfc = test_rfc_index(); diff --git a/src/validate/bracket_refs.rs b/src/validate/bracket_refs.rs index 5f54e254..69dfb3f1 100644 --- a/src/validate/bracket_refs.rs +++ b/src/validate/bracket_refs.rs @@ -4,6 +4,7 @@ use crate::artifact_index::artifact_ref_ids; use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode}; use crate::model::{AdrStatus, ProjectIndex, RfcStatus, WorkItemStatus}; +use crate::reference_pattern; use regex::Regex; use std::collections::HashSet; @@ -41,14 +42,16 @@ pub(super) fn validate_bracket_reference_hierarchy( config: &Config, result: &mut ValidationResult, ) { - let bracket_re = match Regex::new(&config.source_scan.pattern) { + let bracket_re = match reference_pattern::compile( + &config.source_scan.pattern, + config + .display_path(&config.gov_root.join("config.toml")) + .display() + .to_string(), + ) { Ok(r) => r, - Err(e) => { - result.diagnostics.push(Diagnostic::new( - DiagnosticCode::E0501ConfigInvalid, - format!("Invalid source_scan.pattern for bracket reference scan: {e}"), - "gov/config.toml".to_string(), - )); + Err(diagnostic) => { + result.diagnostics.push(diagnostic); return; } }; @@ -282,11 +285,26 @@ fn scan_reference_hierarchy( ) { let mut bracket_ranges = Vec::new(); for caps in scanner.bracket_re.captures_iter(text) { - if let Some(full) = caps.get(0) { + let match_start = caps.get(0).map(|full| { bracket_ranges.push(full.range()); - } - let Some(m) = caps.get(1) else { - continue; + full.start() + }); + let m = match reference_pattern::target_capture(&caps) { + Ok(target) => target, + Err(error) => { + let (line, byte_column) = match_start + .map(|offset| source_line_and_byte_column(text, offset)) + .unwrap_or((1, 1)); + result.diagnostics.push(Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + format!( + "Invalid source_scan.pattern match in {field} at line {line}, byte column {byte_column}: {error}", + field = source.field, + ), + source.path, + )); + continue; + } }; let target = m.as_str(); if let Err(diagnostic) = @@ -343,7 +361,7 @@ fn bare_artifact_reference_warning( } fn source_line_context(text: &str, byte_offset: usize) -> (usize, String) { - let line = text[..byte_offset].bytes().filter(|b| *b == b'\n').count() + 1; + let (line, _) = source_line_and_byte_column(text, byte_offset); let line_start = text[..byte_offset].rfind('\n').map_or(0, |idx| idx + 1); let line_end = text[byte_offset..] .find('\n') @@ -352,6 +370,12 @@ fn source_line_context(text: &str, byte_offset: usize) -> (usize, String) { (line, truncate_context(&context)) } +fn source_line_and_byte_column(text: &str, byte_offset: usize) -> (usize, usize) { + let line = text[..byte_offset].bytes().filter(|b| *b == b'\n').count() + 1; + let line_start = text[..byte_offset].rfind('\n').map_or(0, |idx| idx + 1); + (line, byte_offset - line_start + 1) +} + fn collapse_context_whitespace(line: &str) -> String { line.split_whitespace().collect::>().join(" ") } @@ -525,4 +549,53 @@ mod tests { assert!(result.diagnostics.is_empty()); Ok(()) } + + #[test] + fn invalid_bracket_matches_report_distinct_positions() -> DiagnosticResult<()> { + let bracket_re = Regex::new(r"\[\[(RFC-\d{4})?\]\]").map_err(|err| { + Diagnostic::new( + DiagnosticCode::E0903UnexpectedError, + format!("test bracket regex must compile: {err}"), + "test", + ) + })?; + let scanner = ReferenceScanner { + bracket_re, + bare_re: bare_re()?, + known_ids: HashSet::new(), + }; + let mut result = ValidationResult::default(); + + scan_reference_hierarchy( + &scanner, + "[[]] and [[]]", + "RFC-0001", + TextSource { + path: "f", + field: "content.text", + }, + ScanPolicy { + scan_bare_text: false, + warn_on_bare_text: false, + }, + &mut result, + ); + + assert_eq!(result.diagnostics.len(), 2); + assert!( + result.diagnostics[0] + .message + .contains("content.text at line 1, byte column 1"), + "message: {}", + result.diagnostics[0].message + ); + assert!( + result.diagnostics[1] + .message + .contains("content.text at line 1, byte column 10"), + "message: {}", + result.diagnostics[1].message + ); + Ok(()) + } } diff --git a/src/verification/runner/process_group.rs b/src/verification/runner/process_group.rs index 185d8032..c3d61d08 100644 --- a/src/verification/runner/process_group.rs +++ b/src/verification/runner/process_group.rs @@ -1,4 +1,5 @@ use std::process::{Child, Command}; +#[cfg(unix)] use std::time::Duration; #[cfg(unix)] diff --git a/src/write/artifact.rs b/src/write/artifact.rs index a9feef23..69e8f14a 100644 --- a/src/write/artifact.rs +++ b/src/write/artifact.rs @@ -30,22 +30,14 @@ pub fn read_rfc(config: &Config, path: &Path) -> DiagnosticResult { /// Write RFC to file in TOML only. /// TOML output uses the `[govctl]` wire format plus schema header. pub fn write_rfc( - _config: &Config, + config: &Config, path: &Path, rfc: &RfcSpec, op: WriteOp, display_path: Option<&Path>, ) -> DiagnosticResult<()> { let wire: RfcWire = rfc.clone().into(); - write_toml_artifact( - path, - &wire, - ArtifactSchema::Rfc, - DiagnosticCode::E0101RfcSchemaInvalid, - "RFC", - op, - display_path, - ) + write_toml_artifact(config, path, &wire, &RFC_IO, op, display_path) } /// Read a clause from canonical structured TOML. @@ -65,13 +57,5 @@ pub fn write_clause( ) -> DiagnosticResult<()> { crate::load::validate_clause_storage_path(config, path).map_err(Diagnostic::from)?; let wire: ClauseWire = clause.clone().into(); - write_toml_artifact( - path, - &wire, - ArtifactSchema::Clause, - DiagnosticCode::E0201ClauseSchemaInvalid, - "clause", - op, - display_path, - ) + write_toml_artifact(config, path, &wire, &CLAUSE_IO, op, display_path) } diff --git a/src/write/artifact_io.rs b/src/write/artifact_io.rs index 7567f6d8..d9c85a2a 100644 --- a/src/write/artifact_io.rs +++ b/src/write/artifact_io.rs @@ -61,22 +61,29 @@ where } pub(super) fn write_toml_artifact( + config: &Config, path: &Path, wire: &Wire, - schema: ArtifactSchema, - schema_error: DiagnosticCode, - message_label: &str, + io: &ArtifactIo, op: WriteOp, display_path: Option<&Path>, ) -> DiagnosticResult<()> { let diagnostic_path = display_path.unwrap_or(path); let body = toml::to_string_pretty(wire).map_err(|err| { Diagnostic::new( - schema_error, - format!("Failed to serialize {message_label} TOML: {err}"), + io.schema_error, + format!("Failed to serialize {} TOML: {err}", io.message_label), diagnostic_path.display().to_string(), ) })?; - let content = with_schema_header(schema, &body); + let raw = toml::from_str(&body).map_err(|err| { + Diagnostic::new( + io.schema_error, + format!("Failed to normalize {} TOML: {err}", io.message_label), + diagnostic_path.display().to_string(), + ) + })?; + validate_toml_value(io.schema, config, path, &raw)?; + let content = with_schema_header(io.schema, &body); write_file(path, &content, op, display_path) } diff --git a/tests/edit_tests/clause.rs b/tests/edit_tests/clause.rs index 6d5aa377..31f82cf7 100644 --- a/tests/edit_tests/clause.rs +++ b/tests/edit_tests/clause.rs @@ -71,6 +71,25 @@ fn test_clause_edit_text_canonical() -> common::TestResult { Ok(()) } +#[test] +fn test_clause_edit_rejects_schema_invalid_text_without_writing() -> common::TestResult { + let temp_dir = init_project()?; + let clause = new_test_clause("Test Clause"); + run_commands(temp_dir.path(), &[NEW_TEST_RFC, &clause])?; + let clause_path = temp_dir.path().join("gov/rfc/RFC-0001/clauses/C-TEST.toml"); + let before = std::fs::read(&clause_path)?; + + let output = run_commands( + temp_dir.path(), + &[&["clause", "edit", TEST_CLAUSE_ID, "text", "--set", ""]], + )?; + + assert!(output.contains("error[E0201]"), "{output}"); + assert!(output.contains("is shorter than 1 character"), "{output}"); + assert_eq!(std::fs::read(clause_path)?, before); + Ok(()) +} + #[test] fn test_clause_set_title() -> common::TestResult { let (temp_dir, date) = init_project_with_date()?; diff --git a/tests/snapshots/test_help__rfc_bump_help.snap b/tests/snapshots/test_help__rfc_bump_help.snap index 44647780..6e79030d 100644 --- a/tests/snapshots/test_help__rfc_bump_help.snap +++ b/tests/snapshots/test_help__rfc_bump_help.snap @@ -28,7 +28,8 @@ EXAMPLES: NOTES: - Version-changing bumps require a normative RFC in impl, test, or stable with a sealed signature. - While an RFC is in spec, continue authoring the current version candidate instead of bumping again. - - Choose one of `--patch`, `--minor`, or `--major` when releasing a content amendment. + - Bump flags select literal SemVer components; they are not remapped impact labels. + - For 0.y.z, use `--minor` for a breaking pre-1.0 amendment; `--major` deliberately advances to 1.0.0. - `--change` without a bump level updates the current changelog entry without changing version. - Use `-m/--summary` for a release summary and `-c/--change` for detailed entries. exit: 0 diff --git a/tests/snapshots/test_scan__scan_deprecated_rfc_reference.snap b/tests/snapshots/test_scan__scan_deprecated_rfc_reference.snap index 445005b6..b19a9a94 100644 --- a/tests/snapshots/test_scan__scan_deprecated_rfc_reference.snap +++ b/tests/snapshots/test_scan__scan_deprecated_rfc_reference.snap @@ -13,5 +13,5 @@ Checked: 1 source files scanned 1 references found -warning[W0107]: Outdated reference: RFC-0001 (deprecated) (hint: update comment or remove [[...]]) (src/main.rs) +warning[W0107]: Outdated reference: RFC-0001 (deprecated) (hint: update comment or remove [[...]]) (src/main.rs:1:17) exit: 0 diff --git a/tests/snapshots/test_scan__scan_mixed_valid_invalid_references.snap b/tests/snapshots/test_scan__scan_mixed_valid_invalid_references.snap index 53b46674..5c58e8e2 100644 --- a/tests/snapshots/test_scan__scan_mixed_valid_invalid_references.snap +++ b/tests/snapshots/test_scan__scan_mixed_valid_invalid_references.snap @@ -13,5 +13,5 @@ Checked: 1 source files scanned 2 references found -error[E0107]: Unknown artifact reference: RFC-9999 (src/main.rs) +error[E0107]: Unknown artifact reference: RFC-9999 (src/main.rs:1:34) exit: 1 diff --git a/tests/snapshots/test_scan__scan_unknown_clause_reference.snap b/tests/snapshots/test_scan__scan_unknown_clause_reference.snap index 7f6dd154..ccde603f 100644 --- a/tests/snapshots/test_scan__scan_unknown_clause_reference.snap +++ b/tests/snapshots/test_scan__scan_unknown_clause_reference.snap @@ -13,5 +13,5 @@ Checked: 1 source files scanned 1 references found -error[E0107]: Unknown artifact reference: RFC-0001:C-NONEXISTENT (src/main.rs) +error[E0107]: Unknown artifact reference: RFC-0001:C-NONEXISTENT (src/main.rs:1:17) exit: 1 diff --git a/tests/snapshots/test_scan__scan_unknown_rfc_reference.snap b/tests/snapshots/test_scan__scan_unknown_rfc_reference.snap index b75bc517..a544a772 100644 --- a/tests/snapshots/test_scan__scan_unknown_rfc_reference.snap +++ b/tests/snapshots/test_scan__scan_unknown_rfc_reference.snap @@ -13,5 +13,5 @@ Checked: 1 source files scanned 1 references found -error[E0107]: Unknown artifact reference: RFC-9999 (src/main.rs) +error[E0107]: Unknown artifact reference: RFC-9999 (src/main.rs:1:17) exit: 1 diff --git a/tests/test_conformance.rs b/tests/test_conformance.rs index f1c7b25c..da4ccad6 100644 --- a/tests/test_conformance.rs +++ b/tests/test_conformance.rs @@ -302,7 +302,7 @@ requirements = [{ ref = "RFC-0001:C-REQ", version = "0.1.0" }] .join("gov/conformance/CONF-EXISTING.toml") .exists() ); - assert!(fs::read_to_string(temp_dir.path().join("gov/config.toml"))?.contains("version = 4")); + assert!(fs::read_to_string(temp_dir.path().join("gov/config.toml"))?.contains("version = 5")); Ok(()) } diff --git a/tests/test_migrate.rs b/tests/test_migrate.rs index 6c957e4e..ee9baeb2 100644 --- a/tests/test_migrate.rs +++ b/tests/test_migrate.rs @@ -23,6 +23,31 @@ fn current_schema_version(dir: &Path) -> Result> Ok(u32::try_from(version)?) } +fn write_schema_four_source_scan( + dir: &Path, + exclude: toml::Value, +) -> Result<(), Box> { + let config_path = dir.join("gov/config.toml"); + let mut config: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?; + config["schema"]["version"] = toml::Value::Integer(4); + config + .as_table_mut() + .ok_or("config is not a table")? + .insert( + "source_scan".to_string(), + toml::Value::Table(toml::map::Map::from_iter([ + ("enabled".to_string(), toml::Value::Boolean(true)), + ( + "include".to_string(), + toml::Value::Array(vec![toml::Value::String("src/**/*.rs".to_string())]), + ), + ("exclude".to_string(), exclude), + ])), + ); + fs::write(config_path, toml::to_string_pretty(&config)?)?; + Ok(()) +} + fn write_legacy_rfc_project(dir: &std::path::Path) -> Result<(), Box> { let rfc_dir = dir.join("gov/rfc/RFC-0001"); fs::create_dir_all(rfc_dir.join("clauses"))?; @@ -402,3 +427,187 @@ fn test_check_rejects_legacy_json_storage() -> TestResult { Ok(()) } + +#[test] +fn test_migrate_v4_source_excludes_to_govignore() -> TestResult { + let temp_dir = init_project()?; + write_schema_four_source_scan( + temp_dir.path(), + toml::Value::Array(vec![ + toml::Value::String("target/".to_string()), + toml::Value::String("!literal".to_string()), + toml::Value::String("#literal".to_string()), + ]), + )?; + fs::write(temp_dir.path().join(".govignore"), "existing-rule\n")?; + + let output = run_commands(temp_dir.path(), &[&["migrate"]])?; + assert!(output.contains("v4 -> v5"), "{output}"); + assert_eq!(current_schema_version(temp_dir.path())?, 5); + let config = fs::read_to_string(temp_dir.path().join("gov/config.toml"))?; + assert!(!config.contains("exclude"), "{config}"); + assert_eq!( + fs::read_to_string(temp_dir.path().join(".govignore"))?, + "target/\n\\!literal\n\\#literal\nexisting-rule\n" + ); + Ok(()) +} + +#[test] +fn test_migrate_v4_preserves_config_comments_and_section_order() -> TestResult { + let temp_dir = init_project()?; + write_schema_four_source_scan( + temp_dir.path(), + toml::Value::Array(vec![toml::Value::String("target/".to_string())]), + )?; + let config_path = temp_dir.path().join("gov/config.toml"); + let config = fs::read_to_string(&config_path)? + .replacen("version = 4", "version = 4 # schema version", 1) + .replacen("[source_scan]", "# source scan settings\n[source_scan]", 1); + let sections_before = config + .lines() + .filter(|line| line.starts_with('[')) + .map(str::to_owned) + .collect::>(); + fs::write(&config_path, config)?; + + run_commands(temp_dir.path(), &[&["migrate"]])?; + + let migrated = fs::read_to_string(&config_path)?; + let sections_after = migrated + .lines() + .filter(|line| line.starts_with('[')) + .map(str::to_owned) + .collect::>(); + assert!( + migrated.contains("version = 5 # schema version"), + "{migrated}" + ); + assert!(migrated.contains("# source scan settings"), "{migrated}"); + assert!(!migrated.contains("exclude"), "{migrated}"); + assert_eq!(sections_after, sections_before); + Ok(()) +} + +#[test] +fn test_migrate_v4_empty_excludes_does_not_create_govignore() -> TestResult { + let temp_dir = init_project()?; + write_schema_four_source_scan(temp_dir.path(), toml::Value::Array(vec![]))?; + + let output = run_commands(temp_dir.path(), &[&["migrate"]])?; + assert!(output.contains("v4 -> v5"), "{output}"); + assert_eq!(current_schema_version(temp_dir.path())?, 5); + assert!(!temp_dir.path().join(".govignore").exists()); + let config = fs::read_to_string(temp_dir.path().join("gov/config.toml"))?; + assert!(!config.contains("exclude"), "{config}"); + Ok(()) +} + +#[test] +fn test_migrate_v4_source_excludes_dry_run_reports_without_writing() -> TestResult { + let temp_dir = init_project()?; + write_schema_four_source_scan( + temp_dir.path(), + toml::Value::Array(vec![toml::Value::String("target/".to_string())]), + )?; + fs::write(temp_dir.path().join(".govignore"), "existing-rule\n")?; + let config_path = temp_dir.path().join("gov/config.toml"); + let govignore_path = temp_dir.path().join(".govignore"); + let config_before = fs::read(&config_path)?; + let govignore_before = fs::read(&govignore_path)?; + let lock_path = temp_dir.path().join("gov/.govctl.lock"); + if lock_path.exists() { + fs::remove_file(&lock_path)?; + } + + let output = run_commands(temp_dir.path(), &[&["--dry-run", "migrate"]])?; + assert!(output.contains("Would write: .govignore"), "{output}"); + assert!(output.contains("Would write: gov/config.toml"), "{output}"); + assert_eq!(fs::read(&config_path)?, config_before); + assert_eq!(fs::read(&govignore_path)?, govignore_before); + assert!(!lock_path.exists()); + Ok(()) +} + +#[test] +fn test_migrate_v4_rejects_multiline_exclude_without_mutation() -> TestResult { + let temp_dir = init_project()?; + write_schema_four_source_scan( + temp_dir.path(), + toml::Value::Array(vec![toml::Value::String("bad\nrule".to_string())]), + )?; + let config_path = temp_dir.path().join("gov/config.toml"); + let config_before = fs::read(&config_path)?; + + let output = run_commands(temp_dir.path(), &[&["migrate"]])?; + assert!(output.contains("error[E0501]"), "{output}"); + assert!(output.contains("source_scan.exclude[0]"), "{output}"); + assert_eq!(fs::read(&config_path)?, config_before); + assert!(!temp_dir.path().join(".govignore").exists()); + Ok(()) +} + +#[test] +fn test_schema_v5_rejects_residual_source_scan_exclude() -> TestResult { + let temp_dir = init_project()?; + let config_path = temp_dir.path().join("gov/config.toml"); + let mut config: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?; + config + .as_table_mut() + .ok_or("config is not a table")? + .insert( + "source_scan".to_string(), + toml::Value::Table(toml::map::Map::from_iter([( + "exclude".to_string(), + toml::Value::Array(vec![]), + )])), + ); + fs::write(&config_path, toml::to_string_pretty(&config)?)?; + let before = fs::read(&config_path)?; + + let output = run_commands(temp_dir.path(), &[&["check"], &["migrate"]])?; + assert_eq!(output.matches("error[E0501]").count(), 2, "{output}"); + assert!(output.contains("source_scan.exclude"), "{output}"); + assert_eq!(fs::read(&config_path)?, before); + Ok(()) +} + +#[test] +fn test_schema_v4_normal_command_requires_migration_without_mutation() -> TestResult { + let temp_dir = init_project()?; + write_schema_four_source_scan(temp_dir.path(), toml::Value::Array(vec![]))?; + + let output = run_commands( + temp_dir.path(), + &[&["work", "new", "--active", "Must not be created"]], + )?; + assert!(output.contains("error[E0505]"), "{output}"); + assert!(output.contains("govctl migrate"), "{output}"); + assert_eq!(fs::read_dir(temp_dir.path().join("gov/work"))?.count(), 0); + Ok(()) +} + +#[test] +fn test_schema_v4_gate_precedes_full_config_load_but_not_project_independent_commands() -> TestResult +{ + let temp_dir = init_project()?; + write_schema_four_source_scan( + temp_dir.path(), + toml::Value::String("not-an-array".to_string()), + )?; + + let output = run_commands( + temp_dir.path(), + &[ + &["work", "new", "--active", "Must not be created"], + &["describe"], + &["completions", "bash"], + ], + )?; + assert_eq!(output.matches("error[E0505]").count(), 1, "{output}"); + assert!(!output.contains("Failed to parse config"), "{output}"); + assert!(output.contains("\"schema_version\": 1"), "{output}"); + assert!(output.contains("_govctl()"), "{output}"); + assert_eq!(fs::read_dir(temp_dir.path().join("gov/work"))?.count(), 0); + Ok(()) +} diff --git a/tests/test_scan.rs b/tests/test_scan.rs index 024635af..cca7a658 100644 --- a/tests/test_scan.rs +++ b/tests/test_scan.rs @@ -21,12 +21,46 @@ fn enable_source_scan(dir: &std::path::Path) -> Result<(), Box Result<(), Box> { + let config_path = dir.join("gov/config.toml"); + let mut config: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?; + config["source_scan"]["include"] = toml::Value::Array( + patterns + .iter() + .map(|pattern| toml::Value::String((*pattern).to_string())) + .collect(), + ); + fs::write(config_path, toml::to_string_pretty(&config)?)?; + Ok(()) +} + +fn set_source_scan_pattern(dir: &Path, pattern: &str) -> Result<(), Box> { + let config_path = dir.join("gov/config.toml"); + let mut config: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?; + let config_table = config + .as_table_mut() + .ok_or("config root must be a TOML table")?; + let source_scan = config_table + .entry("source_scan") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or("source_scan must be a TOML table")?; + source_scan.insert( + "pattern".to_string(), + toml::Value::String(pattern.to_string()), + ); + fs::write(config_path, toml::to_string_pretty(&config)?)?; + Ok(()) +} + fn init_source_scan_project() -> Result<(tempfile::TempDir, String), Box> { let (temp_dir, date) = init_project_with_date()?; enable_source_scan(temp_dir.path())?; @@ -66,6 +100,41 @@ fn test_scan_no_references() -> common::TestResult { assert_scan_check_snapshot!(temp_dir, &date) } +#[test] +fn test_scan_disabled_does_not_read_ignore_files() -> common::TestResult { + let (temp_dir, _) = init_project_with_date()?; + fs::create_dir(temp_dir.path().join(".govignore"))?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(!output.contains(".govignore"), "{output}"); + assert!(!output.contains("source files scanned"), "{output}"); + assert!(!output.contains("exit: 1"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_empty_include_selects_no_files() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &[])?; + write_main_rs(temp_dir.path(), "fn main() {}\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 0 source files scanned"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_include_matching_is_case_sensitive() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + let src_dir = temp_dir.path().join("src"); + fs::create_dir_all(&src_dir)?; + fs::write(src_dir.join("UPPER.RS"), "fn upper() {}\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 0 source files scanned"), "{output}"); + Ok(()) +} + #[test] fn test_scan_valid_rfc_reference() -> common::TestResult { let (temp_dir, date) = init_source_scan_project()?; @@ -149,6 +218,103 @@ fn test_scan_unknown_rfc_reference() -> common::TestResult { assert_scan_check_snapshot!(temp_dir, &date) } +#[test] +fn test_check_rejects_pattern_without_capture_group_when_scan_disabled() -> common::TestResult { + let (temp_dir, _) = init_project_with_date()?; + set_source_scan_pattern(temp_dir.path(), r"\[\[RFC-\d{4}\]\]")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0501]"), "{output}"); + assert!(output.contains("capture group 1 is required"), "{output}"); + assert_eq!(output.matches("error[E0501]").count(), 1, "{output}"); + Ok(()) +} + +#[test] +fn test_check_reports_pattern_contract_once_when_scan_enabled() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_pattern(temp_dir.path(), r"\[\[RFC-\d{4}\]\]")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("capture group 1 is required"), "{output}"); + assert_eq!(output.matches("error[E0501]").count(), 1, "{output}"); + Ok(()) +} + +#[test] +fn test_check_reports_pattern_contract_when_project_loading_fails() -> common::TestResult { + let (temp_dir, _) = init_project_with_date()?; + set_source_scan_pattern(temp_dir.path(), r"\[\[RFC-\d{4}\]\]")?; + let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); + fs::create_dir_all(&rfc_dir)?; + fs::write(rfc_dir.join("rfc.toml"), "not valid TOML =")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0501]"), "{output}"); + assert!(output.contains("capture group 1 is required"), "{output}"); + assert!(output.contains("exit: 1"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_rejects_match_without_participating_capture() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_pattern(temp_dir.path(), r"(?:PLAIN|\[\[([A-Z][A-Z0-9-]*)\]\])")?; + write_main_rs(temp_dir.path(), "PLAIN\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0501]"), "{output}"); + assert!( + output.contains("capture group 1 did not participate"), + "{output}" + ); + assert!(output.contains("src/main.rs:1:1"), "{output}"); + assert!(output.contains(" 0 references found"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_rejects_empty_capture() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_pattern(temp_dir.path(), r"\[\[([^]]*)\]\]")?; + write_main_rs(temp_dir.path(), "[[]]\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0501]"), "{output}"); + assert!( + output.contains("capture group 1 matched an empty target"), + "{output}" + ); + assert!(output.contains("src/main.rs:1:3"), "{output}"); + assert!(output.contains(" 0 references found"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_reports_one_based_byte_column() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + write_main_rs(temp_dir.path(), "// é [[RFC-9999]]\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("src/main.rs:1:9"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_orders_diagnostics_by_path_and_position() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + fs::write(temp_dir.path().join("z.rs"), "[[RFC-9003]]\n")?; + fs::write(temp_dir.path().join("a.rs"), "[[RFC-9001]]\n[[RFC-9002]]\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + let first = output.find("a.rs:1:3").ok_or("missing first location")?; + let second = output.find("a.rs:2:3").ok_or("missing second location")?; + let third = output.find("z.rs:1:3").ok_or("missing third location")?; + assert!(first < second && second < third, "{output}"); + Ok(()) +} + #[test] fn test_scan_unknown_clause_reference() -> common::TestResult { let (temp_dir, date) = init_source_scan_project()?; @@ -247,3 +413,250 @@ fn test_scan_mixed_valid_invalid_references() -> common::TestResult { assert_scan_check_snapshot!(temp_dir, &date) } + +#[test] +fn test_scan_govignore_reincludes_gitignored_file_and_parent() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + fs::write(temp_dir.path().join(".gitignore"), "ignored/\n")?; + fs::write( + temp_dir.path().join(".govignore"), + "!ignored/\n!ignored/keep.rs\n", + )?; + fs::create_dir(temp_dir.path().join("ignored"))?; + fs::write(temp_dir.path().join("ignored/keep.rs"), "fn keep() {}\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 1 source files scanned"), "{output}"); + assert!(!output.contains("exit: 1"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_deeper_govignore_overrides_root_rule() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + write_main_rs(temp_dir.path(), "fn main() {}\n")?; + fs::write(temp_dir.path().join(".govignore"), "src/*.rs\n")?; + fs::write(temp_dir.path().join("src/.govignore"), "!main.rs\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 1 source files scanned"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_last_matching_rule_wins_within_one_ignore_file() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + write_main_rs(temp_dir.path(), "fn main() {}\n")?; + fs::write( + temp_dir.path().join(".govignore"), + "src/*.rs\n!src/main.rs\n", + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 1 source files scanned"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_deeper_gitignore_overrides_root_rule() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + write_main_rs(temp_dir.path(), "fn main() {}\n")?; + fs::write(temp_dir.path().join(".gitignore"), "src/*.rs\n")?; + fs::write(temp_dir.path().join("src/.gitignore"), "!main.rs\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 1 source files scanned"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_root_govignore_overrides_deeper_gitignore() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + fs::write( + temp_dir.path().join(".govignore"), + "!nested/\n!nested/keep.rs\n", + )?; + let nested = temp_dir.path().join("nested"); + fs::create_dir(&nested)?; + fs::write(nested.join(".gitignore"), "keep.rs\n")?; + fs::write(nested.join("keep.rs"), "fn keep() {}\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 1 source files scanned"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_deeper_govignore_overrides_root_gitignore() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + fs::write( + temp_dir.path().join(".gitignore"), + "!nested/\n!nested/skip.rs\n", + )?; + let nested = temp_dir.path().join("nested"); + fs::create_dir(&nested)?; + fs::write(nested.join(".govignore"), "skip.rs\n")?; + fs::write(nested.join("skip.rs"), "fn skip() {}\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 0 source files scanned"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_prunes_ignored_directory_before_nested_ignore_file() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + fs::write(temp_dir.path().join(".govignore"), "excluded/\n")?; + let excluded = temp_dir.path().join("excluded"); + fs::create_dir(&excluded)?; + fs::write(excluded.join("hidden.rs"), "fn hidden() {}\n")?; + fs::create_dir(excluded.join(".govignore"))?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 0 source files scanned"), "{output}"); + assert!(!output.contains("Source scan traversal failed"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_prunes_git_metadata_directory() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + write_main_rs(temp_dir.path(), "fn main() {}\n")?; + let git_objects = temp_dir.path().join(".git/objects"); + fs::create_dir_all(&git_objects)?; + fs::write(git_objects.join("metadata.rs"), "fn metadata() {}\n")?; + fs::create_dir(git_objects.join(".govignore"))?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 1 source files scanned"), "{output}"); + assert!(!output.contains("Source scan traversal failed"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_reports_reached_invalid_ignore_file() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + fs::write(temp_dir.path().join(".govignore"), "[z-a]\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0501]"), "{output}"); + assert!( + output.contains("Invalid source scan ignore rule"), + "{output}" + ); + assert!(output.contains("exit: 1"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_reports_reached_unreadable_ignore_path() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + fs::create_dir(temp_dir.path().join(".govignore"))?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0901]"), "{output}"); + assert!(output.contains(".govignore"), "{output}"); + assert!(output.contains("exit: 1"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_reports_selected_source_decode_failure() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + let src_dir = temp_dir.path().join("src"); + fs::create_dir_all(&src_dir)?; + fs::write(src_dir.join("invalid.rs"), [0xff, 0xfe])?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0901]"), "{output}"); + assert!(output.contains("read selected source file"), "{output}"); + assert!(output.contains("src/invalid.rs"), "{output}"); + assert!(output.contains("exit: 1"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_rejects_invalid_positive_include_pattern() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["src/[z-a].rs"])?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0501]"), "{output}"); + assert!(output.contains("source_scan.include"), "{output}"); + assert!(output.contains("exit: 1"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_rejects_negated_positive_include_pattern() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["!src/main.rs"])?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0501]"), "{output}"); + assert!(output.contains("leading '!' is not allowed"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_applies_gitignore_style_positive_include_forms() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include( + temp_dir.path(), + &["/root.rs", "nested.rs", "#hash.rs", "selected/"], + )?; + fs::write(temp_dir.path().join("root.rs"), "fn root() {}\n")?; + fs::write(temp_dir.path().join("#hash.rs"), "fn hash() {}\n")?; + fs::create_dir_all(temp_dir.path().join("deep"))?; + fs::write(temp_dir.path().join("deep/nested.rs"), "fn nested() {}\n")?; + fs::write(temp_dir.path().join("deep/root.rs"), "fn not_root() {}\n")?; + fs::create_dir_all(temp_dir.path().join("selected/deeper"))?; + fs::write( + temp_dir.path().join("selected/deeper/data.txt"), + "selected\n", + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 4 source files scanned"), "{output}"); + Ok(()) +} + +#[test] +fn test_scan_does_not_use_git_info_exclude_or_hidden_filter() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + fs::create_dir_all(temp_dir.path().join(".git/info"))?; + fs::write(temp_dir.path().join(".git/info/exclude"), "src/\n")?; + fs::write(temp_dir.path().join(".ignore"), "src/\n")?; + fs::write(temp_dir.path().join(".hidden.rs"), "fn hidden() {}\n")?; + write_main_rs(temp_dir.path(), "fn main() {}\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 2 source files scanned"), "{output}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn test_scan_does_not_follow_symbolic_links() -> common::TestResult { + use std::os::unix::fs::symlink; + + let (temp_dir, _) = init_source_scan_project()?; + set_source_scan_include(temp_dir.path(), &["**/*.rs"])?; + let external = tempfile::tempdir()?; + fs::write(external.path().join("linked.rs"), "fn linked() {}\n")?; + symlink(external.path(), temp_dir.path().join("linked"))?; + write_main_rs(temp_dir.path(), "fn main() {}\n")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains(" 1 source files scanned"), "{output}"); + Ok(()) +}