diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..271114d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,39 @@ +# Normalize line endings so a Windows checkout cannot commit CRLF into files a +# Linux runner executes or diffs (scripts/deploy.sh fails on `\r`). +# +# `text=auto` is the safety net for any file type not listed below; the +# explicit entries force LF in the index AND in the working tree, whatever a +# contributor's core.autocrlf says. +* text=auto + +# Source and docs the issue requires to be LF. +*.sh text eol=lf +*.rs text eol=lf +*.ts text eol=lf +*.md text eol=lf + +# Everything else this repo executes or parses. A CR breaks make recipes, shell +# shebangs and YAML/TOML just as it breaks the files above. +*.cjs text eol=lf +*.mjs text eol=lf +*.js text eol=lf +*.json text eol=lf +*.toml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +Makefile text eol=lf +.gitattributes text eol=lf +.gitignore text eol=lf + +# Lockfiles are generated by cargo / npm. GitHub collapses their diffs and +# excludes them from language statistics. +Cargo.lock text eol=lf linguist-generated=true +package-lock.json text eol=lf linguist-generated=true +sdk/package-lock.json text eol=lf linguist-generated=true + +# Never touch binary payloads. +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.wasm binary diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..911703c --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,44 @@ +name: Commitlint + +# Lints every commit a pull request adds against commitlint.config.cjs, which +# implements the "Commit Style" section of CONTRIBUTING.md. +on: + pull_request: + branches: [main, develop] + +permissions: + contents: read + +jobs: + commitlint: + name: Conventional Commits + runs-on: ubuntu-latest + # Dependabot titles its own commits ("Bump x from 1 to 2"). + if: github.actor != 'dependabot[bot]' + steps: + - uses: actions/checkout@v7 + with: + # The range base..head needs the PR's history, not a shallow clone. + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: sdk/package-lock.json + + - name: Install dependencies + working-directory: sdk + run: npm ci + + - name: Lint pull request commits + working-directory: sdk + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + npx --no-install commitlint \ + --config ../commitlint.config.cjs \ + --from "$BASE_SHA" \ + --to "$HEAD_SHA" \ + --verbose diff --git a/.github/workflows/sdk-ci.yml b/.github/workflows/sdk-ci.yml index 9c397ab..511a953 100644 --- a/.github/workflows/sdk-ci.yml +++ b/.github/workflows/sdk-ci.yml @@ -7,6 +7,15 @@ on: - "sdk/**" - "contracts/oracle-adapter/**" - "scripts/generate-oracle-parity-table.cjs" + - "scripts/pre-commit-eslint.sh" + - "scripts/check-unused-deps.sh" + - "**/Cargo.toml" + - ".gitattributes" + - ".pre-commit-config.yaml" + - "commitlint.config.cjs" + - "CONTRIBUTING.md" + - ".github/workflows/commitlint.yml" + - ".github/workflows/unused-deps.yml" - ".github/workflows/sdk-ci.yml" pull_request: branches: [main, develop] @@ -14,6 +23,16 @@ on: - "sdk/**" - "contracts/oracle-adapter/**" - "scripts/generate-oracle-parity-table.cjs" + - "scripts/pre-commit-eslint.sh" + - "scripts/check-unused-deps.sh" + - "**/Cargo.toml" + - ".gitattributes" + - ".pre-commit-config.yaml" + - "commitlint.config.cjs" + - "CONTRIBUTING.md" + - ".github/workflows/commitlint.yml" + - ".github/workflows/unused-deps.yml" + - ".github/workflows/sdk-ci.yml" jobs: oracle-parity: @@ -63,6 +82,10 @@ jobs: working-directory: sdk run: npm run typecheck + - name: Lint + working-directory: sdk + run: npm run lint + - name: Run tests working-directory: sdk run: npm test diff --git a/.github/workflows/unused-deps.yml b/.github/workflows/unused-deps.yml new file mode 100644 index 0000000..a94f77c --- /dev/null +++ b/.github/workflows/unused-deps.yml @@ -0,0 +1,55 @@ +name: Unused Dependencies + +on: + schedule: + # Weekly on Mondays at 07:00 UTC. A dependency can become unused without a + # Cargo.toml change (a refactor that drops the last `use`), so the check + # also runs on a clock. + - cron: "0 7 * * 1" + workflow_dispatch: + push: + branches: [main] + paths: &unused-deps-paths + - "contracts/**" + - "tools/**" + - "Cargo.toml" + - "Cargo.lock" + - "scripts/check-unused-deps.sh" + - ".github/workflows/unused-deps.yml" + pull_request: + branches: [main, develop] + paths: *unused-deps-paths + +permissions: + contents: read + +env: + # Pinned so the check is the same build on every run. Keep in step with + # MACHETE_VERSION in scripts/check-unused-deps.sh. + CARGO_MACHETE_VERSION: "0.7.0" + +jobs: + cargo-machete: + name: cargo-machete (unused Rust dependencies) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install Rust toolchain + run: rustup toolchain install && rustc --version + + # Also caches ~/.cargo/bin, so cargo-machete is compiled once, not per run. + - name: Cache cargo registry and binaries + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: ${{ runner.os }}-machete-${{ env.CARGO_MACHETE_VERSION }} + + - name: Install cargo-machete + run: cargo install cargo-machete --locked --version "$CARGO_MACHETE_VERSION" + + # Proves the job can fail: an unused dependency must exit non-zero. + - name: Self-test + run: bash scripts/check-unused-deps.sh --self-test + + - name: Check workspace for unused dependencies + run: bash scripts/check-unused-deps.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..41c0691 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +# Local pre-commit hooks: the same formatting gates CI runs, before a commit +# exists. Install once per clone: +# +# pipx install pre-commit # or: pip install --user pre-commit +# make sdk-install # eslint lives in sdk/node_modules +# make hooks # = pre-commit install +# +# Run against every file instead of only staged ones: +# pre-commit run --all-files +# +# Both hooks are `language: system`: they use the Rust toolchain pinned by +# rust-toolchain.toml and the eslint installed by `npm ci`, so the local check +# and the CI check are the same binaries. +minimum_pre_commit_version: "3.0.0" + +repos: + - repo: local + hooks: + - id: cargo-fmt + name: cargo fmt --check + # cargo fmt formats a whole workspace; passing it individual staged + # paths would skip its edition and config handling. + entry: cargo fmt --all -- --check + language: system + files: \.rs$ + pass_filenames: false + + - id: eslint + name: eslint (sdk) + entry: bash scripts/pre-commit-eslint.sh + language: system + files: ^sdk/src/.*\.ts$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 097b5dc..fe6af8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Repository tooling, no contract or SDK API change (#85, #89, #90, #92): + - `.gitattributes` forces LF for `*.sh`, `*.rs`, `*.ts`, `*.md` and the other files the repo executes or parses, and marks `Cargo.lock` and the npm lockfile as generated, so a Windows checkout can no longer commit CRLF into `scripts/deploy.sh` (#85). + - `.pre-commit-config.yaml` runs `cargo fmt --all -- --check` on staged `.rs` files and eslint on staged `sdk/src` files; install with `make hooks`. The SDK now ships an `eslint.config.mjs` and the eslint dev dependencies its `lint` script always assumed, and `npm run lint` is a step in SDK CI (#89). + - `commitlint.config.cjs` and a `Commitlint` workflow check every pull request commit against the types and scopes in CONTRIBUTING.md; `make commitlint` runs the same check locally (#90). + - A weekly and per-pull-request `Unused Dependencies` workflow runs a pinned `cargo-machete` over the workspace and fails on an unused dependency; `make unused-deps` runs it locally. A manual audit of every workspace manifest against its sources found no unused dependency to remove (#92). + - `rwa-asset`: holder count tracking (#146). `holder_count()` returns the number of addresses currently holding a strictly positive balance. The counter is incremented when a balance crosses 0 → >0 (first receive) and decremented when a balance hits exactly 0 (full burn or full send). `set_holder_cap(cap)` lets the admin set an optional ceiling on the holder count (Reg D style); any `mint`, `transfer`, `transfer_from` or `forced_transfer` that would add a new holder when the count already equals the cap is rejected with `ExceedsHolderCap` (error 25). `holder_count_cap()` returns the configured ceiling or `None`. The new `HolderCapSet` event is emitted by `set_holder_cap`, including when the cap is removed. A property test (`tests/test_holder_count.rs`) verifies the invariant `holder_count == count(addresses with balance > 0)` after every step in a 50-operation sequence of mints, burns, and transfers, both with and without an active cap. - `rwa-asset`: `set_governance`, `propose_cap_increase`/`approve_cap_increase`/`cancel_cap_increase` and `pending_cap_increase`. `update_metadata` still refuses to loosen a supply cap by itself (IR-02); raising one now requires the admin to propose a value and a separately configured `governance` address to approve it, mirroring `propose_admin`/`accept_admin`'s two-step shape. See [ADR-007](docs/architecture/007-governance-controlled-supply-cap.md) for why this is a second address rather than the `governance` contract itself (#148). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b902500..4c55509 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,6 +91,49 @@ make sdk-install make sdk-test ``` +### Git hooks (recommended) + +Formatting failures should never reach CI. `.pre-commit-config.yaml` runs +`cargo fmt --all -- --check` when you stage a `.rs` file and `eslint` when you +stage a file under `sdk/src/`, and rejects the commit if either fails. + +```bash +pipx install pre-commit # or: pip install --user pre-commit +make hooks # npm ci in sdk/, then `pre-commit install` +``` + +Run the hooks on the whole tree with `pre-commit run --all-files`. Skip them +once with `git commit --no-verify`; CI runs the same checks, so a skipped hook +only moves the failure to your pull request. + +### Unused dependencies + +An unused crate grows the WASM and the audit surface. CI runs +[cargo-machete](https://github.com/bnjbvr/cargo-machete) on every pull request +that touches Rust code or a manifest, and weekly on `main` +(`.github/workflows/unused-deps.yml`). + +```bash +cargo install cargo-machete --locked --version 0.7.0 +make unused-deps # fails and lists the crate if a dependency is unused +make unused-deps-selftest # proves the check can fail +``` + +If cargo-machete reports a false positive (a crate used only through a macro it +cannot see), list it under `[package.metadata.cargo-machete] ignored = ["name"]` +in that crate's `Cargo.toml` and say why in the pull request. + +### Line endings + +`.gitattributes` forces LF for `*.sh`, `*.rs`, `*.ts`, `*.md` and the other +files the repo executes or parses, whatever your `core.autocrlf` is. Windows +contributors do not need to configure anything. To check a clone: + +```bash +git ls-files --eol # the i/ column must never say crlf or mixed +git add --renormalize . # re-normalizes files committed before .gitattributes +``` + ### Running a single contract's tests ```bash @@ -193,6 +236,10 @@ make lint # TypeScript — checks src/ and tests/ cd sdk && npm run typecheck +cd sdk && npm run lint # eslint, config in sdk/eslint.config.mjs + +# Unused Rust dependencies +make unused-deps ``` CI will reject PRs that fail formatting or linting. @@ -217,7 +264,26 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): **Types:** `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `perf` -**Scopes:** `rwa-asset`, `registry`, `compliance`, `governance`, `oracle-adapter`, `sdk`, `ci`, `docs` +**Scopes:** `rwa-asset`, `registry`, `compliance`, `governance`, `oracle-adapter`, `sdk`, `ci`, `docs`, `common`, `benchmark`, `readme` + +The scope is optional, but if you give one it must be on this list. Use the +contract's name for a change to that contract, `common` for `contracts/common`, +`benchmark` for `tools/benchmark`, `sdk` for `sdk/`, `ci` for workflows and +tooling, and `docs` or `readme` for documentation. Add `!` before the colon for a +breaking change (`feat(sdk)!: ...`). + +**Enforced:** the `Commitlint` workflow lints every commit in a pull request +against `commitlint.config.cjs` and fails the check on a type or scope outside +these lists, a subject over 72 characters, an empty subject or a trailing full +stop. Check your branch before pushing: + +```bash +make commitlint # lints commits on your branch that are not on origin/main +``` + +Fix a bad message with `git commit --amend` (last commit) or +`git rebase -i origin/main` (older ones), then force-push your branch. Merge +commits and `Revert "..."` commits are ignored. **Examples:** diff --git a/Makefile b/Makefile index 12da6ce..4cbdde9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build test fmt lint clean deploy-testnet help +.PHONY: all build test fmt lint clean deploy-testnet help unused-deps unused-deps-selftest sdk-lint commitlint hooks # ─── Variables ──────────────────────────────────────────────────────────────── NETWORK ?= testnet @@ -30,6 +30,14 @@ lint: @echo "Running Clippy..." $(CARGO) clippy --all-targets --all-features -- -D warnings +# Needs cargo-machete: cargo install cargo-machete --locked --version 0.7.0 +unused-deps: + @echo "Checking for unused Rust dependencies..." + bash scripts/check-unused-deps.sh + +unused-deps-selftest: + bash scripts/check-unused-deps.sh --self-test + clean: $(CARGO) clean rm -rf sdk/dist sdk/node_modules @@ -44,6 +52,19 @@ sdk-build: sdk-install sdk-test: sdk-install cd sdk && npm test +sdk-lint: sdk-install + cd sdk && npm run lint + +# ─── Git hooks and commit style ─────────────────────────────────────────────── +# Installs the pre-commit hooks in .pre-commit-config.yaml (cargo fmt, eslint). +# Needs the pre-commit tool: pipx install pre-commit +hooks: sdk-install + pre-commit install + +# Lints the commits on this branch that are not on origin/main, as CI does. +commitlint: sdk-install + cd sdk && npx --no-install commitlint --config ../commitlint.config.cjs --from origin/main --to HEAD --verbose + # ─── Deployment ─────────────────────────────────────────────────────────────── # scripts/deploy.sh builds, deploys all four contracts with their constructor # arguments, and writes deployed-contracts.json. Deploying one wasm here with no @@ -62,8 +83,12 @@ help: @echo " make test Run all Rust contract tests" @echo " make fmt Format Rust source code" @echo " make lint Run Clippy linter" + @echo " make unused-deps Fail on unused Rust dependencies (cargo-machete)" @echo " make clean Remove build artifacts" @echo " make sdk-build Build the TypeScript SDK" @echo " make sdk-test Run SDK unit tests" + @echo " make sdk-lint Run eslint on the SDK" + @echo " make hooks Install the pre-commit hooks (cargo fmt, eslint)" + @echo " make commitlint Lint this branch's commit messages against origin/main" @echo " make deploy-testnet Deploy all four contracts to Stellar testnet" @echo "" diff --git a/commitlint.config.cjs b/commitlint.config.cjs new file mode 100644 index 0000000..eb82cd7 --- /dev/null +++ b/commitlint.config.cjs @@ -0,0 +1,46 @@ +// Enforces the commit style documented in CONTRIBUTING.md ("Commit Style"). +// Self-contained on purpose: no `extends`, so it resolves the same way from the +// repo root, from sdk/ (where the CLI is installed) and inside CI. +// +// When you change the type or scope lists, update CONTRIBUTING.md too; a test +// in sdk/tests/tooling/commitlint.test.ts fails if the two drift apart. + +const types = ["feat", "fix", "docs", "test", "refactor", "chore", "perf"]; + +const scopes = [ + // Documented in CONTRIBUTING.md + "rwa-asset", + "registry", + "compliance", + "governance", + "oracle-adapter", + "sdk", + "ci", + "docs", + // Parts of the repo that are not one of the contracts above + "common", + "benchmark", + "readme", +]; + +module.exports = { + // `type(scope)!: subject`, the `!` marks a breaking change. + parserPreset: { + parserOpts: { + headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/, + headerCorrespondence: ["type", "scope", "subject"], + }, + }, + rules: { + "type-enum": [2, "always", types], + "type-case": [2, "always", "lower-case"], + "type-empty": [2, "never"], + "scope-enum": [2, "always", scopes], + "scope-case": [2, "always", "kebab-case"], + "subject-empty": [2, "never"], + "subject-full-stop": [2, "never", "."], + "header-max-length": [2, "always", 72], + "body-leading-blank": [2, "always"], + "footer-leading-blank": [2, "always"], + }, +}; diff --git a/scripts/check-unused-deps.sh b/scripts/check-unused-deps.sh new file mode 100644 index 0000000..4ec221e --- /dev/null +++ b/scripts/check-unused-deps.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Fails if any crate in the workspace declares a dependency it never uses. +# +# scripts/check-unused-deps.sh scan the workspace +# scripts/check-unused-deps.sh --self-test prove the scan can fail (and pass) +# +# Uses cargo-machete: it reads Cargo.toml and greps the sources, so it needs no +# nightly toolchain and no build. The version is pinned in +# .github/workflows/unused-deps.yml; keep MACHETE_VERSION below in step with it. +set -euo pipefail + +MACHETE_VERSION="0.7.0" + +if ! command -v cargo-machete >/dev/null 2>&1; then + echo "cargo-machete is not installed. Install it with:" >&2 + echo " cargo install cargo-machete --locked --version ${MACHETE_VERSION}" >&2 + exit 2 +fi + +if [ "${1:-}" = "--self-test" ]; then + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + mkdir -p "$tmp/clean/src" "$tmp/dirty/src" + for crate in clean dirty; do + cat > "$tmp/$crate/Cargo.toml" < "$tmp/clean/src/lib.rs" + printf '// serde is declared but never used\n' > "$tmp/dirty/src/lib.rs" + + cargo machete "$tmp/clean" >/dev/null + echo "self-test: a crate that uses its dependency passes" + + if cargo machete "$tmp/dirty" >/dev/null 2>&1; then + echo "self-test FAILED: an unused dependency was not detected" >&2 + exit 1 + fi + echo "self-test: a crate with an unused dependency fails" + exit 0 +fi + +cd "$(git rev-parse --show-toplevel)" +exec cargo machete diff --git a/scripts/pre-commit-eslint.sh b/scripts/pre-commit-eslint.sh new file mode 100644 index 0000000..40eb11c --- /dev/null +++ b/scripts/pre-commit-eslint.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Runs the SDK's eslint on the staged files pre-commit hands us. +# +# pre-commit passes repo-root-relative paths (sdk/src/x.ts); eslint must run +# from sdk/ so it finds sdk/eslint.config.mjs, so the prefix is stripped. +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +cd "$root/sdk" + +eslint_bin="node_modules/.bin/eslint" +if [ ! -e "$eslint_bin" ]; then + echo "eslint is not installed. Run 'make sdk-install' (npm ci in sdk/) first." >&2 + exit 2 +fi + +files=() +for f in "$@"; do + files+=("${f#sdk/}") +done + +if [ "${#files[@]}" -eq 0 ]; then + exit 0 +fi + +exec "$eslint_bin" "${files[@]}" diff --git a/sdk/eslint.config.mjs b/sdk/eslint.config.mjs new file mode 100644 index 0000000..d68db30 --- /dev/null +++ b/sdk/eslint.config.mjs @@ -0,0 +1,20 @@ +// ESLint flat config for the SDK. `npm run lint` and the pre-commit hook +// (scripts/pre-commit-eslint.sh) both read this file. +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default [ + { ignores: ["dist/**", "node_modules/**", "coverage/**"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.ts"], + rules: { + // A leading underscore marks a parameter or binding as intentionally unused. + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrors: "none" }, + ], + }, + }, +]; diff --git a/sdk/package.json b/sdk/package.json index 6472753..1cf0371 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -29,7 +29,7 @@ "test:watch": "vitest", "test:smoke": "vitest run tests/smoke.testnet.test.ts", "test:smoke:write": "vitest run tests/smoke.write.testnet.test.ts", - "lint": "eslint src --ext .ts", + "lint": "eslint src", "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json", "prepack": "cp ../LICENSE ./LICENSE" }, @@ -50,9 +50,13 @@ "@stellar/stellar-sdk": "^17.0.1" }, "devDependencies": { + "@commitlint/cli": "^19.6.0", + "@eslint/js": "^9.17.0", "@types/node": "^26.5.0", + "eslint": "^9.17.0", "tsup": "^8.0.0", "typescript": "^5.7.0", + "typescript-eslint": "^8.18.0", "vitest": "^5.0.0" }, "engines": { diff --git a/sdk/tests/tooling/commitlint.test.ts b/sdk/tests/tooling/commitlint.test.ts new file mode 100644 index 0000000..0b008d2 --- /dev/null +++ b/sdk/tests/tooling/commitlint.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { readRepoFile, repoRoot, run } from "./helpers.js"; + +const configPath = join(repoRoot, "commitlint.config.cjs"); + +/** Path of the commitlint CLI installed in sdk/ (it is a devDependency there). */ +function cliPath(): string { + const pkgDir = join(repoRoot, "sdk", "node_modules", "@commitlint", "cli"); + const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")) as { + bin: string | Record; + }; + const bin = typeof pkg.bin === "string" ? pkg.bin : (pkg.bin["commitlint"] ?? ""); + return join(pkgDir, bin); +} + +const cli = cliPath(); + +/** Lints one commit message exactly as CI and `make commitlint` do. */ +function lint(message: string): { ok: boolean; output: string } { + const result = run(process.execPath, [cli, "--config", configPath], { input: message }); + return { ok: result.status === 0, output: result.output }; +} + +describe("commitlint accepts conforming commits", () => { + // The examples in CONTRIBUTING.md "Commit Style". + it.each([ + "feat(rwa-asset): add transfer_from with allowance deduction", + "fix(compliance): correct expiry check direction (> not <)", + "docs(readme): add SDK quick-start example", + "test(rwa-asset): add pause/unpause integration tests", + "chore(ci): cache cargo registry for faster builds", + ])("%s", (message) => { + const result = lint(message); + expect(result.ok, result.output).toBe(true); + }); + + it.each(["refactor", "perf"])("allows the %s type", (type) => { + expect(lint(`${type}(sdk): tidy the client`).ok).toBe(true); + }); + + it("allows a commit with no scope", () => { + expect(lint("fix: handle an empty holder list").ok).toBe(true); + }); + + it("allows a breaking-change marker", () => { + expect(lint("feat(sdk)!: rename the client constructor option").ok).toBe(true); + }); + + it("allows a body and a footer separated by blank lines", () => { + const message = [ + "fix(governance): reject a zero voting period", + "", + "A zero period closed the vote in the ledger it opened.", + "", + "Closes #123", + ].join("\n"); + const result = lint(message); + expect(result.ok, result.output).toBe(true); + }); + + it("ignores a merge commit", () => { + expect(lint("Merge pull request #12 from someone/branch").ok).toBe(true); + }); +}); + +describe("commitlint rejects non-conforming commits", () => { + it.each([ + ["an unknown type", "update(sdk): add a thing"], + ["a type that is not lower case", "Feat(sdk): add a thing"], + ["no type at all", "added a thing to the sdk"], + ["a missing colon", "feat(sdk) add a thing"], + ["an unknown scope", "feat(frontend): add a thing"], + ["an empty subject", "feat(sdk): "], + ["a trailing full stop", "feat(sdk): add a thing."], + ["a subject over 72 characters", `feat(sdk): ${"x".repeat(70)}`], + ["a body with no blank line before it", "feat(sdk): add a thing\nbody starts too soon"], + ])("%s", (_reason, message) => { + const result = lint(message); + expect(result.ok, `expected a failure for ${JSON.stringify(message)}`).toBe(false); + expect(result.output).toMatch(/✖|problems/); + }); + + it("names the broken rule so the author can fix it", () => { + const result = lint("feat(frontend): add a thing"); + expect(result.output).toContain("scope-enum"); + }); +}); + +describe("commitlint config and CONTRIBUTING.md agree", () => { + const config = createRequire(import.meta.url)(configPath) as { + rules: Record; + }; + + /** The backticked words on the `**Label:**` line of CONTRIBUTING.md. */ + function documented(label: string): string[] { + const line = readRepoFile("CONTRIBUTING.md") + .split("\n") + .find((l) => l.startsWith(`**${label}:**`)); + expect(line, `CONTRIBUTING.md has no **${label}:** line`).toBeDefined(); + return [...(line ?? "").matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? ""); + } + + it("lists the same types", () => { + const configured = config.rules["type-enum"]?.[2] as string[]; + expect([...documented("Types")].sort()).toEqual([...configured].sort()); + }); + + it("lists the same scopes", () => { + const configured = config.rules["scope-enum"]?.[2] as string[]; + expect([...documented("Scopes")].sort()).toEqual([...configured].sort()); + }); +}); diff --git a/sdk/tests/tooling/gitattributes.test.ts b/sdk/tests/tooling/gitattributes.test.ts new file mode 100644 index 0000000..922530c --- /dev/null +++ b/sdk/tests/tooling/gitattributes.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { devNull, tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot, run } from "./helpers.js"; + +// Keep the user's global git config (core.autocrlf in particular) out of the +// experiments, so a result never depends on the machine running the test. +const isolatedEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_SYSTEM: devNull, + GIT_CONFIG_NOSYSTEM: "1", +}; + +/** `git check-attr` for several attributes of several paths -> { path: { attr: value } }. */ +function checkAttr(attrs: string[], paths: string[]): Record> { + const out = run("git", ["check-attr", ...attrs, "--", ...paths], { env: isolatedEnv }); + expect(out.status, out.output).toBe(0); + const parsed: Record> = {}; + for (const line of out.stdout.split("\n")) { + const m = /^(.+): (\S+): (.*)$/.exec(line.trim()); + if (!m) continue; + const [, path = "", attr = "", value = ""] = m; + (parsed[path] ??= {})[attr] = value; + } + return parsed; +} + +describe(".gitattributes in this repository", () => { + const lfPaths = [ + "scripts/deploy.sh", + "contracts/rwa-asset/src/lib.rs", + "sdk/src/index.ts", + "README.md", + ]; + + it("forces LF for *.sh, *.rs, *.ts and *.md", () => { + const attrs = checkAttr(["text", "eol"], lfPaths); + for (const path of lfPaths) { + expect(attrs[path]?.["eol"], path).toBe("lf"); + expect(attrs[path]?.["text"], path).toBe("set"); + } + }); + + it("forces LF for the other files the repo executes or parses", () => { + const paths = ["Makefile", ".github/workflows/sdk-ci.yml", "Cargo.toml", "sdk/package.json"]; + const attrs = checkAttr(["eol"], paths); + for (const path of paths) expect(attrs[path]?.["eol"], path).toBe("lf"); + }); + + it("marks Cargo.lock and the npm lockfile as generated", () => { + const paths = ["Cargo.lock", "sdk/package-lock.json"]; + const attrs = checkAttr(["linguist-generated"], paths); + for (const path of paths) expect(attrs[path]?.["linguist-generated"], path).toBe("true"); + }); + + it("does not mark hand-written source as generated", () => { + const attrs = checkAttr(["linguist-generated"], ["sdk/src/index.ts", "Cargo.toml"]); + expect(attrs["sdk/src/index.ts"]?.["linguist-generated"]).toBe("unspecified"); + expect(attrs["Cargo.toml"]?.["linguist-generated"]).toBe("unspecified"); + }); + + it("leaves no CRLF and no mixed endings in any tracked text file", () => { + const out = run("git", ["ls-files", "--eol"], { env: isolatedEnv }); + expect(out.status, out.output).toBe(0); + const bad = out.stdout.split("\n").filter((line) => /^i\/(crlf|mixed)\b/.test(line)); + expect(bad).toEqual([]); + }); +}); + +describe(".gitattributes normalizes line endings on `git add`", () => { + let dir = ""; + + const git = (...args: string[]) => run("git", args, { cwd: dir, env: isolatedEnv }); + + /** The index line ending git reports for a file: lf, crlf, mixed or -text. */ + const indexEol = (file: string): string => { + const out = git("ls-files", "--eol", "--", file); + return /^i\/(\S+)/.exec(out.stdout)?.[1] ?? `no output: ${out.output}`; + }; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "gitattributes-")); + expect(git("init", "-q").status).toBe(0); + // Windows default; the whole point is to hold even then. + expect(git("config", "core.autocrlf", "false").status).toBe(0); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it.each(["deploy.sh", "lib.rs", "index.ts", "README.md"])("commits %s with LF", (name) => { + copyFileSync(join(repoRoot, ".gitattributes"), join(dir, ".gitattributes")); + writeFileSync(join(dir, name), "line one\r\nline two\r\n"); + expect(git("add", "--", ".gitattributes", name).status).toBe(0); + expect(indexEol(name)).toBe("lf"); + }); + + it("commits a file that already had LF unchanged", () => { + copyFileSync(join(repoRoot, ".gitattributes"), join(dir, ".gitattributes")); + writeFileSync(join(dir, "ok.sh"), "echo hi\n"); + git("add", "--", ".gitattributes", "ok.sh"); + expect(indexEol("ok.sh")).toBe("lf"); + }); + + it("does not rewrite binary files", () => { + copyFileSync(join(repoRoot, ".gitattributes"), join(dir, ".gitattributes")); + writeFileSync(join(dir, "logo.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + git("add", "--", ".gitattributes", "logo.png"); + expect(indexEol("logo.png")).toBe("-text"); + }); + + it("control: without .gitattributes the same CRLF script stays CRLF", () => { + writeFileSync(join(dir, "deploy.sh"), "line one\r\nline two\r\n"); + git("add", "--", "deploy.sh"); + expect(indexEol("deploy.sh")).toBe("crlf"); + }); +}); diff --git a/sdk/tests/tooling/helpers.ts b/sdk/tests/tooling/helpers.ts new file mode 100644 index 0000000..29e25d9 --- /dev/null +++ b/sdk/tests/tooling/helpers.ts @@ -0,0 +1,39 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Repository root: sdk/tests/tooling -> three levels up. */ +export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); + +/** Reads a repo file as text with CRLF folded to LF, so checks pass on any checkout. */ +export function readRepoFile(relativePath: string): string { + return readFileSync(join(repoRoot, relativePath), "utf8").replace(/\r\n/g, "\n"); +} + +export interface RunResult { + status: number | null; + stdout: string; + stderr: string; + output: string; +} + +/** Runs a command to completion and never throws on a non-zero exit. */ +export function run( + command: string, + args: string[], + options: { cwd?: string; input?: string; env?: NodeJS.ProcessEnv } = {}, +): RunResult { + const result = spawnSync(command, args, { + cwd: options.cwd ?? repoRoot, + encoding: "utf8", + ...(options.input !== undefined ? { input: options.input } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), + }); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + return { status: result.status, stdout, stderr, output: `${stdout}${stderr}` }; +} + +/** True when `bash` can be started (it cannot on a bare Windows shell). */ +export const hasBash = run("bash", ["-c", "true"]).status === 0; diff --git a/sdk/tests/tooling/pre-commit.test.ts b/sdk/tests/tooling/pre-commit.test.ts new file mode 100644 index 0000000..3f1e826 --- /dev/null +++ b/sdk/tests/tooling/pre-commit.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { hasBash, readRepoFile, repoRoot, run } from "./helpers.js"; + +const config = readRepoFile(".pre-commit-config.yaml"); + +/** The text of one hook block: from its `- id:` line to the next hook or the end. */ +function hookBlock(id: string): string { + const start = config.indexOf(`- id: ${id}`); + expect(start, `no hook with id ${id}`).toBeGreaterThanOrEqual(0); + const rest = config.slice(start + 1); + const next = rest.search(/\n\s*- id: /); + return next === -1 ? rest : rest.slice(0, next); +} + +/** The value of a `key:` line inside a hook block. */ +function field(block: string, key: string): string { + const m = new RegExp(`^\\s*${key}:\\s*(.+?)\\s*$`, "m").exec(block); + expect(m, `hook has no ${key}`).not.toBeNull(); + return m?.[1] ?? ""; +} + +describe(".pre-commit-config.yaml", () => { + it("runs cargo fmt in check mode over the whole workspace", () => { + const block = hookBlock("cargo-fmt"); + expect(field(block, "entry")).toBe("cargo fmt --all -- --check"); + expect(field(block, "language")).toBe("system"); + // cargo fmt takes no file list; handing it staged paths would be wrong. + expect(field(block, "pass_filenames")).toBe("false"); + }); + + it("runs the cargo fmt hook only when a Rust file is staged", () => { + const files = new RegExp(field(hookBlock("cargo-fmt"), "files")); + expect(files.test("contracts/rwa-asset/src/lib.rs")).toBe(true); + expect(files.test("tools/benchmark/src/main.rs")).toBe(true); + expect(files.test("sdk/src/index.ts")).toBe(false); + expect(files.test("README.md")).toBe(false); + }); + + it("runs eslint through the staged-files wrapper script", () => { + const block = hookBlock("eslint"); + expect(field(block, "entry")).toBe("bash scripts/pre-commit-eslint.sh"); + expect(field(block, "language")).toBe("system"); + }); + + it("runs the eslint hook only for SDK source files", () => { + const files = new RegExp(field(hookBlock("eslint"), "files")); + expect(files.test("sdk/src/utils.ts")).toBe(true); + expect(files.test("sdk/tests/utils.test.ts")).toBe(false); + expect(files.test("contracts/rwa-asset/src/lib.rs")).toBe(false); + expect(files.test("sdk/src/notes.md")).toBe(false); + }); +}); + +// The wrapper is what the eslint hook actually executes, so exercise it for real. +describe.skipIf(!hasBash)("scripts/pre-commit-eslint.sh", () => { + let dir = ""; + + beforeEach(() => { + // Inside sdk/ so the file falls under sdk/eslint.config.mjs; removed afterwards. + dir = mkdtempSync(join(repoRoot, "sdk", "tests", "tooling", "tmp-eslint-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + /** Runs the hook exactly as pre-commit does: repo-relative, forward-slash path. */ + function hook(...files: string[]) { + const paths = files.map((f) => relative(repoRoot, f).split("\\").join("/")); + return run("bash", ["scripts/pre-commit-eslint.sh", ...paths]); + } + + it("accepts a clean file", () => { + const file = join(dir, "clean.ts"); + writeFileSync(file, "export const answer = 42;\n"); + const result = hook(file); + expect(result.status, result.output).toBe(0); + }); + + it("rejects a file with lint errors", () => { + const file = join(dir, "bad.ts"); + writeFileSync(file, "var answer: any = 42;\nexport { answer };\n"); + const result = hook(file); + expect(result.status, result.output).not.toBe(0); + expect(result.output).toMatch(/no-var|no-explicit-any/); + }); + + it("fails the whole commit when only one of several files is bad", () => { + const good = join(dir, "good.ts"); + const bad = join(dir, "bad.ts"); + writeFileSync(good, "export const ok = true;\n"); + writeFileSync(bad, "export const x: any = 1;\n"); + expect(hook(good, bad).status).not.toBe(0); + }); + + it("does nothing, successfully, when no files are passed", () => { + const result = run("bash", ["scripts/pre-commit-eslint.sh"]); + expect(result.status, result.output).toBe(0); + }); +}); diff --git a/sdk/tests/tooling/unused-deps.test.ts b/sdk/tests/tooling/unused-deps.test.ts new file mode 100644 index 0000000..e0b68f0 --- /dev/null +++ b/sdk/tests/tooling/unused-deps.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { hasBash, readRepoFile, repoRoot, run } from "./helpers.js"; + +/** + * A small offline stand-in for cargo-machete: a dependency counts as used when + * some `.rs` file of the crate mentions it as `name::` or `use name`. It is + * stricter than a bare word search, looser than the compiler. It exists so + * `npm test` can guard the manifests on a machine without cargo-machete; the + * real check is the `Unused Dependencies` workflow. + */ +function findUnusedDependencies(crateDir: string): string[] { + const manifest = readFileSync(join(crateDir, "Cargo.toml"), "utf8").replace(/\r\n/g, "\n"); + const declared = new Set(); + let inDependencies = false; + for (const line of manifest.split("\n")) { + const section = /^\s*\[(.+)\]\s*$/.exec(line); + if (section) { + inDependencies = /^(dev-|build-)?dependencies$/.test(section[1] ?? ""); + continue; + } + const entry = /^([A-Za-z0-9_-]+)\s*=/.exec(line); + if (inDependencies && entry) declared.add(entry[1] ?? ""); + } + + const sources = readdirSync(crateDir, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith(".rs")) + .filter((e) => !e.parentPath.split(/[\\/]/).includes("target")) + .map((e) => readFileSync(join(e.parentPath, e.name), "utf8")) + .join("\n"); + + return [...declared].filter((name) => { + const ident = name.replace(/-/g, "_"); + return !new RegExp(`(^|[^\\w])${ident}\\s*::|\\buse\\s+${ident}\\b|extern\\s+crate\\s+${ident}\\b`).test(sources); + }); +} + +/** Directories of every workspace member listed in the root Cargo.toml. */ +function workspaceMembers(): string[] { + const root = readRepoFile("Cargo.toml"); + const block = /members\s*=\s*\[([^\]]*)\]/.exec(root)?.[1] ?? ""; + return [...block.matchAll(/"([^"]+)"/g)].map((m) => m[1] ?? ""); +} + +describe("workspace manifests", () => { + it("lists the members this check expects to find", () => { + const members = workspaceMembers(); + expect(members).toContain("contracts/rwa-asset"); + expect(members).toContain("tools/benchmark"); + }); + + it.each(workspaceMembers())("%s declares no unused dependency", (member) => { + expect(findUnusedDependencies(join(repoRoot, member))).toEqual([]); + }); +}); + +describe("findUnusedDependencies", () => { + let dir = ""; + + /** A throwaway crate with the given manifest dependencies and lib.rs. */ + function crate(dependencies: string, source: string, extraSources: Record = {}): string { + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync( + join(dir, "Cargo.toml"), + `[package]\nname = "fixture"\nversion = "0.0.0"\nedition = "2021"\n\n${dependencies}`, + ); + writeFileSync(join(dir, "src", "lib.rs"), source); + for (const [path, text] of Object.entries(extraSources)) { + mkdirSync(join(dir, path, ".."), { recursive: true }); + writeFileSync(join(dir, path), text); + } + return dir; + } + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "unused-deps-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("reports nothing when every dependency is used", () => { + crate('[dependencies]\nserde = "1"\n', "use serde::Serialize;\n"); + expect(findUnusedDependencies(dir)).toEqual([]); + }); + + it("reports a dependency that no source mentions", () => { + crate('[dependencies]\nserde = "1"\nrand = "0.8"\n', "use serde::Serialize;\n"); + expect(findUnusedDependencies(dir)).toEqual(["rand"]); + }); + + it("maps a hyphenated crate name to its underscored identifier", () => { + crate('[dependencies]\nsoroban-sdk = { workspace = true }\n', "use soroban_sdk::Env;\n"); + expect(findUnusedDependencies(dir)).toEqual([]); + }); + + it("counts a dependency used only from tests/", () => { + crate('[dev-dependencies]\ncompliance = { path = "../compliance" }\n', "", { + "tests/it.rs": "use compliance::ComplianceContract;\n", + }); + expect(findUnusedDependencies(dir)).toEqual([]); + }); + + it("reports an unused dev-dependency", () => { + crate('[dev-dependencies]\ncompliance = { path = "../compliance" }\n', "pub fn f() {}\n"); + expect(findUnusedDependencies(dir)).toEqual(["compliance"]); + }); + + it("is not fooled by a dependency name that is only a substring", () => { + crate('[dependencies]\nserde = "1"\n', "use serde_json::Value;\n"); + expect(findUnusedDependencies(dir)).toEqual(["serde"]); + }); + + it("ignores keys outside the dependency tables", () => { + crate('[features]\ntestutils = []\n\n[dependencies]\n', "pub fn f() {}\n"); + expect(findUnusedDependencies(dir)).toEqual([]); + }); +}); + +describe("Unused Dependencies workflow", () => { + const workflow = readRepoFile(".github/workflows/unused-deps.yml"); + + it("runs on a schedule and can be started by hand", () => { + expect(workflow).toMatch(/^\s*schedule:/m); + expect(workflow).toMatch(/^\s*- cron: "(\S+ ){4}\S+"/m); + expect(workflow).toMatch(/^\s*workflow_dispatch:/m); + }); + + it("also gates pull requests and main", () => { + expect(workflow).toMatch(/^\s*pull_request:/m); + expect(workflow).toMatch(/^\s*push:/m); + }); + + it("runs the self-test and the real check", () => { + expect(workflow).toContain("bash scripts/check-unused-deps.sh --self-test"); + expect(workflow).toMatch(/run: bash scripts\/check-unused-deps\.sh\s*$/m); + }); + + it("pins the tool version, and the script, workflow and docs agree on it", () => { + const fromWorkflow = /CARGO_MACHETE_VERSION: "(\d+\.\d+\.\d+)"/.exec(workflow)?.[1]; + const fromScript = /MACHETE_VERSION="(\d+\.\d+\.\d+)"/.exec(readRepoFile("scripts/check-unused-deps.sh"))?.[1]; + const fromDocs = /cargo-machete --locked --version (\d+\.\d+\.\d+)/.exec(readRepoFile("CONTRIBUTING.md"))?.[1]; + const fromMakefile = /--version (\d+\.\d+\.\d+)/.exec(readRepoFile("Makefile"))?.[1]; + expect(fromWorkflow).toBeDefined(); + expect(fromScript).toBe(fromWorkflow); + expect(fromDocs).toBe(fromWorkflow); + expect(fromMakefile).toBe(fromWorkflow); + }); +}); + +describe("new workflows pin their actions", () => { + it.each([".github/workflows/unused-deps.yml", ".github/workflows/commitlint.yml"])("%s", (path) => { + const uses = [...readRepoFile(path).matchAll(/^\s*(?:- )?uses:\s*(\S+)/gm)].map((m) => m[1] ?? ""); + expect(uses.length).toBeGreaterThan(0); + for (const action of uses) { + // owner/repo@vN, owner/repo@vN.N.N or owner/repo@<40-hex sha>; never a branch. + expect(action, action).toMatch(/@(v\d+(\.\d+){0,2}|[0-9a-f]{40})$/); + } + }); +}); + +describe("Commitlint workflow", () => { + const workflow = readRepoFile(".github/workflows/commitlint.yml"); + + it("runs on pull requests and checks the whole PR range", () => { + expect(workflow).toMatch(/^\s*pull_request:/m); + expect(workflow).toContain("fetch-depth: 0"); + expect(workflow).toContain("--from \"$BASE_SHA\""); + expect(workflow).toContain("--to \"$HEAD_SHA\""); + expect(workflow).toContain("commitlint.config.cjs"); + }); + + it("does not splice PR-controlled values into the shell script", () => { + const script = /run: \|([\s\S]*)$/.exec(workflow)?.[1] ?? ""; + expect(script).not.toContain("${{"); + }); +}); + +// With cargo-machete installed, run the real thing too. +const hasMachete = run("cargo-machete", ["--version"]).status === 0; + +describe.skipIf(!hasBash || !hasMachete)("scripts/check-unused-deps.sh (needs cargo-machete)", () => { + it("proves it can pass and fail", () => { + const result = run("bash", ["scripts/check-unused-deps.sh", "--self-test"]); + expect(result.status, result.output).toBe(0); + expect(result.stdout).toContain("passes"); + expect(result.stdout).toContain("fails"); + }); + + it("passes on this workspace", () => { + const result = run("bash", ["scripts/check-unused-deps.sh"]); + expect(result.status, result.output).toBe(0); + }); +});