commit 708390242c175af8952dc1fc55a12f373bf796ba Author: Mihaela Mihaljevic Date: Sun Jun 7 23:20:20 2026 +0200 chore: scaffold PureXML pure-Swift XML package Root Swift package mirroring PureYAML's structure, rules, and verification gates. Includes the XML node model (Node, Element, Attribute, QualifiedName), a working emitter (Serializer) with escaping and pretty/compact options, and structural validation. The parser surface is stable; parse() raises notImplemented until the scanner lands, pinned by a test. - dependency-free, namespaced under PureXML - portability and style gates carried over (style, namespacing, forbidden patterns, swiftformat, swiftlint, linux, wasm, windows CI) - 11 tests passing diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5ddc311 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf + +*.ps1 text eol=crlf diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..8c30b65 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# commit-msg hook: reject style tells in commit messages. +# +# Enforces github-discipline.md Rule 5.1 (no AI attribution) and +# Rule 5.2 (no em dashes U+2014) at the commit boundary. +# +# Install (per repo): git config core.hooksPath .githooks +# +# Portable: uses only BSD-compatible grep (no -P, no \x escapes in patterns). + +set -e + +MSG_FILE="$1" +[ -z "$MSG_FILE" ] && exit 0 +[ ! -f "$MSG_FILE" ] && exit 0 + +# Strip comment lines (lines starting with #) and the diff scissors block. +CONTENT=$(awk ' + /^# ------------------------ >8 ------------------------$/ { exit } + /^[[:space:]]*#/ { next } + { print } +' "$MSG_FILE") + +FAIL=0 + +# Em dash U+2014 = UTF-8 bytes E2 80 94. Build the literal as a variable so the +# script source itself contains no em-dash character. +EMDASH=$(printf '\xe2\x80\x94') +if LC_ALL=C printf '%s' "$CONTENT" | grep -qF -- "$EMDASH"; then + printf 'commit-msg: forbidden em dash (U+2014) in commit message.\n' >&2 + printf ' Replace with a comma, period, colon, semicolon, or restructure.\n' >&2 + printf ' Rule: github-discipline.md Rule 5.2\n' >&2 + printf ' Locations (line numbers in the cleaned message):\n' >&2 + LC_ALL=C printf '%s\n' "$CONTENT" | grep -nF -- "$EMDASH" | sed 's/^/ /' >&2 + FAIL=1 +fi + +# Rule 5.1: literal AI-attribution phrases. +LITERAL_PATTERNS=( + 'Co-Authored-By: Claude' + 'Co-authored-by: Claude' + 'Generated with Claude Code' + 'Generated with Claude' + 'Generated by Claude' + 'with help from Claude' + 'Powered by Claude' + 'powered by Claude' + 'as an AI' + 'As an AI' + 'this commit was generated' + 'This commit was generated' + 'this change was generated' + 'This change was generated' +) +for p in "${LITERAL_PATTERNS[@]}"; do + if printf '%s' "$CONTENT" | grep -qF -- "$p"; then + printf 'commit-msg: forbidden AI-attribution phrase: %s\n' "$p" >&2 + printf ' Rule: github-discipline.md Rule 5.1\n' >&2 + FAIL=1 + fi +done + +# Rule 5.1: AI-tell signature emojis used as flourish at end of line. +# Build emoji byte sequences in a variable so the script source stays clean. +EMOJI_TELLS=$(printf '\xf0\x9f\xa4\x96\n\xe2\x9c\xa8\n\xf0\x9f\xaa\x84\n\xf0\x9f\xa7\xa0\n\xf0\x9f\xa6\xbe\n\xf0\x9f\xa4\x9d') +while IFS= read -r emoji; do + [ -z "$emoji" ] && continue + if LC_ALL=C printf '%s' "$CONTENT" | grep -qF -- "$emoji"; then + printf 'commit-msg: forbidden AI-signature emoji in commit message.\n' >&2 + printf ' Rule: github-discipline.md Rule 5.1\n' >&2 + FAIL=1 + break + fi +done <<<"$EMOJI_TELLS" + +# Rule 5.1: AI-tell preamble phrasings at start of any line. +PREAMBLE_PATTERNS=( + "I'll now" + "Let me " + "Here is a quick summary" + "Here's a quick summary" +) +for p in "${PREAMBLE_PATTERNS[@]}"; do + if printf '%s' "$CONTENT" | grep -qE -- "^${p}"; then + printf 'commit-msg: forbidden AI-tell preamble at line start: %s\n' "$p" >&2 + printf ' Rule: github-discipline.md Rule 5.1\n' >&2 + FAIL=1 + fi +done + +# Rule 5.1: Cursor attribution. Reject Cursor co-author trailers, the agent +# email, any @cursor.com address, and "generated with Cursor" style tells. +# Case-insensitive on purpose: cursoragent@cursor.com, Co-authored-by: Cursor. +CURSOR_LITERALS=( + 'Co-Authored-By: Cursor' + 'Co-authored-by: Cursor' + 'Generated with Cursor' + 'Generated by Cursor' + 'with help from Cursor' + 'Powered by Cursor' +) +for p in "${CURSOR_LITERALS[@]}"; do + if printf '%s' "$CONTENT" | grep -qiF -- "$p"; then + printf 'commit-msg: forbidden Cursor attribution: %s\n' "$p" >&2 + printf ' Rule: github-discipline.md Rule 5.1\n' >&2 + FAIL=1 + fi +done +if printf '%s' "$CONTENT" | grep -qiE -- '@cursor\.com'; then + printf 'commit-msg: forbidden Cursor email (@cursor.com) in commit message.\n' >&2 + printf ' Rule: github-discipline.md Rule 5.1\n' >&2 + FAIL=1 +fi + +# Author / committer identity must not be Cursor. git var resolves the exact +# identity (including --author / GIT_*_IDENT overrides) this commit will record. +for ident in "$(git var GIT_AUTHOR_IDENT 2>/dev/null)" "$(git var GIT_COMMITTER_IDENT 2>/dev/null)"; do + [ -z "$ident" ] && continue + if printf '%s' "$ident" | grep -qiE -- '@cursor\.com|(^|[^[:alpha:]])cursor(agent)?([^[:alpha:]]|$)'; then + printf 'commit-msg: forbidden Cursor identity as author/committer: %s\n' "$ident" >&2 + printf ' Fix: set git config user.name / user.email to your own identity.\n' >&2 + FAIL=1 + fi +done + +exit $FAIL diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..603a28c --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# pre-commit hook: reject style tells in staged file content. +# +# Companion to commit-msg-style-check (which scans the commit message). +# Scans the staged diff for the same forbidden patterns: +# - Em-dash (U+2014). github-discipline.md Rule 5.2. +# - AI-attribution literal phrases. github-discipline.md Rule 5.1. +# - AI-signature emojis used as flourish. +# - AI-tell preamble phrasings at line start. +# +# Scope: only ADDED lines (`^+` in the staged diff, excluding file headers). +# This means existing offending content stays put; only new content is gated. +# A future "sweep" task can clean up legacy content; this hook prevents new +# violations from entering staged commits. +# +# Install (per repo): git config core.hooksPath .githooks +# +# Skip mechanism (for legitimate meta-references that need to quote the +# forbidden tokens, like the rule documentation itself): +# Any line containing `# style-check: allow` is skipped. Use sparingly. +# +# Excluded paths (never scanned): +# - Generated lockfiles: Cargo.lock, Package.resolved, package-lock.json, +# pnpm-lock.yaml, yarn.lock, uv.lock, Pipfile.lock, poetry.lock, Gemfile.lock +# - Binary files (detected via `git diff --numstat` reporting `-` width) +# - Vendored / third-party directories: vendor/, third_party/, node_modules/, +# _archive/python-reference/ (frozen senko snapshot we don't author) +# - The rule files that DEFINE the prohibitions: github-discipline.md and +# similar (would otherwise self-flag for citing the forbidden strings) +# +# Portable: BSD-compatible grep, no PCRE. + +set -e + +# Skip when there's nothing staged (e.g. `git commit --amend` with no index). +if git diff --cached --name-only --diff-filter=ACMR | head -1 | grep -q .; then + : +else + exit 0 +fi + +# Build the list of staged text files that should be scanned. +# `--diff-filter=ACMR` picks Added/Copied/Modified/Renamed; we skip Deleted. +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR) + +# Exclusion path patterns. A staged file path that matches any of these is +# skipped entirely. Match against the path-from-repo-root, no leading slash. +SKIP_PATTERNS=( + # Lockfiles (generated, not human-authored). + 'Cargo\.lock$' + 'Package\.resolved$' + 'package-lock\.json$' + 'pnpm-lock\.yaml$' + 'yarn\.lock$' + 'uv\.lock$' + 'Pipfile\.lock$' + 'poetry\.lock$' + 'Gemfile\.lock$' + 'composer\.lock$' + # Vendored / third-party. + '^vendor/' + '^third_party/' + '^node_modules/' + '^_archive/' + '^Tests/Fixtures/acme/' + '^Tests/Fixtures/real-yaml/' + # Rule files that DEFINE the prohibitions; they MUST be able to cite + # the forbidden strings to document them. + '/github-discipline\.md$' + '/commit-msg-style-check$' + '/pre-commit-style-check$' + # Same hooks installed under the public-kit names (.githooks/commit-msg, + # .githooks/pre-commit) rather than the canonical *-style-check names. + '/commit-msg$' + '/pre-commit$' + '/pre-push$' + '/check-style\.sh$' + '/check-staged-style\.sh$' + # The CI workflow that enforces the same Cursor prohibition server-side; it + # must quote the forbidden tokens to scan for them. + '^\.github/workflows/no-cursor-attribution\.yml$' +) + +# Filter STAGED_FILES through the skip list. +SCAN_FILES="" +while IFS= read -r f; do + [ -z "$f" ] && continue + skipped=0 + for pat in "${SKIP_PATTERNS[@]}"; do + if printf '%s' "$f" | grep -qE -- "$pat"; then + skipped=1 + break + fi + done + [ "$skipped" -eq 1 ] && continue + SCAN_FILES="${SCAN_FILES}${f}"$'\n' +done <<<"$STAGED_FILES" + +# Exit clean if nothing to scan. +if [ -z "${SCAN_FILES//[$'\n\t ']}" ]; then + exit 0 +fi + +# Helper: get the ADDED lines (starting with `+` but not the `+++` file header) +# from the staged diff for a specific file, with their line numbers. +# Output format: `:`. +added_lines_for() { + local file="$1" + # `git diff --cached -U0` minimises context; `--no-color` keeps output clean. + git diff --cached -U0 --no-color -- "$file" \ + | awk ' + /^\+\+\+ / { next } # skip the +++ file header + /^@@ / { + # Parse the hunk header "@@ -a,b +c,d @@" to seed line numbers. + # Find "+c" or "+c,d" and extract c. + match($0, /\+[0-9]+/) + ln = substr($0, RSTART+1, RLENGTH-1) + 0 + next + } + /^\+/ { + # An added line. Print it (without the leading +) with line number. + printf "%d:%s\n", ln, substr($0, 2) + ln++ + } + /^[^+]/ { + # Context or removed line. Context lines are normally suppressed + # by -U0, but advance the counter if any do appear. + if (/^ /) ln++ + } + ' +} + +FAIL=0 +EMDASH=$(printf '\xe2\x80\x94') + +# AI-attribution literal phrases (mirror commit-msg-style-check). +LITERAL_PATTERNS=( + 'Co-Authored-By: Claude' + 'Co-authored-by: Claude' + 'Generated with Claude Code' + 'Generated with Claude' + 'Generated by Claude' + 'with help from Claude' + 'Powered by Claude' + 'powered by Claude' + 'as an AI' + 'As an AI' + 'this commit was generated' + 'This commit was generated' + 'this change was generated' + 'This change was generated' +) + +# Cursor-attribution literal tells (case-insensitive at the call site). +CURSOR_LITERALS=( + 'Co-Authored-By: Cursor' + 'Co-authored-by: Cursor' + 'Generated with Cursor' + 'Generated by Cursor' + 'with help from Cursor' + 'Powered by Cursor' +) + +# AI-tell signature emojis (UTF-8 byte sequences kept in a variable so the +# script source stays clean of the glyphs themselves). +EMOJI_TELLS=$(printf '\xf0\x9f\xa4\x96\n\xe2\x9c\xa8\n\xf0\x9f\xaa\x84\n\xf0\x9f\xa7\xa0\n\xf0\x9f\xa6\xbe\n\xf0\x9f\xa4\x9d') + +# Scan each file's added lines. +while IFS= read -r file; do + [ -z "$file" ] && continue + test -f "$file" || continue + + # Get added lines for this file, then filter out lines marked + # `# style-check: allow`. + ADDED=$(added_lines_for "$file" | grep -vF -- '# style-check: allow' || true) + [ -z "$ADDED" ] && continue + + # Em-dash check. + if printf '%s' "$ADDED" | LC_ALL=C grep -qF -- "$EMDASH"; then + printf 'pre-commit: forbidden em dash (U+2014) in staged %s.\n' "$file" >&2 + printf ' Replace with comma, period, colon, semicolon, or restructure.\n' >&2 + printf ' Rule: github-discipline.md Rule 5.2\n' >&2 + printf ' Locations (line numbers in the working tree):\n' >&2 + printf '%s\n' "$ADDED" | LC_ALL=C grep -F -- "$EMDASH" | sed 's/^/ /' >&2 + FAIL=1 + fi + + # AI-attribution literal-phrase check. + for p in "${LITERAL_PATTERNS[@]}"; do + if printf '%s' "$ADDED" | grep -qF -- "$p"; then + printf 'pre-commit: forbidden AI-attribution phrase in staged %s: %s\n' "$file" "$p" >&2 + printf ' Rule: github-discipline.md Rule 5.1\n' >&2 + FAIL=1 + fi + done + + # Cursor-attribution check (mirrors commit-msg). Specific tells only, never + # the bare word "cursor" (a legitimate programming term: text/db cursor). + for p in "${CURSOR_LITERALS[@]}"; do + if printf '%s' "$ADDED" | grep -qiF -- "$p"; then + printf 'pre-commit: forbidden Cursor attribution in staged %s: %s\n' "$file" "$p" >&2 + printf ' Rule: github-discipline.md Rule 5.1\n' >&2 + FAIL=1 + fi + done + if printf '%s' "$ADDED" | grep -qiE -- '@cursor\.com'; then + printf 'pre-commit: forbidden Cursor email (@cursor.com) in staged %s.\n' "$file" >&2 + printf ' Rule: github-discipline.md Rule 5.1\n' >&2 + FAIL=1 + fi + + # AI-signature emoji check. + while IFS= read -r emoji; do + [ -z "$emoji" ] && continue + if LC_ALL=C printf '%s' "$ADDED" | grep -qF -- "$emoji"; then + printf 'pre-commit: forbidden AI-signature emoji in staged %s.\n' "$file" >&2 + printf ' Rule: github-discipline.md Rule 5.1\n' >&2 + FAIL=1 + break + fi + done <<<"$EMOJI_TELLS" +done <<<"$SCAN_FILES" + +if [ "$FAIL" -ne 0 ]; then + printf '\n' >&2 + printf 'Hook scope: only ADDED lines in the staged diff are gated.\n' >&2 + printf 'Existing offending content is grandfathered until a sweep is run.\n' >&2 + printf 'To temporarily bypass (use sparingly): add `# style-check: allow`\n' >&2 + printf 'on the same line, or `git commit --no-verify` (logged in audit).\n' >&2 +fi + +exit $FAIL diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..9804d0e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# pre-push hook: run the mechanical gates locally before a push reaches GitHub. +# These mirror the CI workflow (.github/workflows/ci.yml) so red is caught here +# first. PureXML is a root Swift package, so Swift gates run at repo root. +# +# Install (per repo): git config core.hooksPath .githooks + +set -u + +ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo .) +cd "$ROOT" || exit 1 + +bash scripts/check-all.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1792505 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,61 @@ +name: Bug report +description: Something ships incorrectly in PureXML. +labels: ["bug"] +body: + - type: input + id: status-date + attributes: + label: Status date + description: Today's date, in YYYY-MM-DD form. Keep this current as the issue evolves. + placeholder: 2026-05-29 + validations: + required: true + - type: textarea + id: symptom + attributes: + label: Symptom + description: What goes wrong? What did you observe? + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen instead? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: A minimal, ordered list. Include a YAML document or command if relevant. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: How will we know this is fixed? + validations: + required: true + - type: dropdown + id: priority + attributes: + label: Priority + description: High means critical, release-gating, or actively blocking. Otherwise leave as Normal. + options: + - Normal + - High + validations: + required: true + - type: input + id: version + attributes: + label: Version or commit + description: The PureXML version or commit SHA where you saw this. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..137d78f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and ideas + url: https://github.com/mihaelamj/PureXML/discussions + about: Ask questions, float ideas, or discuss design in Discussions. + - name: Security issue + url: https://github.com/mihaelamj/PureXML/blob/main/SECURITY.md + about: Do not file security issues publicly. Follow the security policy instead. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a3924cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,43 @@ +name: Feature request +description: A new feature, refactor, or design proposal for PureXML. +labels: ["enhancement"] +body: + - type: input + id: status-date + attributes: + label: Status date + description: Today's date, in YYYY-MM-DD form. Keep this current as the issue evolves. + placeholder: 2026-05-29 + validations: + required: true + - type: textarea + id: goal + attributes: + label: Goal + description: What should be possible that is not possible today, and why does it matter? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: How might this work? Sketch the YAML, API, config, or workflow if you have one. + validations: + required: false + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: How will we know this is done? + validations: + required: true + - type: dropdown + id: priority + attributes: + label: Priority + description: High means release-gating or actively blocking other work. Otherwise leave as Normal. + options: + - Normal + - High + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..56cb1b4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ + + +## What + + + +## Why + + + +## How to verify + + + +```sh +swiftformat . --config .swiftformat --lint +swiftlint --config .swiftlint.yml --strict +swift build +swift test +bash scripts/check-wasm.sh +``` + +## Checklist + +- [ ] Format, lint, build, test, and WASM gates pass. +- [ ] Code follows the conventions in `docs/CONVENTIONS.md`. +- [ ] `CHANGELOG.md` updated under `Unreleased` (or this change is docs/tests/config only). +- [ ] No AI attribution and no em dashes in commits, comments, or this description. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b929c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +# PureXML is a root Swift package. CI must prove macOS, Linux, Windows, and WASM. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" + +permissions: + contents: read + +jobs: + macos: + name: macOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - name: Install format and lint tools + run: brew install swiftformat swiftlint + - name: Repository gates + run: | + bash scripts/check-style.sh + bash scripts/check-namespacing.sh + bash scripts/check-forbidden-patterns.sh + - name: Format and lint + run: | + swiftformat . --config .swiftformat --lint + swiftlint --config .swiftlint.yml --strict + - name: Build and test + run: | + swift build + swift test + swift build -c release + swift test -c release + + linux: + name: Linux + runs-on: ubuntu-latest + container: swift:6.2 + steps: + - uses: actions/checkout@v6 + - name: Build and test + run: bash scripts/check-linux.sh + + windows: + name: Windows + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + - name: Install Swift + uses: compnerd/gha-setup-swift@main + with: + swift-version: swift-6.3.2-release + swift-build: 6.3.2-RELEASE + - name: Build and test + shell: pwsh + run: ./scripts/check-windows.ps1 + + wasm: + name: WASM + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install Swift toolchain dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcurl4-openssl-dev + - name: Install swiftly + run: | + curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz + tar zxf swiftly.tar.gz + ./swiftly init --quiet-shell-followup --assume-yes --skip-install + - name: Install Swift 6.3.2 and Wasm SDK + run: | + . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + swiftly install --use 6.3.2 + swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c + - name: Build for WASM + run: | + . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + bash scripts/check-wasm.sh diff --git a/.github/workflows/no-cursor-attribution.yml b/.github/workflows/no-cursor-attribution.yml new file mode 100644 index 0000000..23cf811 --- /dev/null +++ b/.github/workflows/no-cursor-attribution.yml @@ -0,0 +1,60 @@ +name: No Cursor attribution + +# Server-side companion to the local .githooks/commit-msg and pre-commit guards. +# Fails the check if any commit pushed (or in a PR) carries Cursor attribution: +# a Cursor co-author trailer, an @cursor.com email, or a "generated with Cursor" +# tell, in the commit message or in the author/committer identity. + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + scan: + name: Scan commits + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Reject Cursor attribution + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + PUSH_BEFORE: ${{ github.event.before }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + + # Resolve the set of commits to scan. + if [ "$EVENT" = "pull_request" ]; then + range="${PR_BASE}..${PR_HEAD}" + elif [ -n "${PUSH_BEFORE:-}" ] && [ "$PUSH_BEFORE" != "0000000000000000000000000000000000000000" ]; then + range="${PUSH_BEFORE}..${SHA}" + else + # New branch / tag push or manual run: scan just the tip commit. + range="${SHA}~1..${SHA}" + git rev-parse "${SHA}~1" >/dev/null 2>&1 || range="$SHA" + fi + echo "Scanning commit range: $range" + + # Emit message + author + committer for each commit, then grep. + fmt='commit %H%nauthor: %an <%ae>%ncommitter: %cn <%ce>%n%B' + if printf '%s' "$range" | grep -q '\.\.'; then + log=$(git log --format="$fmt" "$range") + else + log=$(git log -1 --format="$fmt" "$range") + fi + + pattern='cursoragent@cursor\.com|@cursor\.com|Co-authored-by:[[:space:]]*Cursor|Generated (with|by) Cursor|with help from Cursor|Powered by Cursor' + if printf '%s\n' "$log" | grep -niE -- "$pattern"; then + echo "::error::Cursor attribution found in the commits above. Strip it (rewrite the commits) before pushing." + exit 1 + fi + echo "No Cursor attribution found." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d20fe2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,83 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore. + +## User settings +**/xcuserdata/ + +## Obj-C/Swift specific +**/*.hmap + +## App packaging +**/*.ipa +**/*.dSYM.zip +**/*.dSYM + +## Playgrounds +**/timeline.xctimeline +**/playground.xcworkspace + +# Swift Package Manager +# +# `swift build` writes everything under .build/. .swiftpm/ holds Xcode- +# integrated SPM settings + per-user resolved manifests. Package.resolved +# is a lock file: pinning it across the team helps reproducibility but +# is per-machine when the manifest specifies open ranges; we keep it out +# to avoid noisy diffs. +**/.build/ +**/.swiftpm/ +**/Package.resolved +**/Packages/.build/ +**/Packages/.swiftpm/ +**/Packages/Package.resolved + +# Xcode workspace & projects +**/*.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +**/*.xcworkspace/xcshareddata/swiftpm/Package.resolved +**/*.xcworkspace/xcuserdata/ +**/DerivedData/ +**/*.xcuserstate +**/*.xcscmblueprint +**/*.xccheckout + +# macOS system files +.DS_Store + +# Carthage (kept here even though SPM is the default; we may add it) +**/Carthage/Build/ + +# fastlane (release tooling artefacts, not source) +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots/**/*.png +**/fastlane/test_output + +# Build artefacts (global; covers ad-hoc Build/ folders from scripts) +**/Build/ +**/.DerivedData/ +**/build.log +**/*.build.log + +# DocC archive generated by `swift package generate-documentation` +**/Packages/docs-archive/ +**/Packages/Sources/docs-archive/ +**/docs-archive/ +**/*.doccarchive + +# DocC static site output. Anchor with `/` so a Swift package's +# `Resources/Public` folder (e.g. WebAdminFeature's admin.css) isn't +# accidentally ignored. +/public/ + +# Tooling logs (cron, hooks, local scripts) +*.log +**/*.log +.derived-data-log-* + +# Local SQLite DB created by autoMigrate at first boot. +# Codeweaver scaffolds (template-time/bookings/crm/inventory) all default +# to FluentSQLiteDriver with `.file("db.sqlite")` and run autoMigrate at +# composition-root boot, which materialises this file in the working +# directory next to the executable. The file is per-developer state. +Packages/db.sqlite +Packages/db.sqlite-* diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..90a35cd --- /dev/null +++ b/.swiftformat @@ -0,0 +1,24 @@ +# SwiftFormat configuration. The mechanical formatting gate. +# +# Inert until Swift sources exist. The CI workflow that runs +# `swiftformat . --config .swiftformat --lint` plus build plus test ships with +# the engine package. Run `swiftformat . --config .swiftformat` before committing. + +--swiftversion 6.1 + +# House style. +--indent 4 +--maxwidth 180 +--commas always + +# Safe defaults that match the style above. +--trimwhitespace always +--semicolons never +--wraparguments before-first +--wrapparameters before-first +--wrapcollections before-first +--self remove +--exclude .build,DerivedData + +# PureXML uses OpenAPIKit-style `test_...` method names with Swift Testing. +--disable swiftTestingTestCaseNames diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..1b53e12 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,18 @@ +# Mechanical enforcement of the code rules a machine can decide +# (engineering.md principle: no force-unwrap in shipping code). +# +# Inert until Swift sources exist. The CI workflow that runs swiftlint plus +# build plus test ships with the engine package. +opt_in_rules: + - force_unwrapping +excluded: + - .build + - DerivedData +force_unwrapping: error +force_try: error +force_cast: error +line_length: + warning: 180 + error: 180 +trailing_comma: + mandatory_comma: true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1c21f90 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +# Agent Guide + +Guidance for anyone writing code in PureXML. + +## Rule Loading + +At the start of a session, read this file and the rules under `docs/rules/` that +match the task. Confirm by replying with `rules-loaded` and name the files you +loaded. + +For code changes, load at minimum: + +- `docs/rules/code-style.md` +- `docs/rules/namespacing.md` +- `docs/rules/cross-platform.md` +- `docs/rules/testing.md` +- `docs/rules/verification.md` +- `docs/rules/commits.md` + +## What PureXML Is + +PureXML is a dependency-free XML package written entirely in Swift. + +The package must stay: + +- pure Swift +- root Swift package layout, with `Package.swift` at repository root +- dependency-free +- Linux-compatible +- WebAssembly/WASI-compatible +- namespaced under `PureXML` + +The private `PureXMLResearch` repo is for studying reference XML implementations +(libxml2/`expat`, Foundation's `XMLParser`, and the W3C XML 1.0 conformance +suite). Do not copy C code or upstream parser source into this public package. +Keep attribution in `ATTRIBUTION.md` accurate when compatibility work references +those projects. + +Compatibility work must start from that research source. Before changing parser, +emitter, validation, or typed conversion behavior, inspect the matching +PureXMLResearch source or fixture and use it only to define behavior and tests. + +## Namespace Rules + +Every public type lives under the `PureXML` namespace tree and mirrors its +folder: + +- `Sources/Model/Node.swift` declares `PureXML.Model.Node` +- `Sources/Parsing/Parser.swift` declares `PureXML.Parsing.Parser` +- `Sources/Emitting/Serializer.swift` declares `PureXML.Emitting.Serializer` +- `Sources/Validation/Validator.swift` declares `PureXML.Validation.Validator` + +No top-level public concrete types except the root `public enum PureXML`. + +## Dependency Rules + +`Package.swift` must keep `dependencies: []`. + +Do not add external packages, C targets, system libraries, Foundation-only +workarounds, JavaScript tooling, or generated parser dependencies without an +explicit maintainer decision. + +## Verification + +Before claiming a change is complete, run and cite: + +```sh +bash scripts/check-style.sh +bash scripts/check-namespacing.sh +bash scripts/check-forbidden-patterns.sh +swiftformat . --config .swiftformat --lint +swiftlint --config .swiftlint.yml --strict +swift build +swift test +bash scripts/check-wasm.sh +``` + +Enable local hooks: + +```sh +git config core.hooksPath .githooks +``` diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 0000000..87cb67f --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,26 @@ +# Attribution + +PureXML is an independent Swift implementation. + +This project is informed by the behavior and public API surface of established +XML parsers, including [libxml2](https://gitlab.gnome.org/GNOME/libxml2), +[expat](https://github.com/libexpat/libexpat), and Foundation's `XMLParser`, and +by the [W3C XML 1.0 specification](https://www.w3.org/TR/xml/) and its +conformance test suite. Their behavior is a compatibility reference for PureXML. + +PureXML does not copy source code from libxml2, expat, Foundation, or any other +XML parser into the public package. The implementation under `Sources/` is +written in Swift for this repository. + +Upstream sources remain available from their projects: + +- https://gitlab.gnome.org/GNOME/libxml2 +- https://github.com/libexpat/libexpat +- https://www.w3.org/TR/xml/ + +This file is the canonical attribution notice for the project; the `LICENSE` +file is kept to the standard MIT text so it is correctly detected as MIT. + +The private `PureXMLResearch` repository contains reference source snapshots and +conformance corpora for study and compatibility research. That repository is not +the implementation source for PureXML. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aa7d2aa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to PureXML are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial package scaffold: root Swift package, namespacing under `PureXML`, and + the portability and verification gates carried over from PureYAML. +- XML node model (`PureXML.Model`): `Node`, `Element`, `Attribute`, and + `QualifiedName`, preserving document order and node kinds. +- XML emitter (`PureXML.Emitting.Serializer`) with pretty-printed and compact + output and correct text and attribute escaping. +- Structural validation (`PureXML.Validation.Validator`) for duplicate attribute + names. +- Stable parser surface (`PureXML.Parsing.Parser`, `ParseError`, `Mark`). + `PureXML.parse(_:)` raises `ParseError.notImplemented` until the scanner lands; + the gap is pinned by a test. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7157f18 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,39 @@ +# CLAUDE.md + +Guidance for coding agents working in PureXML. + +## Project + +PureXML is a pure-Swift XML parser/emitter package. It is a root Swift package, +not a monorepo package under `Packages/`. + +Non-negotiables: + +- no external SwiftPM dependencies +- no C, C++, Objective-C, JavaScript, or generated parser runtime in the public package +- public API under the `PureXML` namespace tree +- macOS, Linux, and WASI build compatibility +- tests for parser and emitter behavior + +## Read First + +- `AGENTS.md` +- `docs/rules/code-style.md` +- `docs/rules/namespacing.md` +- `docs/rules/cross-platform.md` +- `docs/rules/testing.md` +- `docs/rules/verification.md` + +Confirm rule loading with `rules-loaded`. + +## Commands + +```sh +bash scripts/check-style.sh +bash scripts/check-namespacing.sh +swiftformat . --config .swiftformat --lint +swiftlint --config .swiftlint.yml --strict +swift build +swift test +bash scripts/check-wasm.sh +``` diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c810905 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Code of Conduct + +## Our pledge + +We are committed to providing a friendly, safe, and welcoming environment for +everyone who takes part in the PureXML project, regardless of background or +identity. We pledge to act and interact in ways that contribute to an open, +respectful, and healthy community. + +## Our standards + +Examples of behavior that helps the community: + +- Showing empathy and kindness toward other people. +- Being respectful of differing opinions, viewpoints, and experiences. +- Giving and gracefully accepting constructive feedback. +- Taking responsibility, apologizing to those affected by our mistakes, and + learning from the experience. +- Focusing on what is best for the community as a whole. + +Behavior that is not acceptable includes personal attacks, insulting or +derogatory comments, public or private harassment, publishing others' private +information without explicit permission, and any other conduct that could +reasonably be considered inappropriate in a professional setting. + +## Standard + +This project adopts the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct/. That document +is the full reference for expected behavior and enforcement guidelines. + +## Enforcement + +Instances of unacceptable behavior may be reported to the project maintainer at +**mihaelamj.com**. All reports will be reviewed and investigated promptly and +fairly. The maintainer is obligated to respect the privacy and security of the +reporter of any incident. + +## Scope + +This Code of Conduct applies within all project spaces and also applies when an +individual is officially representing the project in public spaces. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f34d043 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to PureXML + +Thanks for your interest in PureXML. + +By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). + +## Project Shape + +PureXML is a root Swift package: + +```text +PureXML/ +├── Package.swift +├── Sources/ +├── Tests/ +├── docs/ +└── scripts/ +``` + +Do not create a `Packages/` folder for this repo. + +## Rules + +- Pure Swift only. +- No external SwiftPM dependencies. +- No C-backed parser target in the public package. +- Every public type lives under the `PureXML` namespace tree. +- Build and test on macOS, Linux, and WASI. + +## Setup + +```sh +git config core.hooksPath .githooks +swift build +swift test +``` + +Install SwiftFormat and SwiftLint if they are not already available: + +```sh +brew install swiftformat swiftlint +``` + +## Verification + +Before opening a PR or saying a change is ready, run: + +```sh +bash scripts/check-style.sh +bash scripts/check-namespacing.sh +swiftformat . --config .swiftformat --lint +swiftlint --config .swiftlint.yml --strict +swift build +swift test +``` + +## Commits + +Use Conventional Commits: + +```text +(): summary +``` + +Examples: + +- `feat(parser): add element start-tag scanning` +- `test(emitter): cover attribute escaping` +- `ci(wasm): add wasi build gate` + +Do not include AI attribution. Do not use em dashes. + +## License + +By contributing, you agree that your contributions are licensed under the +project's [MIT license](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c811497 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mihaela Mihaljevic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..199109d --- /dev/null +++ b/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 6.1 + +import PackageDescription + +let package = Package( + name: "PureXML", + products: [ + .library( + name: "PureXML", + targets: ["PureXML"], + ), + ], + dependencies: [], + targets: [ + .target( + name: "PureXML", + path: "Sources", + ), + .testTarget( + name: "PureXMLTests", + dependencies: ["PureXML"], + path: "Tests", + resources: [ + .copy("Fixtures"), + ], + ), + ], +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3ec44e --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# PureXML + +[![macOS CI](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureXML/ci.yml?branch=main&label=macOS)](https://github.com/mihaelamj/PureXML/actions/workflows/ci.yml) +[![Linux CI](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureXML/ci.yml?branch=main&label=Linux)](https://github.com/mihaelamj/PureXML/actions/workflows/ci.yml) +[![WASI CI](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureXML/ci.yml?branch=main&label=WASI)](https://github.com/mihaelamj/PureXML/actions/workflows/ci.yml) + +PureXML is a dependency-free XML package written entirely in Swift. + +The goal is a Linux-, Windows-, and WebAssembly-compatible XML reader/writer that +does not pull in `libxml2`, `expat`, or Foundation's `XMLParser`. The package is +intentionally strict about portability: + +- no external SwiftPM dependencies +- no bundled C sources +- no Foundation requirement in the library target +- root Swift package layout +- macOS, Linux, Windows, and WASI build gates + +It is a sibling project to [PureYAML](https://github.com/mihaelamj/PureYAML) and +follows the same structure, rules, and verification gates. + +## Status + +This is an early scaffold. The node model and the emitter are implemented and +usable today; the tokenizing parser is still being built. + +- **Model** (`PureXML.Model`): `Node`, `Element`, `Attribute`, `QualifiedName`. + Preserves document order and the distinction between text, CDATA, comments, + and processing instructions. +- **Emitting** (`PureXML.Emitting`): `Serializer` turns a node tree into + well-formed XML, with pretty-printed and compact options and correct text and + attribute escaping. +- **Validation** (`PureXML.Validation`): structural checks such as duplicate + attribute names. Schema validation (DTD/XSD/RELAX NG) is out of scope for the + library target. +- **Parsing** (`PureXML.Parsing`): the public surface (`Parser`, `ParseError`, + `Mark`) is stable. `PureXML.parse(_:)` currently raises + `ParseError.notImplemented` so callers can wire against the final API before + the scanner lands. The gap is pinned by a test rather than left silent. + +## Usage + +```swift +import PureXML + +// Build a tree and emit it (works today). +let element = PureXML.Model.Element( + "book", + attributes: [.init("id", "bk101")], + children: [.element(.init("title", children: [.text("XML Developer's Guide")]))], +) + +let xml = PureXML.serialize(.element(element)) +try PureXML.validate(.element(element)) + +// Parsing is not implemented yet: +// let node = try PureXML.parse(xml) +``` + +## Attribution + +PureXML is informed by the behavior of established XML parsers (`libxml2`, +`expat`, Foundation's `XMLParser`) and by the W3C XML 1.0 specification, but it +does not copy their implementation into `Sources/`. See +[ATTRIBUTION.md](ATTRIBUTION.md). + +## Development Contract + +PureXML must stay dependency-free and portable. Before merging changes: + +- Swift tools version: 6.1 +- Package products: `PureXML` +- SwiftPM dependencies: none +- Hosted CI matrix: macOS, Linux, Windows, and WASM + +```sh +bash scripts/check-all.sh +``` + +That command expands to: + +```sh +bash scripts/check-style.sh +bash scripts/check-namespacing.sh +bash scripts/check-forbidden-patterns.sh +swiftformat . --config .swiftformat --lint +swiftlint --config .swiftlint.yml --strict +swift build +swift test +``` + +## License + +MIT. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..27ab181 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Reporting a vulnerability + +If you believe you have found a security issue in PureXML, please report it +privately. Do not open a public issue for security problems. + +Email **mihaelamj.com** with: + +- A description of the issue and its impact. +- Steps to reproduce, or a proof of concept. +- The affected version or commit. + +You can expect an acknowledgement within a few days. Once the issue is confirmed, +a fix will be prepared and a release cut, after which the issue can be disclosed +publicly with credit to the reporter if desired. + +## Supported versions + +PureXML is pre-1.0 and under active development. Security fixes are applied to +the `main` branch. Until a stable release exists, only the latest `main` is +supported. + +## Scope + +PureXML parses and emits XML. Reports about parser denial-of-service vectors +(such as entity-expansion or deeply nested input), or about emitter output that +fails to escape special characters and enables injection downstream, are in +scope. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..5669cb9 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,14 @@ +# Support + +Need help with PureXML? Here is where to go. + +- **Questions and ideas**: open a [Discussion](https://github.com/mihaelamj/PureXML/discussions). +- **Bug reports**: open an [issue](https://github.com/mihaelamj/PureXML/issues/new/choose) + using the bug form. +- **Feature requests**: open an issue using the feature form. +- **Security issues**: do not open a public issue. See [SECURITY.md](SECURITY.md). + +Before filing, please search existing issues and discussions to avoid duplicates. + +PureXML is pre-1.0 and maintained on a best-effort basis. Clear, reproducible +reports get the fastest responses. diff --git a/Sources/API.swift b/Sources/API.swift new file mode 100644 index 0000000..8ce0307 --- /dev/null +++ b/Sources/API.swift @@ -0,0 +1,28 @@ +public extension PureXML { + /// Parses an XML document into a ``Model/Node`` tree. + /// + /// The parser is still being built; this entry point currently raises + /// ``Parsing/ParseError/notImplemented(_:)``. The serializer and model are + /// usable today for building and emitting trees. + static func parse(_ xml: String) throws -> Model.Node { + try Parsing.Parser().parse(xml) + } + + /// Serializes a ``Model/Node`` tree into XML with the selected options. + static func serialize( + _ node: Model.Node, + options: Emitting.Options = .default, + ) -> String { + Emitting.Serializer(options: options).serialize(node) + } + + /// Validates a parsed XML node with the default validation rules. + @discardableResult + static func validate( + _ node: Model.Node, + using validator: Validation.Validator = .init(), + strict: Bool = true, + ) throws -> [Validation.Issue] { + try validator.validate(node, strict: strict) + } +} diff --git a/Sources/Emitting/Options.swift b/Sources/Emitting/Options.swift new file mode 100644 index 0000000..a2932fb --- /dev/null +++ b/Sources/Emitting/Options.swift @@ -0,0 +1,28 @@ +public extension PureXML.Emitting { + /// Explicit emitter options. The default is pretty-printed output with + /// two-space indentation and a self-closing tag for empty elements. + struct Options: Equatable, Hashable, Sendable { + /// Whether to indent nested elements onto their own lines. + public var prettyPrint: Bool + /// The indentation unit used when ``prettyPrint`` is enabled. + public var indent: String + /// Whether childless elements collapse to ``. + public var selfCloseEmptyElements: Bool + + public init( + prettyPrint: Bool = true, + indent: String = " ", + selfCloseEmptyElements: Bool = true, + ) { + self.prettyPrint = prettyPrint + self.indent = indent + self.selfCloseEmptyElements = selfCloseEmptyElements + } + + /// Pretty-printed output with two-space indentation. + public static let `default` = Options() + + /// Single-line output with no inserted whitespace. + public static let compact = Options(prettyPrint: false) + } +} diff --git a/Sources/Emitting/Serializer.swift b/Sources/Emitting/Serializer.swift new file mode 100644 index 0000000..9c97a0c --- /dev/null +++ b/Sources/Emitting/Serializer.swift @@ -0,0 +1,98 @@ +public extension PureXML.Emitting { + /// Serializes a ``PureXML/Model/Node`` tree back into XML text. + /// + /// Unlike the parser, the serializer is implemented: it is the working half + /// of the round-trip and lets callers build trees programmatically and emit + /// well-formed XML today. + struct Serializer: Sendable { + public var options: Options + + public init(options: Options = .default) { + self.options = options + } + + /// Serializes a node into an XML string. + public func serialize(_ node: PureXML.Model.Node) -> String { + var output = "" + write(node, depth: 0, into: &output) + return output + } + + private func write(_ node: PureXML.Model.Node, depth: Int, into output: inout String) { + switch node { + case let .document(children): + writeChildren(children, depth: depth, into: &output) + case let .element(element): + write(element, depth: depth, into: &output) + case let .text(value): + output += Self.escapeText(value) + case let .cdata(value): + output += "" + case let .comment(value): + output += "" + case let .processingInstruction(target, data): + output += data.isEmpty ? "" : "" + } + } + + private func write(_ element: PureXML.Model.Element, depth: Int, into output: inout String) { + let pad = options.prettyPrint ? String(repeating: options.indent, count: depth) : "" + output += pad + "<" + element.name.description + for attribute in element.attributes { + output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\"" + } + + if element.children.isEmpty, options.selfCloseEmptyElements { + output += "/>" + if options.prettyPrint { output += "\n" } + return + } + + output += ">" + let onlyText = element.children.allSatisfy { if case .text = $0 { true } else { false } } + if options.prettyPrint, !onlyText { + output += "\n" + writeChildren(element.children, depth: depth + 1, into: &output) + output += pad + } else { + for child in element.children { + write(child, depth: 0, into: &output) + } + } + output += "" + if options.prettyPrint { output += "\n" } + } + + private func writeChildren(_ children: [PureXML.Model.Node], depth: Int, into output: inout String) { + for child in children { + write(child, depth: depth, into: &output) + } + } + + private static func escapeText(_ value: String) -> String { + var result = "" + for character in value { + switch character { + case "&": result += "&" + case "<": result += "<" + case ">": result += ">" + default: result.append(character) + } + } + return result + } + + private static func escapeAttribute(_ value: String) -> String { + var result = "" + for character in value { + switch character { + case "&": result += "&" + case "<": result += "<" + case "\"": result += """ + default: result.append(character) + } + } + return result + } + } +} diff --git a/Sources/Model/Attribute.swift b/Sources/Model/Attribute.swift new file mode 100644 index 0000000..bbc9803 --- /dev/null +++ b/Sources/Model/Attribute.swift @@ -0,0 +1,17 @@ +public extension PureXML.Model { + /// A single name/value attribute on an XML element. + struct Attribute: Equatable, Hashable, Sendable { + public var name: QualifiedName + public var value: String + + public init(name: QualifiedName, value: String) { + self.name = name + self.value = value + } + + public init(_ name: String, _ value: String) { + self.name = QualifiedName(name) + self.value = value + } + } +} diff --git a/Sources/Model/Element.swift b/Sources/Model/Element.swift new file mode 100644 index 0000000..eeaed3a --- /dev/null +++ b/Sources/Model/Element.swift @@ -0,0 +1,42 @@ +public extension PureXML.Model { + /// An XML element: a qualified name, ordered attributes, and ordered child nodes. + struct Element: Equatable, Hashable, Sendable { + public var name: QualifiedName + public var attributes: [Attribute] + public var children: [Node] + + public init( + name: QualifiedName, + attributes: [Attribute] = [], + children: [Node] = [], + ) { + self.name = name + self.attributes = attributes + self.children = children + } + + public init( + _ name: String, + attributes: [Attribute] = [], + children: [Node] = [], + ) { + self.init( + name: QualifiedName(name), + attributes: attributes, + children: children, + ) + } + + /// The concatenated text of direct text and CDATA children. + public var text: String { + children.reduce(into: "") { accumulated, child in + switch child { + case let .text(value), let .cdata(value): + accumulated += value + default: + break + } + } + } + } +} diff --git a/Sources/Model/Node.swift b/Sources/Model/Node.swift new file mode 100644 index 0000000..6f05c68 --- /dev/null +++ b/Sources/Model/Node.swift @@ -0,0 +1,25 @@ +public extension PureXML.Model { + /// A node in an XML tree. The model preserves document order and the + /// distinction between text, CDATA, comments, and processing instructions + /// so that emitting can round-trip a parsed document. + indirect enum Node: Equatable, Hashable, Sendable { + /// The root of a parsed document, holding its prolog and root element. + case document([Node]) + /// An element with a name, attributes, and children. + case element(Element) + /// Character data between markup. + case text(String) + /// A `` section. Stored as its raw inner text. + case cdata(String) + /// A `` comment. Stored as its raw inner text. + case comment(String) + /// A `` processing instruction. + case processingInstruction(target: String, data: String) + + /// The wrapped ``Element`` when this node is an element. + public var element: Element? { + guard case let .element(element) = self else { return nil } + return element + } + } +} diff --git a/Sources/Model/QualifiedName.swift b/Sources/Model/QualifiedName.swift new file mode 100644 index 0000000..c3beaf6 --- /dev/null +++ b/Sources/Model/QualifiedName.swift @@ -0,0 +1,39 @@ +public extension PureXML.Model { + /// An optionally namespace-prefixed XML name such as `xs:element`. + struct QualifiedName: Equatable, Hashable, Sendable, CustomStringConvertible { + /// Namespace prefix preceding the colon, when present (`xs` in `xs:element`). + public var prefix: String? + + /// The local part of the name (`element` in `xs:element`). + public var localName: String + + public init(prefix: String? = nil, localName: String) { + self.prefix = prefix + self.localName = localName + } + + /// Parses a raw `prefix:local` or bare `local` name. + public init(_ raw: String) { + if let colon = raw.firstIndex(of: ":") { + let head = String(raw[raw.startIndex ..< colon]) + let tail = String(raw[raw.index(after: colon)...]) + if head.isEmpty || tail.isEmpty { + prefix = nil + localName = raw + } else { + prefix = head + localName = tail + } + } else { + prefix = nil + localName = raw + } + } + + /// The full `prefix:local` rendering used in serialized output. + public var description: String { + guard let prefix else { return localName } + return "\(prefix):\(localName)" + } + } +} diff --git a/Sources/Parsing/Mark.swift b/Sources/Parsing/Mark.swift new file mode 100644 index 0000000..9d27f0f --- /dev/null +++ b/Sources/Parsing/Mark.swift @@ -0,0 +1,24 @@ +public extension PureXML.Parsing { + /// A position in the source text, used for parser diagnostics. + struct Mark: Equatable, Hashable, Sendable, CustomStringConvertible { + /// One-based line number. + public var line: Int + /// One-based column number. + public var column: Int + /// Zero-based scalar offset from the start of the input. + public var offset: Int + + public init(line: Int, column: Int, offset: Int) { + self.line = line + self.column = column + self.offset = offset + } + + /// The start of any input. + public static let start = Mark(line: 1, column: 1, offset: 0) + + public var description: String { + "line \(line), column \(column)" + } + } +} diff --git a/Sources/Parsing/ParseError.swift b/Sources/Parsing/ParseError.swift new file mode 100644 index 0000000..5e48a08 --- /dev/null +++ b/Sources/Parsing/ParseError.swift @@ -0,0 +1,38 @@ +public extension PureXML.Parsing { + /// Parser error with source position information when available. + enum ParseError: Swift.Error, Equatable, Sendable, CustomStringConvertible { + case emptyDocument + case unexpectedEndOfInput(Mark) + case unexpectedCharacter(Character, Mark) + case unterminatedTag(Mark) + case unterminatedComment(Mark) + case unterminatedCDATA(Mark) + case mismatchedEndTag(expected: String, found: String, Mark) + case duplicateAttribute(name: String, Mark) + /// Raised by entry points whose parsing path is not implemented yet. + case notImplemented(String) + + public var description: String { + switch self { + case .emptyDocument: + "document is empty" + case let .unexpectedEndOfInput(mark): + "unexpected end of input at \(mark)" + case let .unexpectedCharacter(character, mark): + "unexpected character '\(character)' at \(mark)" + case let .unterminatedTag(mark): + "unterminated tag starting at \(mark)" + case let .unterminatedComment(mark): + "unterminated comment starting at \(mark)" + case let .unterminatedCDATA(mark): + "unterminated CDATA section starting at \(mark)" + case let .mismatchedEndTag(expected, found, mark): + "expected but found at \(mark)" + case let .duplicateAttribute(name, mark): + "duplicate attribute '\(name)' at \(mark)" + case let .notImplemented(detail): + "not implemented: \(detail)" + } + } + } +} diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift new file mode 100644 index 0000000..90b62e2 --- /dev/null +++ b/Sources/Parsing/Parser.swift @@ -0,0 +1,17 @@ +public extension PureXML.Parsing { + /// The pure-Swift XML parser. + /// + /// The tokenizing scanner and the well-formedness rules are still being + /// built out. The public entry points exist and are stable; calling them + /// today raises ``ParseError/notImplemented(_:)`` so that downstream code + /// can wire against the final surface before the parser lands. + struct Parser: Sendable { + public init() {} + + /// Parses a single XML document into a ``PureXML/Model/Node/document(_:)`` tree. + public func parse(_ xml: String) throws -> PureXML.Model.Node { + guard !xml.isEmpty else { throw ParseError.emptyDocument } + throw ParseError.notImplemented("document parsing") + } + } +} diff --git a/Sources/PureXML.swift b/Sources/PureXML.swift new file mode 100644 index 0000000..cd49c39 --- /dev/null +++ b/Sources/PureXML.swift @@ -0,0 +1,23 @@ +/// Root namespace for the PureXML package. +public enum PureXML { + /// XML document model types. + public enum Model {} + + /// XML parsing types. + public enum Parsing {} + + /// XML emitting types. + public enum Emitting {} + + /// XML typed decoding types. + public enum Decoding {} + + /// XML typed encoding types. + public enum Encoding {} + + /// XML validation types. + public enum Validation {} + + /// XML stream types. + public enum Stream {} +} diff --git a/Sources/Stream/Document.swift b/Sources/Stream/Document.swift new file mode 100644 index 0000000..17feb13 --- /dev/null +++ b/Sources/Stream/Document.swift @@ -0,0 +1,13 @@ +public extension PureXML.Stream { + /// One document from a parsed stream, carrying its zero-based stream index + /// so that validation can report which document an issue belongs to. + struct Document: Equatable, Hashable, Sendable { + public var index: Int + public var node: PureXML.Model.Node + + public init(index: Int, node: PureXML.Model.Node) { + self.index = index + self.node = node + } + } +} diff --git a/Sources/Validation/Issue.swift b/Sources/Validation/Issue.swift new file mode 100644 index 0000000..9e4eb0d --- /dev/null +++ b/Sources/Validation/Issue.swift @@ -0,0 +1,16 @@ +public extension PureXML.Validation { + /// A single validation finding with a severity and human-readable message. + struct Issue: Equatable, Hashable, Sendable, CustomStringConvertible { + public var severity: Severity + public var message: String + + public init(severity: Severity, message: String) { + self.severity = severity + self.message = message + } + + public var description: String { + "\(severity.rawValue): \(message)" + } + } +} diff --git a/Sources/Validation/Severity.swift b/Sources/Validation/Severity.swift new file mode 100644 index 0000000..937e896 --- /dev/null +++ b/Sources/Validation/Severity.swift @@ -0,0 +1,7 @@ +public extension PureXML.Validation { + /// The severity of a validation issue. + enum Severity: String, Equatable, Hashable, Sendable, CaseIterable { + case warning + case error + } +} diff --git a/Sources/Validation/Validator.swift b/Sources/Validation/Validator.swift new file mode 100644 index 0000000..f958039 --- /dev/null +++ b/Sources/Validation/Validator.swift @@ -0,0 +1,58 @@ +public extension PureXML.Validation { + /// Path-aware validation over a parsed XML tree. + /// + /// The default validator checks structural rules that do not depend on a + /// schema: an element must not declare the same attribute name twice. Schema + /// (DTD, XSD, RELAX NG) validation is intentionally out of scope for the + /// library target and is layered on by callers. + struct Validator: Sendable { + public init() {} + + /// Collects validation issues for a node tree. + public func collect(_ node: PureXML.Model.Node) -> [Issue] { + var issues: [Issue] = [] + walk(node, into: &issues) + return issues + } + + /// Validates a node tree, throwing the first error-severity issue. + @discardableResult + public func validate(_ node: PureXML.Model.Node, strict: Bool = true) throws -> [Issue] { + let issues = collect(node) + if let error = issues.first(where: { $0.severity == .error }) { + throw error + } + if strict, let warning = issues.first(where: { $0.severity == .warning }) { + throw warning + } + return issues + } + + private func walk(_ node: PureXML.Model.Node, into issues: inout [Issue]) { + switch node { + case let .document(children): + for child in children { + walk(child, into: &issues) + } + case let .element(element): + var seen: Set = [] + for attribute in element.attributes { + let key = attribute.name.description + if !seen.insert(key).inserted { + issues.append(.init( + severity: .error, + message: "duplicate attribute '\(key)' on <\(element.name.description)>", + )) + } + } + for child in element.children { + walk(child, into: &issues) + } + case .text, .cdata, .comment, .processingInstruction: + break + } + } + } +} + +extension PureXML.Validation.Issue: Swift.Error {} diff --git a/Tests/Fixtures/sample.xml b/Tests/Fixtures/sample.xml new file mode 100644 index 0000000..18ccd4e --- /dev/null +++ b/Tests/Fixtures/sample.xml @@ -0,0 +1,7 @@ + + + + Gambardella, Matthew + XML Developer's Guide + + diff --git a/Tests/ModelTests.swift b/Tests/ModelTests.swift new file mode 100644 index 0000000..bd67647 --- /dev/null +++ b/Tests/ModelTests.swift @@ -0,0 +1,29 @@ +@testable import PureXML +import Testing + +@Suite("Model") +struct ModelTests { + @Test("Qualified name splits a prefixed name") + func test_qualifiedNameSplitsPrefix() { + let name = PureXML.Model.QualifiedName("xs:element") + #expect(name.prefix == "xs") + #expect(name.localName == "element") + #expect(name.description == "xs:element") + } + + @Test("Qualified name keeps a bare name whole") + func test_qualifiedNameKeepsBareName() { + let name = PureXML.Model.QualifiedName("element") + #expect(name.prefix == nil) + #expect(name.localName == "element") + } + + @Test("Element text concatenates text and CDATA children") + func test_elementTextConcatenates() { + let element = PureXML.Model.Element( + "p", + children: [.text("Hello "), .cdata("")], + ) + #expect(element.text == "Hello ") + } +} diff --git a/Tests/ParsingTests.swift b/Tests/ParsingTests.swift new file mode 100644 index 0000000..9783793 --- /dev/null +++ b/Tests/ParsingTests.swift @@ -0,0 +1,29 @@ +@testable import PureXML +import Testing + +@Suite("Parsing") +struct ParsingTests { + @Test("Empty input reports an empty document") + func test_emptyInputThrows() { + #expect(throws: PureXML.Parsing.ParseError.emptyDocument) { + try PureXML.parse("") + } + } + + /// The tokenizing parser is not implemented yet. This test pins the current + /// contract so the gap is explicit rather than a silent partial parse. + @Test("Non-empty input reports the parser is not implemented yet") + func test_nonEmptyInputNotImplemented() { + do { + _ = try PureXML.parse("") + Issue.record("expected parse to throw notImplemented") + } catch let error as PureXML.Parsing.ParseError { + guard case .notImplemented = error else { + Issue.record("expected notImplemented, got \(error)") + return + } + } catch { + Issue.record("unexpected error: \(error)") + } + } +} diff --git a/Tests/SerializerTests.swift b/Tests/SerializerTests.swift new file mode 100644 index 0000000..89f4610 --- /dev/null +++ b/Tests/SerializerTests.swift @@ -0,0 +1,40 @@ +@testable import PureXML +import Testing + +@Suite("Serializer") +struct SerializerTests { + @Test("Empty element self-closes") + func test_emptyElementSelfCloses() { + let node = PureXML.Model.Node.element(.init("br")) + #expect(PureXML.serialize(node, options: .compact) == "
") + } + + @Test("Attributes and text are escaped") + func test_escapesAttributesAndText() { + let element = PureXML.Model.Element( + "a", + attributes: [.init("href", "x?b=1&c=2")], + children: [.text("1 < 2")], + ) + let xml = PureXML.serialize(.element(element), options: .compact) + #expect(xml == "1 < 2") + } + + @Test("Pretty printing indents nested elements") + func test_prettyPrintsNested() { + let inner = PureXML.Model.Element("title", children: [.text("Guide")]) + let outer = PureXML.Model.Element("book", children: [.element(inner)]) + let xml = PureXML.serialize(.element(outer)) + #expect(xml == "\n Guide\n\n") + } + + @Test("CDATA and comments round-trip verbatim") + func test_emitsCDATAAndComments() { + #expect(PureXML.serialize(.cdata("a") + #expect( + PureXML.serialize(.processingInstruction(target: "xml-stylesheet", data: "href=\"s.css\"")) + == "", + ) + } +} diff --git a/Tests/ValidationTests.swift b/Tests/ValidationTests.swift new file mode 100644 index 0000000..c7ef740 --- /dev/null +++ b/Tests/ValidationTests.swift @@ -0,0 +1,26 @@ +@testable import PureXML +import Testing + +@Suite("Validation") +struct ValidationTests { + @Test("Duplicate attribute is an error") + func test_duplicateAttributeIsError() { + let element = PureXML.Model.Element( + "input", + attributes: [.init("id", "a"), .init("id", "b")], + ) + let issues = PureXML.Validation.Validator().collect(.element(element)) + #expect(issues.count == 1) + #expect(issues.first?.severity == .error) + } + + @Test("Unique attributes validate cleanly") + func test_uniqueAttributesValidate() throws { + let element = PureXML.Model.Element( + "input", + attributes: [.init("id", "a"), .init("name", "b")], + ) + let issues = try PureXML.validate(.element(element)) + #expect(issues.isEmpty) + } +} diff --git a/docs/rules/README.md b/docs/rules/README.md new file mode 100644 index 0000000..654b24c --- /dev/null +++ b/docs/rules/README.md @@ -0,0 +1,22 @@ +# PureXML Rules + +Load these rules before changing code: + +- `code-style.md` +- `namespacing.md` +- `cross-platform.md` +- `testing.md` +- `verification.md` +- `commits.md` + +Project-specific overrides: + +- PureXML uses a root `Package.swift`. +- Sources live directly under `Sources`. +- Tests live in `Tests` (test target `PureXMLTests`). +- `Package.swift` must keep `dependencies: []`. +- Public API must live under the `PureXML` namespace tree. +- The package must build on macOS, Linux, Windows, and WASI. + +The broader rule files are retained for detailed guidance. When they include +generic examples, apply the PureXML-specific overrides above. diff --git a/docs/rules/code-style.md b/docs/rules/code-style.md new file mode 100644 index 0000000..f3cfabe --- /dev/null +++ b/docs/rules/code-style.md @@ -0,0 +1,362 @@ +# Code style: namespacing and structure + +The operational namespacing discipline for PureXML: folder mirroring, file naming, anchor placement, one-type-per-file, reserved names, namespace-vs-module collisions. + +Swift does not have true namespaces, so we simulate them with root types and extensions. + +For the narrower question "should the anchor be `enum`, `struct`, or `class`?", see [namespacing.md](namespacing.md) (the type-choice canonical rule). This file covers the broader operational discipline. + +## Namespacing discipline (mandatory) + +**Every public type lives under a struct or enum namespace that mirrors its folder on disk.** No public type stays at file scope. The qualified name carries module + folder + role; reading `Module.Sub.Leaf` should be enough to know where the type lives and what it does. + +This applies to every Swift project. The PureXML-style root + sub-namespace pattern below is one specific instance of this discipline. + +### Core rules + +1. **No exceptions.** Every `public struct / enum / class / actor / protocol` declared at file scope MUST be wrapped in an `extension { ... }` block (or declared inside a nested type). Free-standing top-level public types are not allowed. + +2. **Mirror the folder.** When a folder `Sources/Foo/Bar/` holds a type `X`, the type lives at `Foo.Bar.X`. The namespace tree is a literal reflection of the file system tree. + +3. **No name repetition.** When the parent namespace already supplies a word, drop it from the type's bare name. Names should not carry redundant context. + + | Before | After | Reason | + |---|---|---| + | `TileError` | `Tile.Error` | parent says `Tile` | + | `RenderProgress` | `Render.Progress` | parent says `Render` | + | `ParserToken` | `Parser.Token` | parent says `Parser` | + | `BuildCommand` | `Build.Command.Run` | both `Build` and `Command` are supplied | + | `SiteBuildCommand` | `Build.Command.Site` | drops `Build` AND `Command` | + +4. **Subnamespace anchor.** Each subnamespace MUST be declared exactly once in a single anchor file (typically `.swift` next to its sibling subnamespaces). Empty enums are the canonical form. The root namespace file is a *map*, not an implementation. + +5. **Concrete types via extensions.** Types implement via `extension Namespace.Sub { public struct/enum/class/actor/protocol X { ... } }`. Conformances (`Codable`, `Sendable`, `Identifiable`, etc.) MAY be separate extensions on the qualified path: `extension Namespace.Sub.X: Codable { ... }`. + +6. **`public extension` for protocol defaults.** Default-implementation extensions on a nested protocol use the qualified path: `public extension Module.Sub.MyProtocol { func defaultImpl() { ... } }`. Bare `extension MyProtocol` no longer resolves once the type is nested. + +### Cross-cutting namespaces + +Some concepts span multiple modules, e.g. *Tile* touches parsing, rendering, indexing, and formatting. When the cross-cutting concept is more semantically primary than the per-module organisation, lift the related types into a cross-cutting namespace **with a sub-namespace per source module**: + +```swift +Tile +├─ Parse (was Parse.TileParser) +│ └─ Parser +├─ Core (was Core.TileCatalog etc.) +│ ├─ Catalog +│ ├─ Entry +│ └─ Statistics +├─ Render (was Render.TileRenderService etc.) +│ ├─ Service +│ ├─ Query +│ └─ Result +├─ Index (was the entire TileIndex SPM target) +└─ Format (was Format.Tile*) + ├─ Markdown + ├─ JSON + └─ Text +``` + +The sub-namespace name preserves provenance (which module the type used to live in). The cross-cutting namespace lives in the lowest common ancestor that every consumer already imports, typically a `Shared*` foundation target. + +### Command-tool pattern + +Subcommand types in CLI binaries (`AsyncParsableCommand` conformers) nest under a `Command` namespace: + +```swift +// In the main CLI binary: +enum Command {} // declared in Commands/Command.swift +extension Command { + struct Build: AsyncParsableCommand { ... } // was BuildCommand + struct Serve: AsyncParsableCommand { ... } // was ServeCommand + struct Watch: AsyncParsableCommand { ... } // was WatchCommand +} + +// In sibling CLI binaries, the outer namespace is the tool's own name: +enum Release { + enum Command {} // declared in ReleaseTool/Release.swift +} +extension Release.Command { + struct Bump: AsyncParsableCommand { ... } // was BumpCommand + struct Database: AsyncParsableCommand { ... } // was DatabaseReleaseCommand +} +``` + +The root dispatcher (the `@main AsyncParsableCommand` that holds `subcommands: [...]`) stays at file scope; it's not a subcommand. `swift-argument-parser` conformance stays on every struct. + +### Namespace-vs-module collisions + +When a nested namespace shares a name with an imported SPM target (e.g. the CLI declares `Command.Render` but also imports the `Render` SPM target), Swift's name lookup checks enclosing types before imported modules. Bare `Render.Type` inside any `extension Command { ... }` scope resolves to the local subcommand struct, not the module. + +Fix: declare **one module-internal typealias** in a small alias file at the consuming target's root, pinning the SPM target under a renamed identifier: + +```swift +// Sources/CLI/RenderModuleAlias.swift +import Render +typealias RenderModule = Render +``` + +Then call sites in the affected target write `RenderModule.Type` for module-level references. One declaration covers every consumer in the target; no per-file typealiases. + +Same pattern for any `Module / Namespace` name clash. + +### Same leaf name under different roots + +Two unrelated namespaces under different roots MAY share a leaf name. Example: `Parser.CSV` (top-level format-parser namespace holding `CSV`/`HTML`/`XML`/etc.) plus `Core.Parser.Encoding` (parser-primitives sub-namespace under `Core`). Swift resolves on the fully-qualified path; the compiler never gets confused. The only friction is the cognitive cost at the callsite of remembering which root a reader means. + +When this emerges, prefer **renaming one of the two roots** to a more specific concept (e.g. `Core/Parser/` to `Core/Parsing/` so the leaf names diverge) over inserting per-file disambiguating typealiases. Typealiases create a per-target rename that has to be maintained; folder renames are cheap once-only moves. + +If renaming is genuinely not worth it (e.g. the two roots have stable independent meanings and most readers never see them in the same file), the collision is acceptable. The case is *cognitive*, not *syntactic*. + +### Reserved names + +- **`Protocol`** is a reserved member on every type expression (the metatype literal `T.Protocol`). A namespace enum cannot be named `Protocol`. Use `Protocols` (plural), `Wire`, or `Spec`. The folder on disk MAY still be named `Protocol/`; only the Swift-level namespace name diverges. +- **`Error`** as a nested type name collides with `Swift.Error` in `catch` patterns. Choices: + - Keep as a sibling: `Module.Error` (e.g. `Tile.RenderError`) + - Or nest with full Swift.Error qualification: `extension Module. { public enum Error: Swift.Error, ... }`, and use `catch let error as Module..Error` explicitly at call sites +- **Self-naming sub-namespace conflict.** If a sub-namespace and the type inside share a name (e.g. `Transport` namespace holding a `Transport` protocol), rename the inner type (`Transport.Channel`, `Transport.Service`). Don't try `Transport.Transport`. + +### Multi-target and mono-repo behaviour + +Multiple SwiftPM targets MAY contribute additional concrete types under the same shared root namespace via extensions: + +```text +PureXML.Parser.Model.Token // from PureXMLCore +PureXML.Generator.Command // from PureXMLCLI +PureXML.Parser.Scanning.Scanner // from PureXMLTools +``` + +Targets MUST NOT redefine conflicting namespace trees, and the root namespace meaning MUST be identical across all modules. + +## Root + sub-namespace pattern (PureXML style) + +This is one specific instance of the namespacing discipline above. Use it as the canonical example when designing a new project's namespace tree. + +A project MAY use a hierarchical namespace tree where: + +- A root namespace enum defines the domain +- Sub-namespaces are nested enums inside it +- All concrete types live in extensions on the leaf namespace + +### Root namespace with nested sub-namespaces + +The root namespace file MUST define the full tree of namespace enums, and MUST contain only namespaces (no concrete types). + +Example: + +```swift +/// Root namespace for all PureXML types across all modules +public enum PureXML { + /// Model types for tile metadata and structure + public enum Model {} + + /// Parser types for processing tiles + public enum Parser { + /// Model types for parsing (tokens, transformations, etc.) + public enum Model {} + + /// Metadata parsing types + public enum Metadata {} + + /// Resolution and variable context types + public enum Resolution {} + + /// Scanning and tile discovery types + public enum Scanning {} + + /// Tree building and hierarchy types + public enum Tree {} + + /// Content and variable parsing types + public enum Content {} + } + + /// Generator types for writing output + public enum Generator {} +} +``` + +Rules: + +- Root namespace = one file (e.g. `PureXML.swift`) +- Only namespace enums inside (no concrete logic) +- No stored properties, no methods, no initializers, no nested concrete types +- Doc comments are allowed and encouraged + +This file is a map, not an implementation. + +### Concrete types via extensions on leaf namespaces + +Concrete model, parsing, resolving, generating, or utility types MUST be implemented using `extension` on the leaf namespace where the type belongs. + +Example: + +```swift +// Token.swift +import Foundation +import Models + +extension PureXML.Parser.Model { + /// Represents a parsed piece of tile content + public enum Token: Equatable, Sendable { + case text(String) + case variable(VariableToken) + } +} + +extension PureXML.Parser.Model.Token: CustomStringConvertible { + public var description: String { + switch self { + case .text(let str): return "Text(\"\(str)\")" + case .variable(let token): return "Variable(\(token))" + } + } +} +``` + +Rules: + +- Implementation and conformances MUST be extensions +- Type lives under the correct semantic path, e.g.: + - `PureXML.Model` + - `PureXML.Parser.Model` + - `PureXML.Parser.Metadata` + - `PureXML.Generator` +- Conformances (`Codable`, `Sendable`, `CustomStringConvertible`, etc.) MAY be separate extensions + +### File layout for PureXML-style namespacing + +Files SHOULD be placed to reflect the namespace tree. Two equivalent conventions: + +**Folder-based approach**: the folder tree mirrors the namespace tree, one file per leaf type: + +```text +PureXML/ + PureXML.swift // namespace anchor: public enum PureXML {} + Parser/Model/Token.swift // extension PureXML.Parser.Model { public enum Token {} } + Parser/Scanning/Scanner.swift // extension PureXML.Parser.Scanning { public struct Scanner {} } + Generator/Writer.swift // extension PureXML.Generator { public struct Writer {} } +``` + +**`..swift` approach**: flat folder, file name encodes the namespace path with dots: + +```text +PureXML.swift // namespace anchor: public enum PureXML {} +PureXML.Parser.Model.Token.swift // extension PureXML.Parser.Model { public enum Token {} } +PureXML.Parser.Metadata.Header.swift // extension PureXML.Parser.Metadata { public struct Header {} } +``` + +Within a single SPM target, pick ONE pattern and stay with it. + +### File-naming rule for namespace extensions (mandatory) + +When a file contains `extension { public X { ... } }`, the file name MUST be `..swift`, the dot-separated qualified path of the type. This applies regardless of whether the type would also be unique without the `.` suffix. + +Examples: + +- `extension Indexer { public enum TilesService { ... } }` to `Indexer.TilesService.swift` +- `extension Indexer { public enum PackagesService { ... } }` to `Indexer.PackagesService.swift` +- `extension Tile.Index { public actor Builder { ... } }` to `Tile.Index.Builder.swift` +- `extension Core.Protocols { public protocol ContentFetcher { ... } }` to `Core.Protocols.ContentFetcher.swift` + +Dots match the Swift qualified name exactly: `Tile.Index.Builder` lives at `Tile.Index.Builder.swift`. The older `+.swift` convention (with a `+` separator) is deprecated; new files MUST use dots, and pre-existing `+` files SHOULD be renamed when touched. + +Why uniformity even when not strictly needed: + +- The namespace path is visible from the file listing without opening any file. +- Adding a second type to the same namespace later doesn't require renaming the first. +- Search and tooling (Xcode's filter, `rg --files-with-matches`, the project navigator) work as a flat index on the qualified path. + +The only file in a namespace folder that does NOT carry `.` is the **namespace anchor**: + +- `PureXML.swift` declaring `public enum PureXML { /* sub-namespaces */ }` +- `Tile.swift` declaring `public enum Tile { public enum Parse {} public enum Core {} ... }` + +Anchor files contain only namespace enums, never concrete types. + +#### Namespace anchor file placement (mandatory) + +The anchor file for a namespace MUST live at the **root of the namespace's owning folder**, not buried in a sub-target folder. + +When `` is the umbrella for several SPM sub-targets that each live in a child folder, the anchor file `.swift` goes at the parent folder root, alongside (not inside) the sub-target folders. The sub-target whose SPM `path:` spans the parent folder picks up the anchor file as part of its sources. + +Concrete example, a `PureXML` namespace umbrella: + +```text +Sources/PureXML/ +├── PureXML.swift // namespace anchor: extension PureXML { ... } <-- AT ROOT, not Core/PureXML.swift +├── Client/ // PureXMLClient SPM target +├── Core/ // PureXMLCore SPM target (path includes ../PureXML.swift) +│ ├── Protocol/ +│ ├── Server/ +│ └── Transport/ +├── SharedTools/ // PureXMLSharedTools SPM target +└── Support/ // PureXMLSupport SPM target +``` + +The PureXMLCore target's `Package.swift` entry uses `path: "Sources/PureXML"` with `exclude: ["Client", "SharedTools", "Support"]` so it picks up the anchor file plus the `Core/` subtree. Every sibling target (Client, SharedTools, Support) depends on PureXMLCore in production, so the `PureXML` namespace anchor reaches them through that dep. + +Why this matters: + +- Reading the file listing immediately tells you "PureXML is the umbrella here": the anchor sits visually at the top. +- Moving an anchor into `Core/PureXML.swift` (or any other sub-target folder) hides the umbrella relationship behind one extra `cd` and one extra mental indirection. +- The same parent-folder pattern applies to `Shared` (anchor at `Sources/Shared/Shared.swift`, not `Sources/Shared/Constants/Shared.swift`), and to any future grouped target family. + +For cross-cutting namespaces (e.g. a `Tile` namespace that touches multiple SPM targets and is not owned by any single folder), the anchor lives in the lowest-leaf foundation target that every consumer reaches, AT THAT TARGET'S FOLDER ROOT, not in a sub-folder: e.g. `Sources/Shared/Tile.swift` (alongside `Shared.swift`), not `Sources/Shared/Constants/Tile.swift`. + +#### One non-private type per file (mandatory) + +Each file contains **exactly one** `public`, `package`, or `internal` (default-visibility) top-level type. Private/fileprivate helper types MAY co-locate with the main type when they exist solely to support it. This is enforced by `scripts/check-namespacing.sh` locally (pre-push) and in CI; see [verification.md](verification.md). + +Strict reading: + +- A file `Foo.Bar.X.swift` declares exactly one of `extension Foo.Bar { public enum X { ... } }`, `... public struct X`, `... public protocol X`, `... public actor X`, `... public class X`, etc. +- Inner / nested types DECLARED INSIDE the main type (`extension Foo.Bar.X { public enum Error { ... } }` or `extension Foo.Bar.X { public struct Stats { ... } }`) MAY live in the same file. Each nested type also goes in its own file once it grows beyond ~50 lines or gains independent reuse. +- File-private decode-only Codable models, internal-detail helper structs, etc. (anything you'd mark `private` or `fileprivate` so it cannot leak), MAY share the file. Mark them with the access modifier explicitly; don't rely on default-internal access. + +Anti-pattern to avoid: + +```swift +// Bad: Core.Parser.HTML.swift declares two unrelated public types +extension Core.Parser { + public struct HTML: ContentTransformer { ... } +} + +extension Core.Parser { + public struct XML: ContentTransformer { ... } // <-- belongs in its own file Core.Parser.XML.swift +} +``` + +Fix: + +```swift +// Core.Parser.HTML.swift: one type +extension Core.Parser { + public struct HTML: ContentTransformer { ... } +} + +// Core.Parser.HTML.Error.swift: nested type that grew (or extract inline if small) +extension Core.Parser.HTML { + public enum Error: Swift.Error { ... } +} + +// Core.Parser.XML.swift: sibling type, own file +extension Core.Parser { + public struct XML: ContentTransformer { ... } +} +``` + +Acceptance check: + +```bash +for f in $(find Sources -name "*.swift"); do + count=$(grep -cE "^(public |package |internal )?(actor|struct|enum|protocol|class|final class) [A-Z]" "$f") + [ "$count" -gt 1 ] && echo "$count $f" +done +``` + +Output MUST be empty; any reported file is a violation. Add the `private`/`fileprivate` modifier to helper types so they drop out of the count, or split the file. + +#### File renames ship in their own PR + +Type renames (`TilesIndexerService` to `Indexer.TilesService`) and file renames (`TilesIndexerService.swift` to `Indexer.TilesService.swift`) are different PRs. Type-rename first; easier review surface, can revert without filesystem churn. File-rename follows once the type change is settled. diff --git a/docs/rules/commits.md b/docs/rules/commits.md new file mode 100644 index 0000000..150804c --- /dev/null +++ b/docs/rules/commits.md @@ -0,0 +1,381 @@ +# Git Commit Framework + +The commit-message format for PureXML: Conventional Commits, imperative mood, one logical change per commit. + +Create meaningful, consistent git commits following the Conventional Commits specification. Every commit must clearly communicate the what, why, and impact of changes for collaboration and automated tooling. + +## Core rules + +### Rule 1: Commit format + +Use this exact format: +``` +(): + +[optional body] + +[optional footer(s)] +``` +- MUST use lowercase for type +- MUST use present tense, imperative mood +- MUST NOT end description with period +- MUST limit first line to 72 characters + +### Rule 2: Commit types + +Select the correct type: +- `feat`: New feature or functionality +- `fix`: Bug fix or error correction +- `docs`: Documentation changes only +- `style`: Code style/formatting (no logic change) +- `refactor`: Code restructuring (no behavior change) +- `perf`: Performance improvements +- `test`: Test additions or corrections +- `build`: Build system or dependencies +- `ci`: CI/CD configuration changes +- `chore`: Maintenance tasks + +### Rule 3: Breaking changes + +Indicate breaking changes: +- MUST add `!` after type/scope for breaking changes +- MUST include `BREAKING CHANGE:` in footer +- MUST explain migration path + +### Rule 4: Commit scope + +Include relevant scope: +- Use component/module name +- Use feature area +- Keep consistent across project +- Omit only if change is truly global + +## Commit type decision tree + +``` +What kind of change are you making? +|- Adding new capability? +| |- User-facing feature? -> feat +| |- Developer tool? -> build/chore +| +- Test coverage? -> test +|- Fixing something broken? +| |- Bug in code? -> fix +| |- Typo in docs? -> docs +| +- Test failure? -> test/fix +|- Changing existing code? +| |- Improving performance? -> perf +| |- Restructuring code? -> refactor +| |- Formatting only? -> style +| +- Updating dependencies? -> build ++- Project maintenance? + |- CI/CD changes? -> ci + |- Documentation? -> docs + +- Other tasks? -> chore +``` + +## Commit message patterns + +### Pattern 1: Feature commits + +```bash +# RULE: New features require clear user benefit +feat(auth): add OAuth2 authentication support + +# RULE: Include implementation details in body +feat(api): implement rate limiting for API endpoints + +Adds configurable rate limiting using Redis: +- 100 requests per minute for anonymous users +- 1000 requests per minute for authenticated users +- Customizable limits per API key + +Closes #234 +``` + +### Pattern 2: Bug fix commits + +```bash +# RULE: Describe what was broken and now works +fix(parser): handle empty input without crashing + +# RULE: Reference issue numbers +fix(ui): correct button alignment on mobile devices + +The submit button was overlapping with the cancel button +on screens smaller than 375px. Added proper flex spacing. + +Fixes #456 +``` + +### Pattern 3: Breaking change commits + +```bash +# RULE: Use ! and BREAKING CHANGE footer +feat(api)!: change authentication from cookies to JWT tokens + +BREAKING CHANGE: API now requires Bearer token authentication. +Cookie-based auth has been removed. Update clients to send +Authorization header with JWT token. + +Migration guide: https://docs.example.com/migration/v2 +``` + +### Pattern 4: Refactoring commits + +```bash +# RULE: Explain why, not just what +refactor(database): extract query builder into separate class + +Improves testability and reduces coupling between +repository and database layers. No functional changes. + +# RULE: Keep behavior identical +refactor(utils): use native Array methods instead of lodash + +Removes lodash dependency for array operations. +All tests pass without modification. +``` + +### Pattern 5: Documentation commits + +```bash +# RULE: Be specific about what was documented +docs(readme): add installation instructions for Windows + +# RULE: Include scope for API docs +docs(api): document rate limiting headers and status codes +``` + +### Pattern 6: Performance commits + +```bash +# RULE: Include metrics when possible +perf(search): optimize full-text search query + +Reduces search time from ~500ms to ~50ms by adding +compound index on (title, content, created_at). + +Benchmark results: +- Before: 487ms avg (n=1000) +- After: 52ms avg (n=1000) +``` + +## Commit scope guidelines + +### Scope selection strategy + +```markdown +# RULE: Use consistent, meaningful scopes +Common scope patterns: +- Component name: (Button), (Modal), (Form) +- Feature area: (auth), (payment), (search) +- Layer: (api), (db), (ui), (service) +- File type: (config), (types), (tests) + +# RULE: Omit scope only for truly global changes +Examples when to omit: +- Project-wide dependency updates +- Global configuration changes +- Cross-cutting refactors +``` + +### Multi-file changes + +```bash +# RULE: Use most specific common scope +# Changed: auth/login.ts, auth/logout.ts, auth/session.ts +feat(auth): add session timeout handling + +# RULE: Use general scope for many areas +# Changed: multiple unrelated files +refactor: update imports to use path aliases +``` + +## Commit body guidelines + +### When to include a body + +```markdown +Include body when: +- Change requires explanation +- Multiple issues addressed +- Breaking changes introduced +- Performance metrics available +- Complex implementation details +- External references needed +``` + +### Body format rules + +```bash +# RULE: Wrap at 72 characters +# RULE: Separate body from subject with blank line +# RULE: Use bullet points for multiple items + +fix(cache): prevent memory leak in LRU cache implementation + +- Add proper cleanup in cache eviction +- Implement WeakMap for object references +- Add memory limit configuration option + +The previous implementation held strong references to evicted +items, preventing garbage collection. This change ensures +proper memory management while maintaining O(1) operations. + +Fixes #789 +``` + +## Commit footer patterns + +### Issue references + +```bash +# RULE: Use correct keywords +fix(api): validate input before processing + +Fixes #123 # Closes the issue +Closes #456 # Also closes the issue +Resolves #789 # Also closes the issue +See #101 # References without closing +Related to #102 # References without closing +``` + +### Co-authors + +```bash +# RULE: Credit all human contributors +feat(ui): implement dark mode toggle + +Pair programmed the CSS architecture and state management. + +Co-authored-by: Jane Doe +Co-authored-by: Bob Smith +``` + +### Review references + +```bash +# RULE: Link to code review discussions +refactor(engine): simplify state machine logic + +See detailed discussion in PR #234 about the +approach and alternatives considered. + +Reviewed-by: Alice Johnson +``` + +## Commit validation checklist + +Before committing, verify: + +- [ ] Type is correct (feat/fix/docs/etc.) +- [ ] Scope reflects affected area +- [ ] Description in imperative mood +- [ ] Description under 72 characters +- [ ] No period at end of description +- [ ] Breaking changes marked with ! +- [ ] BREAKING CHANGE footer if needed +- [ ] Body explains why (not just what) +- [ ] Body wrapped at 72 characters +- [ ] Issue references use correct keywords +- [ ] Co-authors credited properly +- [ ] Commit is atomic (one logical change) +- [ ] All tests pass +- [ ] No debug code or stray logging + +## Common mistakes to avoid + +### DON'T: Use past tense +```bash +# WRONG +feat(auth): added login functionality + +# RIGHT +feat(auth): add login functionality +``` + +### DON'T: Be vague +```bash +# WRONG +fix: fix bug +chore: update stuff +refactor: changes + +# RIGHT +fix(parser): handle null input in JSON parser +chore(deps): update React from 17.0.2 to 18.2.0 +refactor(auth): extract token validation to middleware +``` + +### DON'T: Combine unrelated changes +```bash +# WRONG +feat(ui): add dark mode and fix login bug and update deps + +# RIGHT, split into separate commits: +feat(ui): add dark mode toggle +fix(auth): resolve login redirect issue +build(deps): update React and TypeScript +``` + +### DON'T: Forget breaking change notation +```bash +# WRONG +feat(api): change response format + +# RIGHT +feat(api)!: change response format to follow JSON:API spec + +BREAKING CHANGE: API responses now use JSON:API format. +Old format: { data: [...] } +New format: { data: [...], meta: {}, links: {} } +``` + +## Integration with tools + +### Commitlint configuration + +```javascript +// RULE: Enforce rules automatically +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'scope-enum': [2, 'always', [ + 'api', 'auth', 'build', 'ci', 'core', + 'db', 'deps', 'docs', 'ui', 'utils' + ]], + 'body-max-line-length': [2, 'always', 72], + 'header-max-length': [2, 'always', 72] + } +}; +``` + +## Branch naming rules + +### Branch name format + +```bash +# RULE: Use consistent branch naming +/- + +# Examples: +feat/123-add-oauth-support +fix/456-resolve-memory-leak +chore/update-dependencies +release/v2.0.0 +hotfix/critical-security-patch +``` + +### Branch type mapping + +```markdown +Branch types should match commit types: +- feat/* -> Feature branches +- fix/* -> Bug fix branches +- docs/* -> Documentation updates +- refactor/* -> Code refactoring +- test/* -> Test additions +- chore/* -> Maintenance tasks +``` + +See docs/rules/git-discipline.md for branch, PR, label, and remote conventions that build on this format. diff --git a/docs/rules/concurrency.md b/docs/rules/concurrency.md new file mode 100644 index 0000000..2c0a5a4 --- /dev/null +++ b/docs/rules/concurrency.md @@ -0,0 +1,235 @@ +# Swift 6 Strict Concurrency + +Concurrency posture for PureXML and PureXML: immutability first, sparse actors, escape hatches always justified, grounded in the Swift Evolution proposals. + +Triggers: `async`, `await`, `actor`, `Sendable`, `@MainActor`, `Task`, `nonisolated`, `sending`, `AsyncSequence`, `AsyncStream`, `withTaskGroup`, `@unchecked`, isolation, data race, cross-actor. + +**Canonical source: Swift Evolution proposals.** Each rule below points at the proposal(s) that introduced and justify the feature. When in doubt about a rule, re-read the proposal's Motivation section. That is the load-bearing context, not the rule's summary here. + +## Overall posture + +Strict concurrency is achievable without region-based isolation, complex task hierarchies, or pervasive locking. The discipline is: + +1. **Immutability first.** Default to value-typed structs that get implicit `Sendable`. Reach for `actor` only when global mutable state genuinely exists. +2. **Sparse actor use.** Reserve `actor` for cross-task shared state (transports, file watchers, registries). Don't actor-ify every stateful type. +3. **`@MainActor` is rare in CLI/library code.** Wrap UI-touching types only. Don't smear `@MainActor` across non-UI code to "make it compile." +4. **`@unchecked Sendable` and `nonisolated(unsafe)` are escape hatches.** Every use carries a one-line comment justifying why it is safe. + +## Sendable + +**Rule.** Implicit > explicit > `@unchecked`. + +- Value types whose stored properties are all `Sendable` are **implicitly** `Sendable`. Don't restate. +- Add explicit `: Sendable` on **public** value types so the API surface declares intent: + ```swift + public struct Progress: Sendable { + public let current: Int + public let total: Int + } + ``` +- Error enums: always explicit. +- `@unchecked Sendable` only for wrappers around known-safe non-value types, with a one-line justification: + ```swift + // URLSession is thread-safe; this wrapper is stateless. + struct ContentFetcher: @unchecked Sendable { ... } + ``` +- Never combine `@unchecked Sendable` with hand-rolled `NSLock` / `DispatchQueue` in new code. If you need locks, use `actor`. + +**Why (SE-0302, Swift 5.5).** `Sendable` was introduced because passing arbitrary references across isolation boundaries makes data-race safety impossible to verify locally. The protocol turns *"is this safe to send across a concurrency domain?"* into a property the compiler can check by induction on stored properties. Implicit conformance for value types is the design intent: most structs should be `Sendable` without ceremony, because most value types compose `Sendable` parts. Explicit annotation on public types is for API clarity, not for the compiler. + +**Why (SE-0418, Swift 6.0): inferred `Sendable` for methods and key path literals.** Function values and key paths needed parallel inference. Before SE-0418, you had to mark every closure parameter `@Sendable` by hand even when the closure captured only `Sendable` state. Rule of thumb after this proposal: if every captured / referenced type is `Sendable`, the function is `Sendable`. Bookkeeping cost goes to zero. + +**Why (SE-0463, Swift 6.2): Obj-C completion handlers as `@Sendable`.** Objective-C completion handlers were imported without `@Sendable`, so calling Swift code from an ObjC framework had a hole in its concurrency proof. Swift 6.2 closes the hole. Concrete impact: bridging code that was previously `@unchecked Sendable` to silence the warning can now drop the `@unchecked`. + +**Why (SE-0331, Swift 5.6): unsafe pointers are NOT Sendable.** `Unsafe(Mutable)(Buffer)Pointer` originally conformed to `Sendable` unconditionally. SE-0331 removed that conformance because the inference rules made arbitrary types accidentally `Sendable` when they contained an unsafe pointer to non-`Sendable` data. Lesson: `Sendable`'s safety model only holds if every conformance is honest. Don't add `@unchecked Sendable` to wave away a real race. + +## Actors + +**Rule.** `actor` is for **cross-task shared mutable state**: transports, file watchers, registries with concurrent readers/writers. Not for everyday data. + +- Don't actor-ify data pipelines. A struct that gets copied is a better default than an actor that gets awaited. +- Inside an `actor`, methods are isolated by default. Mark synchronous reads of immutable storage or global resources as `nonisolated`: + ```swift + actor Screen { + let columns: Int // immutable, captured at init + nonisolated var size: (Int, Int) { // POSIX ioctl, no actor state + var ws = winsize() + _ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) + return (Int(ws.ws_col), Int(ws.ws_row)) + } + } + ``` + +**Why (SE-0306, Swift 5.5).** Actors exist because mutable state shared across concurrent contexts is the canonical source of data races. Before actors, the Swift solution was *"don't share mutable state"* (value semantics) or *"use a lock"* (manual). Neither composes with `async/await`. An `actor` provides a structured, compiler-enforced way to express "this state is mutable but accessed serially through a single isolation domain." The serialisation is the safety guarantee; reentrancy (multiple suspended tasks on one actor) is the cost. + +The reentrancy point matters for design: an `actor`'s state can change *between* `await` points inside the actor. Don't write `actor` methods that assume invariants survive a suspension. Re-read after every `await`. + +**Why (SE-0327, Swift 5.10): actor initialisation.** Originally, actor `init` rules were strict: every stored property had to be initialised before any method call. SE-0327 relaxed this so an actor's `init` can be synchronous and call its own (nonisolated) methods. Practical impact: actor init is *not* isolated until the first hop, so you can do synchronous setup work in `init` without `await`. + +## `@MainActor` + +**Rule.** Type-level on UI-owning types, method-level in tests, rarely on protocols. + +- **Type-level `@MainActor`** for types that own UI/AppKit/UIKit objects: + ```swift + @MainActor public final class WindowCoordinator { + private let window: NSWindow + ... + } + ``` +- **Method-level `@MainActor`** in test code where you want the test on main: + ```swift + @MainActor func testTileRendering() async throws { ... } + ``` +- **Protocol-level `@MainActor`** only when every conforming implementation truly needs main-thread isolation. Prefer leaving protocols isolation-agnostic. +- CLI / library code rarely needs `@MainActor`. If you reach for it in a non-UI target, ask whether you actually want `actor`. + +**Why (SE-0316, Swift 5.5): global actors.** Sometimes the right isolation is not per-instance (an `actor`) but per-application (the main thread). `@MainActor` is a global actor that says *"any type or method bearing this annotation runs on the main thread, full stop."* The motivating use case is UI frameworks: AppKit / UIKit are inherently main-thread, and `@MainActor` lifts that constraint into the type system so the compiler enforces it instead of runtime queue assertions. + +**Why (SE-0434, Swift 6.0): usability of global-actor-isolated types.** Before SE-0434, `@MainActor` types had awkward interactions: stored properties could not be read from non-isolated contexts even when they were `let` and `Sendable`, you could not conform `@MainActor` types to non-isolated protocols, and so on. SE-0434 closes those gaps. Practical impact: `@MainActor` is now significantly more ergonomic in Swift 6.0+. + +**Why (SE-0470, Swift 6.2): global-actor isolated conformances.** A type can now have a conformance that is itself isolated to a global actor. Lets you say "this type conforms to `Codable`, but its `Codable` requirements run on `@MainActor`." Niche, but unblocks cases where the underlying type stays UI-bound while still participating in serialization or other protocol contracts. + +## `nonisolated` and `nonisolated(unsafe)` + +**Rule.** `nonisolated` for methods that don't touch isolated state. `nonisolated(unsafe)` is the escape hatch for fire-and-forget globals or test escape hatches, always commented. + +- **`nonisolated` (safe)** on methods/properties of an isolated type that don't read isolated state: + ```swift + // POSIX file descriptors are global; no actor state read. + nonisolated func writeRaw(_ bytes: [UInt8]) { + _ = write(STDOUT_FILENO, bytes, bytes.count) + } + ``` +- **`nonisolated(unsafe)`** reserved for: + - Fire-and-forget logging state (read-mostly, last-write-wins is acceptable) + - Test-only escape hatches (stdin overrides, DI test seams) + + One-line comment justifying: + ```swift + // Logging is fire-and-forget; last-write-wins is acceptable for these flags. + nonisolated(unsafe) static var minimumLevel: Logging.Level = .info + ``` +- Never `nonisolated(unsafe)` to silence a warning on shared mutable state that needs synchronisation. Fix the design. + +**Why (SE-0412, Swift 6.0): strict concurrency for global variables.** Before SE-0412, global `var`s were a backdoor: they bypassed the actor model entirely and were a frequent source of data races in Swift 5 code. SE-0412 closes the backdoor by requiring global mutable state to be either (a) isolated to a global actor, (b) immutable (`let`), or (c) explicitly marked `nonisolated(unsafe)` with the developer accepting the responsibility. + +The `(unsafe)` marker is **intentionally ugly**. It is there so reviewers can grep for it and ask "is this race actually acceptable?" The proposal's stance: most globals should be `let`, some should be `@MainActor`-isolated, and `nonisolated(unsafe)` should be rare and justified. + +## Region-based isolation (`sending`) + +**Rule.** Almost never needed if you stay immutable-by-default. If you reach for `sending`, first ask whether the value can be made `Sendable` by construction (immutable struct of `Sendable` parts). + +**Why (SE-0414, Swift 6.0): region-based isolation.** Strict `Sendable` checking forbids passing non-`Sendable` values across isolation boundaries. But many real programs *create* a non-`Sendable` value in one isolation domain, then *hand it off* to another and stop using it themselves. That is a race-free pattern, but pre-0414, the compiler could not see it. + +Region-based isolation lets the compiler track "this value's region of access" and prove the hand-off is safe even though the type is not `Sendable`. The proposal's example: an actor builds up a non-`Sendable` mutable buffer, then transfers it to another actor. After the transfer, the original actor cannot reach the buffer; the receiving actor has exclusive access. + +**Why (SE-0430, Swift 6.0): `sending` parameter and result.** The `sending` annotation is how you express the transfer in an API signature: *"This parameter is sent to me, the caller cannot use it after this call. This result is sent to you, I cannot use it after returning."* The proposal preserves the semantics from SE-0414 but moves them from inferred regions to explicit annotations at API boundaries. + +Practical takeaway: most codebases do not need `sending`. It is for libraries that build non-`Sendable` graphs (parser ASTs, large mutable buffers) and need to hand them across actor boundaries. If your data is immutable, you do not need it. + +## Task patterns + +**Rule.** `Task { ... }` for cleanup and isolation hops. `Task.detached { @Sendable ... }` for bridging synchronous APIs. `withTaskGroup` for bounded parallelism. + +- **`Task { ... }`** inherits the caller's actor isolation: + ```swift + defer { Task { await transport.disconnect() } } // cleanup + Task { @MainActor in coordinator.onClose() } // hop to MainActor + ``` +- **`Task.detached { @Sendable ... }`** starts a new, unisolated task. Use only for bridging synchronous APIs (FileManager enumerators, blocking IO, legacy callbacks): + ```swift + Task.detached(priority: .userInitiated) { @Sendable () -> [URL] in + // sync-only FileManager work + } + ``` +- **`withTaskGroup { ... }`** for bounded parallelism. Always bound the group; never spawn unbounded. +- **Cancellation:** rely on `await` points to check cancellation implicitly. Explicit `try Task.checkCancellation()` at the top of long synchronous loops; `Task.isCancelled` for graceful bail-out without throwing. + +**Why, structured concurrency.** `Task { ... }` is the structured form: the task inherits priority and isolation from the spawning context, and its lifetime is bound to the enclosing scope. `Task.detached` is the unstructured form: a new, independent task with no inheritance. The default should be structured; reach for `Task.detached` only when you specifically need to break the inheritance chain (for example detaching from `@MainActor` to do sync work, or detaching from a parent that might be cancelled when the child should not be). + +**Why cancellation is cooperative.** Swift's cancellation model is intentionally **cooperative**: setting `Task.isCancelled` does not halt execution. The task must check the flag and exit. This is by design; abrupt cancellation makes resource cleanup impossible. Practical pattern: every `await` is a cancellation point (the framework checks for you); for long synchronous loops, add explicit `try Task.checkCancellation()` at iteration boundaries. + +## `AsyncSequence` and `AsyncStream` + +**Rule.** `AsyncStream` is the canonical channel for "events arrive over time." For single-consumer progress callbacks, prefer a `@Sendable (Progress) -> Void` closure. + +Typical shape: +```swift +actor Channel { + let messages: AsyncStream + private var continuation: AsyncStream.Continuation! + + init() { + (messages, continuation) = AsyncStream.makeStream() + } + + func send(_ m: Message) { continuation.yield(m) } +} +``` + +Back-pressure: pick a buffering policy deliberately (`.unbounded`, `.bufferingNewest(n)`, `.bufferingOldest(n)`). Default is unbounded, fine for low-rate event streams, dangerous for high-rate producers. + +**Why.** `AsyncSequence` generalises the iterator pattern over time. `AsyncStream` is the concrete bridge from "push" producers (callback APIs, delegate methods, system event sources) to "pull" consumers (`for await`). The split between protocol and concrete type lets stdlib types (network responses, file reads) and your own channels share the same `for await` syntax. + +When to **not** use `AsyncStream`: single-callback progress reporting. A `@Sendable (Progress) -> Void` closure passed at the call site is simpler, has no buffering question, and is easier to test. + +## Cross-actor calls + +**Rule.** Pass `Sendable` values, await across the boundary. Explicit `await MainActor.run { ... }` for event boundaries only. + +- Default: the compiler handles the isolation hop when you `await` an isolated method. +- Don't pass non-`Sendable` references across isolation domains. If you find yourself wanting to, either (a) make the type `Sendable`, (b) marshal data into a `Sendable` value, or (c) use `sending` (rare). +- `await MainActor.run { ... }` is for event boundaries (UI callback, terminal redraw), not as a workaround for "this code happens to need to be on main." + +**Why.** The actor model only delivers data-race safety if values crossing boundaries are `Sendable`. The compiler enforces this; you cannot "just pass it", you have to either prove safety (via `Sendable`) or accept responsibility (via `@unchecked`/`sending`). + +## Test concurrency + +**Rule.** Native `async throws` test methods. Actor-isolated state read via `await actor.value`. No `XCTestExpectation` in new code. + +- Async tests: + ```swift + @Test func renderer_returns_expected_output() async throws { + let index = await TileIndex.build(...) + let results = await index.search("foo") + #expect(results.count == 3) + } + ``` +- Actor under test: `await actorInstance.value` to read across the boundary. Use a dedicated `actor SendableCounter` test double when accumulating state from outside. +- `XCTestExpectation` / `fulfillment(of:)` only when interoperating with legacy callback APIs. Native async/await is simpler. + +## Anti-patterns + +Avoid in new Swift 6 code: + +- `DispatchQueue.main.async { ... }` → use `Task { @MainActor in ... }` or `await MainActor.run { ... }`. +- `NSLock` / `pthread_mutex` + `@unchecked Sendable` → use `actor`. +- `withCheckedContinuation` / `withUnsafeContinuation` → only when wrapping legacy callback APIs. +- `@MainActor` smeared across non-UI types to silence warnings → fix the design. +- `nonisolated(unsafe)` to bypass a warning without thinking about the race → fix the design. +- `Task.detached` as the default → use `Task { ... }` unless you specifically need unisolated execution. + +## Vision + +The current direction of Swift concurrency is captured in *"Improving the approachability of data-race safety"* (vision document, referenced by SE-0463, SE-0466, SE-0470). The vision: data-race safety should be the default, but the cost of opting in (annotations, refactors) should keep dropping. Each Swift 6.x release closes another gap that previously forced an `@unchecked` or a `@MainActor` smear. + +When designing a new type, ask: *"will this need annotations to be `Sendable`/isolated, or can I make the design itself simple enough that the compiler infers everything?"* The vision answer is the second. + +## Cross-references + +- `dependency-injection.md` rule 4: no closure typealiases at cross-module seams. Closures as method parameters / property values are still fine; only the named cross-module typealiases are banned. +- `../decisions/point-free-dependencies.md`: open decision on the within-module Point-Free Dependencies pattern (closure-based, `@Sendable` closures). Undecided, not a rule. + +## Reading the proposals + +The rules above are summaries. The proposals' Motivation sections carry the full reasoning. When designing something non-trivial, read the relevant proposal end-to-end: + +- SE-0306: actors +- SE-0302: Sendable +- SE-0414: region-based isolation +- SE-0412: strict concurrency for globals + +The Swift Programming Language book's Concurrency chapter is the second canonical source. Apple also publishes a data-race-safety migration guide. + +When a rule above and a proposal disagree, the proposal wins. Update this file. diff --git a/docs/rules/cross-platform.md b/docs/rules/cross-platform.md new file mode 100644 index 0000000..79d7c19 --- /dev/null +++ b/docs/rules/cross-platform.md @@ -0,0 +1,565 @@ +# Cross-platform Swift (Apple + Linux + Windows + WASI) + +How to structure PureXML's Swift so the same sources build and run on Apple +platforms, Linux, Windows, and WASI without silent breakage. + +Load on demand. Triggers: `canImport`, `Linux`, `Windows`, `WASI`, +`FoundationNetworking`, `Darwin`, `Glibc`, `WinSDK`, `swift-system`, +`swift-foundation`, cross-platform, manifest, `.when(platforms:)`, `os(Linux)`, +`os(Windows)`, `os(WASI)`, `os(iOS)`, `os(macOS)`, `os(visionOS)`, `os(tvOS)`. + +Grounded in Swift Evolution and `swiftlang/swift-foundation`. The PureXML +engine targets macOS, Linux, Windows, and WASI, so it falls under Topology 3 +below. The library target must not require platform-specific subprocesses, +shell-outs, C sources, generated parsers, JavaScript tooling, or Foundation. + +## Cross-platform portability and platform seams (mandatory) + +The engine targets macOS, Linux, Windows, and WASI. Three obligations follow: + +1. Guard every platform-divergent line, preferring `#if canImport()` over `#if os(...)` so future platforms inherit the right branch. +2. Check Linux, Windows, and WASI availability before adding any dependency, + framework import, filesystem behavior, or runtime assumption. +3. Keep the core library dependency-free. If a platform-specific feature ever + becomes necessary, put it behind a protocol seam outside the parser core. + +When the same functionality needs a different implementation per platform, +abstract it behind a protocol seam; do not branch at call sites. Define a +dependency-free protocol (see [shared-protocols.md](shared-protocols.md)), +implement it once per platform, and let the composition root wire the correct +one (see [dependency-injection.md](dependency-injection.md)). The core depends +only on the protocol. + +```swift +public protocol PlatformNameService: Sendable { var name: String { get } } +#if canImport(Darwin) +let platform: any PlatformNameService = DarwinPlatformNameService() +#elseif os(Windows) +let platform: any PlatformNameService = WindowsPlatformNameService() +#else +let platform: any PlatformNameService = PortablePlatformNameService() +#endif +``` + +## Three topologies (pick one before designing) + +The rest of this rule depends on which topology your target is in. Mixing them silently is the root cause of most cross-platform breakage. + +1. **Apple-only.** iOS, macOS, plus optional visionOS/tvOS/watchOS. UI-heavy, SwiftUI or UIKit/AppKit primary. No Linux build, no server-side targets. +2. **Apple clients + Linux server (split package).** UI lives on Apple, one or more server products build to Linux. The Linux-buildable surface is narrow (typically a single server product). Apple-only stuff is wrapped in conditional product/target arrays in `Package.swift`. +3. **Cross-platform library or CLI.** Runs identically on Apple + Linux + + Windows. No UI. Probably uses URLSession-via-FoundationNetworking, + Darwin/Glibc/WinSDK conditionals, swift-system. The PureXML engine lives + here, with the stricter rule that the library target must also build for + WASI and therefore cannot rely on process spawning or platform filesystem + behavior. + +A "Linux UI" topology technically exists but is not production-grade. Do not try to render SwiftUI on Linux; if a server needs UI, ship HTTP responses (templates, JSON for an SPA), not native widgets. + +## Three layers of platform conditioning + +Each topology uses some combination of these. The layer determines which directive is correct. + +### Layer A: `Package.swift` manifest + +Use `#if os()` only. **Never `#if canImport()`** in the manifest. The manifest is parsed eagerly; `canImport` is evaluated lazily and silently breaks Linux CI. + +Apple-only products and targets go in conditional arrays so non-Apple builds see an empty list: + +```swift +#if os(iOS) || os(macOS) +let appleOnlyProducts: [Product] = [ + .singleTargetLibrary("PureXMLUI"), + .singleTargetLibrary("PureXMLComponents"), + // ... +] +#else +let appleOnlyProducts: [Product] = [] +#endif +``` + +Same pattern for `appleOnlyTargets`. Core engine products (e.g. `PureXML`) stay unconditional. + +### Layer B: Swift source files + +Prefer `#if canImport()` over `#if os()` for framework gating. canImport is robust to new Apple platforms inheriting the framework; explicit platform lists need updating each time a new OS ships. + +Canonical use cases: + +- `#if canImport(UIKit)` for UIKit types (iOS, tvOS, visionOS) +- `#if canImport(AppKit)` for AppKit types (macOS) +- `#if canImport(FoundationNetworking)` for URLSession on Linux (see Server patterns below) +- `#if canImport(Darwin)` / `#elseif canImport(Glibc)` for low-level POSIX C (read, write, ioctl) +- `#if canImport(os)` for Apple's unified logging (`os.log`) + +Reserve `#if os()` for behaviour that genuinely varies by OS even when the same framework is present, e.g. iOS-only SwiftUI modifiers that exist alongside macOS SwiftUI. + +### Layer C: Per-target dependency conditioning + +For a target that is mostly cross-platform but has one Apple-only dependency, use `.when(platforms:)`: + +```swift +.target( + name: "TileRenderer", + dependencies: [ + "TileModels", + .product(name: "CryptoKit", package: "swift-crypto"), + ].when(platforms: [.macOS]) +) +``` + +Avoid spreading platform conditioning across both Layer A and Layer C for the same target; pick one place per concern. + +## UI cross-platform patterns + +These patterns (Patterns 1-7 and Pattern 13: iOS-only SwiftUI modifiers, UIKit/AppKit app shells, the forward-compat SDK shim, and the `@available(iOS ...)` examples) describe the planned native Apple UI app, NOT the PureXML engine. The engine targets macOS, Linux, Windows, and WASI, and has no UI. Apply these only in the app tier. + +Linux UI is out of scope (no SwiftUI on Linux). + +### Pattern 1: Apple-only UI file (outer gate) + +A SwiftUI file that has no Linux meaning gets a file-level gate: + +```swift +// PureXMLUI/MainTabs.swift +import SwiftUI + +#if os(iOS) || os(macOS) + +public struct MainTabs: View { + public var body: some View { ... } +} + +#endif +``` + +The whole file is skipped on Linux. This is the default for UI source files in an Apple-clients-plus-Linux-server topology. Without the outer gate, a Linux build pulling the same source tree hits compile errors on SwiftUI. + +### Pattern 2: iOS-only SwiftUI modifier on a cross-Apple view (inline gate) + +A view that runs on both iOS and macOS, with one modifier that exists only on iOS: + +```swift +public struct TileListView: View { + public var body: some View { + List(tiles) { ... } + #if os(iOS) + .toolbar { EditButton() } // iOS-only + .listStyle(.insetGrouped) // iOS-only + .navigationBarTitleDisplayMode(.inline) // iOS-only + #endif + } +} +``` + +Use this when the file is already inside an Apple-only outer gate (Pattern 1), and only specific SwiftUI modifiers need iOS-vs-macOS branching. Common iOS-only modifiers: `.toolbar { EditButton() }`, `.listStyle(.insetGrouped)`, `.navigationBarTitleDisplayMode()`, `.keyboardType()`, `.textInputAutocapitalization()`. + +macOS-only modifiers (rarer): window-style toolbars, `.toolbar(.windowToolbar, ...)`, `.windowResizability()`. + +### Pattern 3: UIKit/AppKit framework bridge (`canImport`) + +When the same conceptual API ships under different framework names on each platform: + +```swift +// TileColors/TileColors.swift +#if canImport(UIKit) +import UIKit +extension Color { + public static let systemBackground = Color(uiColor: .systemBackground) + public static let systemGroupedBackground = Color(uiColor: .systemGroupedBackground) +} +#elseif canImport(AppKit) +import AppKit +extension Color { + public static let systemBackground = Color(nsColor: .windowBackgroundColor) + public static let systemGroupedBackground = Color(nsColor: .underPageBackgroundColor) +} +#endif +``` + +Bridges from UIKit/AppKit primitives into unified SwiftUI types (Color, Image, Font). Use `canImport` on each branch, not `os()`. The `Color` API itself is cross-Apple; only the bridge to the framework-specific colour is platform-specific. + +### Pattern 4: Layered `canImport` outer + `os()` inner + +The combined pattern when both the framework gate AND the per-platform behaviour matter: + +```swift +// TileColors/Color+Dynamic.swift +#if canImport(UIKit) +import UIKit + +extension Color { + public static func dynamic(light: Color, dark: Color) -> Color { + Color(uiColor: UIColor { trait in + #if os(iOS) + return trait.userInterfaceStyle == .dark ? dark.uiColor : light.uiColor + #else + // tvOS, visionOS handle dynamic colours differently + return light.uiColor + #endif + }) + } +} +#elseif canImport(AppKit) +import AppKit +// macOS dynamic-color implementation +#endif +``` + +The outer `canImport(UIKit)` gates whether UIKit is available; the inner `os(iOS)` distinguishes iOS from other UIKit-using platforms (tvOS, visionOS). Useful when one framework has multiple consumers with different APIs. + +### Pattern 5: Apple-only logging via `canImport(os)` + +Apple's unified logging (`os.log`, `Logger`) does not exist on Linux. Gate on the framework, fall back to `print` on Linux: + +```swift +#if canImport(os) +import os.log + +public struct TileLogger { + private let logger: Logger + + public init(subsystem: String, category: String) { + self.logger = Logger(subsystem: subsystem, category: category) + } + + public func info(_ message: String) { logger.info("\(message, privacy: .public)") } + public func error(_ message: String) { logger.error("\(message, privacy: .public)") } +} +#else +public struct TileLogger { + public init(subsystem: String, category: String) {} + + public func info(_ message: String) { print("[INFO] \(message)") } + public func error(_ message: String) { print("[ERROR] \(message)") } +} +#endif +``` + +Using `canImport(os)` rather than `os(macOS) || os(iOS) || os(visionOS) || os(tvOS) || os(watchOS)` is the gold-standard pattern. Future Apple platforms inherit `os` automatically; explicit platform lists rot. + +### Pattern 6: SwiftUI app target per platform, shared feature package + +Separate Xcode projects for iOS and macOS apps, same SwiftUI feature package providing the root view. Keep the platform-specific app shells thin; put the shared UI in one feature package. + +### Pattern 7: UIKit (iOS) and AppKit (macOS) app shells, shared feature package + +When the app target must be UIKit (iOS scene delegate) or AppKit (NSWindowController), with the feature package providing platform-specific factory functions (`createMainWindow(for:)` / `createWindowController()`). Cross-platform abstractions for the shared types (`PlatformView`, `PlatformViewController`) live in a native-UI package. + +### Pattern 13: Forward-compat shim for unreleased Apple APIs + +When the next SDK release will ship a new SwiftUI / Apple API and you want call sites to use it now without rewriting later, build a forward-compat wrapper. Single edit when the real API ships; zero call-site changes. + +**Shape:** + +1. A wrapper struct on `View` (or whatever the host type is) that takes the wrapped content. +2. A namespace accessor as a computed property: `var upcoming: SwiftUIUpcoming`. Call sites read `view.upcoming.(...)`. +3. **Mirror types** (`UpcomingGlass`, `UpcomingGlassEffectTransition`, etc.): enums or structs with the same shape as the not-yet-shipped Apple types. They exist today so call sites can reference them; when the real types ship, a single `#if false` to real-mapping flip converts them. +4. **Fallback bodies** on the wrapper's methods that return the closest current-SDK approximation. `glassEffect` returns `content.clipShape(shape)`; `glassEffectTransition` returns `content` unchanged; etc. No call-site `if #available` ceremony needed. +5. **`#if false` blocks** holding the future mapping from mirror types to real Apple types, annotated with the future `@available(iOS 26, macOS 26, *)` (or whatever the target SDK is). When that SDK is the build target, flip `#if false` to `#if canImport()` or just remove the gate. + +**Worked example (abbreviated):** + +```swift +import SwiftUI + +public struct SwiftUIUpcoming { + public let content: Content + public init(_ content: Content) { self.content = content } +} + +@available(iOS 18, macOS 15, *) +public extension View { + var upcoming: SwiftUIUpcoming { SwiftUIUpcoming(self) } +} + +@available(iOS 18, macOS 15, *) +public enum UpcomingGlass: Equatable, Sendable { + case regular + case clear + case tinted(Color?) + case interactive(isEnabled: Bool) + // ... mirror the upcoming Apple type's cases +} + +@MainActor +@available(iOS 18, macOS 15, *) +public extension SwiftUIUpcoming where Content: View { + func glassEffect(_ glass: UpcomingGlass = .regular, in shape: some Shape = Capsule()) -> some View { + // Fallback for current SDK. Real glassEffect ships in iOS 26 / macOS 26. + content.clipShape(shape) + } +} + +// Real API mapping. Flip to `#if canImport(...)` or remove gate when iOS 26 / macOS 26 is the build target. +#if false +@available(iOS 26, macOS 26, *) +extension UpcomingGlass { + var toGlass: Glass { + switch self { + case .regular: .regular + case .clear: .clear + case .tinted(let color): .regular.tint(color) + case .interactive(let isEnabled): .regular.interactive(isEnabled) + } + } +} +#endif +``` + +**When to use:** + +- A new SwiftUI / UIKit / AppKit API is announced for the next major Apple release and you want call sites to start adopting it before you can build against the new SDK. +- The new API's shape is stable enough that you can mirror its types reasonably (single Apple beta release usually shows the final surface). +- You want a single switchover point when the new SDK ships, not a sweep through every call site. + +**When NOT to use:** + +- The API's shape is still in flux (avoid mirror-type rot). +- The fallback would be misleading (e.g. a security-sensitive API where "no-op fallback" silently weakens the contract; better to gate the call site explicitly with `if #available`). +- The codebase ships before the new SDK is final and you do not actually need pre-adoption (just wait). + +**Naming:** + +The accessor name and wrapper name are project conventions. `upcoming` and `SwiftUIUpcoming` are the defaults. Mirror types prefixed with `Upcoming` (e.g. `UpcomingGlass`) for findability. Do not use `Forward*` or `forwards`; the name is too generic and collides with view-forwarding patterns. + +**Aging:** + +When the new SDK ships: + +1. Replace `#if false` with the real availability gate (`#if canImport()` or just remove the gate if the SDK is the build minimum). +2. Replace fallback method bodies with `if #available { real API } else { fallback }`. +3. Eventually, when the deployment target moves to the new SDK, delete the wrapper entirely and rewrite call sites to use the real API directly. Plan for this; the wrapper is transitional, not permanent. + +## Server cross-platform patterns + +A Linux server topology typically uses Vapor + AsyncHTTPClient + Fluent + OpenAPI. The key design decision: **avoid URLSession on the server path entirely**. AsyncHTTPClient abstracts POSIX networking and works on Linux without source-level conditionals. + +### Pattern 8: AsyncHTTPClient over URLSession (server pattern) + +For any server-side HTTP code that needs to build on Linux, use `swift-server/async-http-client`: + +```swift +import AsyncHTTPClient +import NIOHTTP1 + +let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) +let request = HTTPClientRequest(url: "https://example.com/api") +let response = try await httpClient.execute(request, timeout: .seconds(30)) +let body = try await response.body.collect(upTo: 1024 * 1024) +``` + +No `#if canImport(FoundationNetworking)` needed. AsyncHTTPClient is the canonical cross-platform HTTP client on the server side. + +### Pattern 9: Vapor (or Hummingbird) on Linux + +Vapor + Fluent-SQLite + OpenAPI builds and runs on Linux out of the box. Manifest declares the package dependencies; no source-level platform conditionals required for the server targets. Production deployment is typically via Docker (`swift:6.0-jammy` or later). + +Anti-pattern: pulling `URLSession`-based code into a Vapor handler. The handler now needs FoundationNetworking conditional + the rest of the rule below. + +### Pattern 10: FoundationNetworking conditional (library pattern, not server) + +For cross-platform library code that uses URLSession (Topology 3), conditionally import FoundationNetworking BEFORE Foundation: + +```swift +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Foundation + +let url = URL(string: "https://example.com/api")! +let (data, response) = try await URLSession.shared.data(from: url) +``` + +`URLSession` lives in `Foundation` on Apple and in `FoundationNetworking` on Linux (where `Foundation` is `swift-corelibs-foundation` and re-exports `FoundationNetworking`). Conditionally importing `FoundationNetworking` first ensures the type is in scope on both platforms. + +Reference: `swiftlang/swift-foundation/README.md` documents the three-tier architecture (Darwin Foundation.framework / `swift-foundation` toolchain / `swift-corelibs-foundation` Linux fallback). + +### Pattern 11: Darwin / Glibc conditional for low-level C (or use swift-system) + +For raw POSIX (read, write, ioctl, stat, file descriptors): + +**Legacy pattern (still works):** + +```swift +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +var ws = winsize() +_ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) +``` + +**Preferred modern pattern, `apple/swift-system` + `CInterop`:** + +```swift +import SystemPackage + +let fd = try FileDescriptor.open("/path", .readOnly) +defer { try? fd.close() } +let bytes = try fd.read(into: buffer) +``` + +`swift-system` provides a unified, type-safe surface over Darwin/Glibc/Windows. Reference: SYS-0008 (CInterop.stat back-deploy), SE-0063 / SE-0208 (system packages). + +When to use which: legacy code or operations not covered by `swift-system` use the Darwin/Glibc canImport pattern; new code reaches for `swift-system` first. + +### Pattern 12: Logging on the server path (`canImport(os)` + print fallback) + +Pattern 5 (logging via `canImport(os)`) applies to server code too. On Linux the logger falls back to `print()`; in production Linux deployments, redirect stdout/stderr to the host's logging facility (journald, Docker logs, etc.). + +For more sophisticated logging that's cross-platform native, use `apple/swift-log` (`Logging` package, SSWG standard). Single `LogHandler` protocol works identically on Apple and Linux. + +## Linux-buildable target rules (the parity gaps) + +`swift-corelibs-foundation` (the Linux Foundation re-implementation) has improved significantly in Swift 5.9 / 6.x but still has gaps. Avoid these in any code that must run on Linux: + +- `NSKeyedArchiver` / `NSKeyedUnarchiver`. Use `Codable` + JSON instead. +- Full `NSXMLParser` / `NSXMLDocument`. Use a third-party XML parser if you need DOM-level access. +- `NSEnergyFormatter` and some specialty formatters. +- Distributed notifications, `NSHostName`. + +Prefer `swift-foundation`'s `FoundationEssentials` and `FoundationInternationalization` for new code; they ship the same implementation on every platform. + +## Test framework on cross-platform code + +Swift Testing (`@Test`, `@Suite`, `#expect`, `#require`) is platform-agnostic by design. Use it for cross-platform tests: + +```swift +import Testing + +@Test func crossPlatformBehaviour() async throws { + let result = try await renderer.render(tile) + #expect(result == .expected) +} +``` + +XCTest still works on Linux (auto-discovery since Swift 5.4; `LinuxMain.swift` no longer required), but Swift Testing avoids historical parity gaps. For new tests in a cross-platform target, default to Swift Testing. + +If a specific test needs to skip on a platform: + +```swift +@Test(.disabled(if: ProcessInfo.processInfo.environment["CI"] == "linux")) +func appleOnlyTest() { ... } +``` + +Or simpler, gate the whole test file with `#if os(...)`. Both are fine; the `.disabled` form gives the test runner a record. + +References: ST-0009, ST-0016, ST-0017, ST-0022 (Swift Testing cross-platform behaviour). + +## CI requirements + +### If your repo ships any Linux-buildable product + +Linux CI runs on every PR. Minimum job: + +```yaml +linux-build: + runs-on: ubuntu-latest + container: swift:6.0-jammy + steps: + - uses: actions/checkout@v6 + - run: swift build -c release --product +``` + +Without this, the Linux build can rot silently between deploys. The Docker production deploy is not a CI substitute; it finds breakage at deploy time, not PR time. + +### If your repo is Apple-only (Topology 1) + +macOS CI is sufficient. Optional iOS Simulator + visionOS simulator on tagged PRs. + +### Linux test job (when you have Linux-buildable tests) + +```yaml +linux-test: + runs-on: ubuntu-latest + container: swift:6.0-jammy + steps: + - uses: actions/checkout@v6 + - run: swift test --filter +``` + +Run only the test suites known to be Linux-buildable; gate the others with `#if os(iOS) || os(macOS)` at the file level. + +## Manifest discipline for split-topology repos (Topology 2) + +If your repo ships an Apple-clients-plus-Linux-server split: + +1. **Declare Apple platforms in `platforms:`**. There is no stable Linux, + Windows, or WASI entry in the SPM platforms enum; non-Apple support is + implicit when the package builds on those toolchains. For the engine, avoid + Apple-only platform declarations unless an Apple API genuinely requires a + minimum OS. A repo that also ships an Apple UI app tier adds `.iOS(.v18)` for + that tier. + +2. **Document the Linux-buildable surface.** In the repo's `README.md` / `AGENTS.md`, name the products that build to Linux: e.g. "On Linux, build the server product only: `swift build --product tiledownserver`." Otherwise a new developer running `swift build` on Linux hits confusing errors from Apple-only targets. + +3. **Wrap Apple-only products and targets in conditional arrays** (Layer A pattern above). This is the load-bearing rule that makes Layer-A discipline work. + +4. **Server-side targets stay unconditional.** Do not accidentally wrap an unconditional server product in `#if os(iOS) || os(macOS)`; that is the most common error in this topology. + +## Fallthrough discipline + +If your code runs on Linux in production, every `#if os(...)` chain that affects observable behaviour needs an explicit `#elseif os(Linux)` arm. + +Bad (User-Agent header reports "unknown" on Linux even though we ship there): + +```swift +#if os(iOS) +return "PureXML/iOS/\(version)" +#elseif os(macOS) +return "PureXML/macOS/\(version)" +#elseif os(visionOS) +return "PureXML/visionOS/\(version)" +#else +return "PureXML/unknown/\(version)" // Linux falls into "unknown" +#endif +``` + +Good: + +```swift +#if os(iOS) +return "PureXML/iOS/\(version)" +#elseif os(macOS) +return "PureXML/macOS/\(version)" +#elseif os(visionOS) +return "PureXML/visionOS/\(version)" +#elseif os(Linux) +return "PureXML/Linux/\(version)" +#else +return "PureXML/unknown/\(version)" // truly unknown future platform +#endif +``` + +The `#else` arm is reserved for platforms you genuinely do not run on; every platform you DO run on gets a named arm. Otherwise metrics, telemetry, and feature toggles silently lump Linux into "unknown." + +## Cross-references + +- `docs/rules/linux-server.md` is the server-specific operational layer (AsyncHTTPClient, swift-log, graceful shutdown, Docker). Load both together for a server repo. +- `concurrency.md` is platform-agnostic; Sendable + async/await behave identically on Apple and Linux. + +## Authoritative references + +- **Foundation architecture**: `swiftlang/swift-foundation` README, the three-tier model (Darwin / swift-foundation / swift-corelibs-foundation). +- **System packages**: SE-0063, SE-0113, SE-0208. +- **swift-system CInterop**: `apple/swift-system` Proposals/0008-backdeploy-cinterop-stat.md. +- **Swift Testing cross-platform**: ST-0009, ST-0016, ST-0017, ST-0022. +- **swift-log (SSWG logger)**: `apple/swift-log`. +- **Vapor / AsyncHTTPClient / Hummingbird**: their respective package docs. + +## Quick reference table + +| Concern | Apple-only (T1) | Apple+Linux split (T2) | Cross-platform (T3) | +|---|---|---|---| +| Package.swift platforms | `[.iOS, .macOS, ...]` | `[.iOS, .macOS]` + document Linux products | `[.iOS, .macOS, ...]`, Linux implicit | +| Manifest `#if` | rare | `#if os(iOS) \|\| os(macOS)` for Apple-only product/target arrays | Apple-only product/target arrays plus `.when(platforms:)` | +| Source files | `#if os(iOS) \|\| os(macOS)` if needed | outer gate on UI files | `#if canImport()` | +| HTTP | URLSession | AsyncHTTPClient on server, URLSession on client | `canImport(FoundationNetworking)` + URLSession | +| C interop | `import Darwin` | `import Darwin` on Apple, none on server | `swift-system` or canImport(Darwin)/Glibc | +| Logging | `os.log` directly | `canImport(os)` + `print` fallback or `swift-log` | `swift-log` or `canImport(os)` + fallback | +| Tests | Swift Testing | Swift Testing, Linux-buildable suites separate | Swift Testing | +| CI | macOS only | macOS + Linux Docker job for the Linux product | macOS + Linux + Windows + WASI | +| Linux UI | n/a | server-side: HTTP responses, not native widgets | n/a | diff --git a/docs/rules/dependency-injection.md b/docs/rules/dependency-injection.md new file mode 100644 index 0000000..982acee --- /dev/null +++ b/docs/rules/dependency-injection.md @@ -0,0 +1,144 @@ +# Dependency Injection Rules + +Canonical dependency-injection principles for PureXML and the wider PureXML codebase: no singletons, constructor injection, protocol seams. + +These are standing rules for every change and every PR. They are grounded in *Design Patterns* (Gamma, Helm, Johnson, Vlissides, 1994) and Mark Seemann's *Dependency Injection in .NET* (2011). + +## 1. No Singletons. Ever. + +- **No `static let shared = X()`**. Not on a `final class`. Not when the class is immutable. Not when *Design Patterns* (1994, p. 127) explicitly sanctions the pattern. +- **No static accessors** that reach into a process-wide config holder. That is a Service Locator (Seemann 2011, ch. 5) and it counts. +- **No "but this case is sanctioned" soft framing.** When tempted to document a Singleton as "legitimate per p. 127," stop. Do the injection. +- **Apple's `os.Logger` per-category statics** are allowed; they are Apple's idiom, not ours. +- **Internal-detail caches** (`private static let cache = Cache()` inside a static enum loader) are not the kind of Singleton being banned. They are memoization, not Service Locator, and are not reachable from consumers as a dependency. + +Why: every dependency must appear in the call graph at the type's `init` site, not be conjured from process-wide state at runtime. That makes coupling visible, testable, and removable. + +## 2. Every external collaborator goes through `init`. No exceptions. + +- Paths, factories, strategies, loggers, indexes, anything not owned by the type itself goes through the **constructor**. +- Not through method parameters at the callsite. Not through `static var` fallbacks. Not through environment lookup. +- **Pure free functions** that compute a value from arguments are fine (no state). +- **Stateful types with collaborators** take them via `init`. + +Example for PureXML: + +```swift +public struct TileRenderer { + private let loader: TileLoading + private let logger: Logging + + public init(loader: TileLoading, logger: Logging) { + self.loader = loader + self.logger = logger + } +} +``` + +## 3. Cross-module coupling via protocols, not concrete imports. + +- Code that crosses a module boundary depends on a **named protocol**, not on a concrete type imported from another module. +- The composition root (the executable target / app entry point) is the only place that holds concretes from multiple modules and wires them together. + +If and when PureXML grows into multiple SPM packages: + +- A feature/producer package may **not** `import` another feature/producer package. +- Cross-package seams are **named protocols**, either declared in a small foundation-only `*Models` package or inlined per package. +- The composition root remains the only place that imports concretes from several packages. + +## 4. No closure typealiases at cross-module seams. + +Forbidden at cross-module seams: + +```swift +public typealias TileLookup = @Sendable (String) async throws -> TileType? +``` + +Required: + +```swift +public protocol TileLookupStrategy: Sendable { + func lookup(uri: String) async throws -> TileType? +} +``` + +Reasons: a named protocol is discoverable (you can find all conformers), it forces a real type at the conformer site, and it matches the Factory Method / Strategy phrasing from *Design Patterns*. + +- This does **not** ban closures as method parameters or property values. `onProgress: (@Sendable (Progress) -> Void)?` is fine. +- It **does** ban closure-typed *named* cross-module contracts. + +## 5. Every module lifts out cleanly. + +If and when PureXML is split into multiple packages, each package should lift out of the repo without modification: + +- If a package needs a dependency to function, that dependency is **named in its public API**. +- Hidden dependencies (ambient singletons, reach-into-shared-state, undeclared transitive imports) are violations even if they currently build. +- A mechanical lift-out check copies the package plus its declared transitive dependencies to a temp directory, generates a minimal `Package.swift`, and runs `swift build`. Green means it lifts out. + +## 6. Composition root: binary name stays clean. + +If PureXML ships multiple binaries, the **executable target name** stays normal (no `Impl` suffix). The binary's external identity (filename on disk, `which` output) stays clean. + +Use an `*Impl` namespace for the source files **inside** that target where each module is concretely glued together: + +```swift +// Sources/CLI/CLIImpl.swift +public enum CLIImpl {} // namespace anchor + +// Sources/CLI/CLIImpl.LiveTileLoaderFactory.swift +extension CLIImpl { + struct LiveTileLoaderFactory: TileLoaderFactory { ... } +} +``` + +The `*Impl` namespace says "this file is where we wire concretes." Composition roots can `import` anything; they wire the universe. Library/producer code cannot import beyond its explicit allowed list. + +## 7. Keep the import contract explicit. + +If PureXML grows into multiple packages, keep a checked-in `docs/package-import-contract.md`: one row per target listing target name, allowed imports, and current state. Anything outside the allowed column is a **violation** and must be fixed in the same change. Back it with a CI script that greps `^import` per target and fails on disallowed imports. + +## 8. Foundation-only is the end state for producer packages. + +The end goal for a multi-package split: producer packages import **only external primitives** (Foundation, system frameworks, vetted third-party SSG dependencies). Zero internal package imports. Each producer declares its own protocols inline. The composition root grows to bridge between per-package protocols. + +Until then, a producer package may import: + +- Foundation primitives and system frameworks +- Its own `*Models` companion and other foundation-only `*Models` protocol-seam targets +- A shared value-type layer, if one exists + +Sequence the work: settle the singleton and static-state removal first, then pursue foundation-only producers as a separate effort. Do not fold the two together. + +## 9. No relitigating a settled principle. Just do it. + +- When the strict-DI / strict-portability path is chosen, **stop offering alternatives** and execute. Surface only **ordering** / **sequencing** questions, not principle-relitigation. +- Do not ask permission to fix obvious blockers (build failures, fatal crashes, CI breakers). Fix them. Reserve "want me to?" for genuinely debatable scope/timing calls. + +## 10. Verify before claiming done. + +- Use the project's canonical build command (`swift build`). +- Run the full test suite and cite the count: `142 / 142 pass`. "Tests pass" without a number is not evidence. +- If incremental build is acting strange (stale module errors, link errors against old signatures), clean the build and rebuild. + +## 11. Keep the roadmap current. + +Update the project roadmap (document or tracker issue) on every phase, release, or scope change. "Closed" means **shipped**, not "PR merged." + +## 12. Documentation mirrors the user-facing surface in the same change. + +When a CLI command, option, or other user-facing surface changes, update the documentation in the same change. Also update the CHANGELOG. Hand-curated, not auto-generated. + +--- + +## Reference + +- Gamma, Helm, Johnson, Vlissides, *Design Patterns* (1994). Factory Method p. 107; Strategy p. 315; Singleton p. 127 (we do not use the third one). +- Mark Seemann, *Dependency Injection in .NET* (2011). Service Locator anti-pattern ch. 5; Composition Root ch. 4. + +## Stop rule (paste at the top of any session that touches imports) + +Before adding `import X` to a producer module: + +1. Is `X` external (Foundation, system framework, a vetted third-party dependency)? Allowed. +2. Is the target an executable target / composition root? Allowed. +3. Otherwise: **STOP.** Surface the situation. The target state is foundation-only. diff --git a/docs/rules/documentation.md b/docs/rules/documentation.md new file mode 100644 index 0000000..4041ffe --- /dev/null +++ b/docs/rules/documentation.md @@ -0,0 +1,268 @@ +# Documentation Rules (DocC) + +How PureXML organizes its documentation: a single DocC catalog that is the source of truth and moves with the code in every PR. + +Every project's documentation lives in a **DocC catalog** inside the repo as its own SPM target. There is no freeform `Docs/` folder. The catalog is the single source of truth. Every PR that touches code must update the matching catalog article in the same PR. + +## Core rules + +### Rule 1: Documentation lives in a DocC catalog + +Organize repo-level documentation as a Swift-DocC catalog in a dedicated SPM target. + +- MUST create a docs-only SPM target named `{ProductName}Documentation` (Apple's own convention, see `SwiftDocCPluginDocumentation`). +- MUST contain a `.docc` catalog directory inside that target: `{ProductName}Documentation.docc/`. +- MUST have a single minimal Swift source file in the target (DocC requires at least one Swift file). Typical content: `@_exported import Foundation` with a comment explaining the target's purpose. +- MUST have `swift-docc-plugin` listed as a package dependency in `Package.swift` (from 1.4.3 or newer). +- MUST use `@DisplayName("ShortName")` on the landing page to give readers a short sidebar label while the target keeps the long, unambiguous name (e.g. target `PureXMLDocumentation`, display name `PureXML`). +- MUST NOT add `@TechnologyRoot` to the landing page; DocC warns it is unnecessary for a landing page tied to a module. + +### Rule 2: Never keep a `Docs/` folder alongside the catalog + +Never let documentation accumulate in a freeform `Docs/` directory. + +- MUST consolidate all project-level prose into the catalog. +- MUST delete any legacy `Docs/` folder during DocC adoption. +- MUST migrate each `.md` file to a PascalCase article inside `{Name}.docc/`. +- MAY keep package-level `README.md` files next to code (they serve different audiences; the catalog cross-references them as plain text paths when needed). + +### Rule 3: Landing page is an Apple-style sample-article + +Model the catalog landing page (`{Name}.md`) on Apple's sample-article format (e.g. the Wishlist or Landmarks sample). + +**MUST have:** +- First line: `# ``{TargetName}``` (the ``-resolving backtick form). +- `@Metadata { @DisplayName("...") }` block. +- One-sentence abstract right under the title. +- `## Overview` with 2 to 3 paragraphs of context. +- `## Configure the sample code project` with clone, build, run steps. +- One or more `## {feature area}` sections, each with prose + a real code listing + optionally a diagram. +- A closing `## Topics` section that groups every article in the catalog. + +**MUST NOT have:** +- Marketing filler ("that's it!", "only N lines", "super simple", "production-ready" status badges). +- Aspirational content; everything in the landing page is accurate to current repo state. + +### Rule 4: Topics structure: flat-wider, not deep + +For article-heavy catalogs (not symbol-heavy frameworks), **prefer a flat-wider Topics layout** over multi-level parent articles. Apple uses flat-wider for sample catalogs. + +- SHOULD have 5 to 10 top-level Topics groups (guidance, not a hard cap; a 3-group catalog is fine if the groups are cohesive). +- SHOULD have 2 to 8 articles per group. +- MUST avoid 1-item groups (merge the lonely article into an adjacent larger group). +- MUST avoid parent articles that duplicate the landing page's overview (extra click, no new information). +- MAY introduce parent articles ONLY when a category has 8+ items AND they naturally sub-group (e.g. by role or layer). + +Apple's framework docs nest via *symbols* (types contain methods); article catalogs stay flat. + +### Rule 5: Article format + +Every article (`.md` file in the catalog) follows this shape: + +```markdown +# Article title in plain language + +One-sentence abstract. + +## Overview + +Two or three paragraphs. Establish context. + +## {First topic} + +Content with real code listings, tables, lists. + +## {Next topic} + +... +``` + +- MUST start with `# Title` (not `# ``Symbol```). +- MUST have an abstract after the title (DocC displays it under the title in the viewer). +- MUST use PascalCase file names (`PackageLayers.md`, not `package-layers.md`). +- MUST match the filename to the `` reference name exactly; DocC is case-sensitive. + +### Rule 6: Cross-linking + +- MUST use `` for catalog-internal links. No angle brackets around spaces ever (causes DocC warnings). +- MUST use plain markdown paths (`Sources/Foo/README.md`) for files outside the catalog. +- MUST NOT use `` for non-existent articles; DocC emits a warning per broken reference. +- MUST NOT put `` inside example code blocks; DocC sometimes tries to resolve them even inside fences. Use `{example-placeholder}` or HTML entities for placeholders. + +### Rule 7: Diagrams pipeline: mermaid source, PNG output + +Single source of truth is a `.mmd` file; the committed PNG is a generated artifact. + +- MUST place `.mmd` sources in `{Name}.docc/Resources/diagrams/`. +- MUST commit the rendered `.png` alongside the source so the catalog renders without Node at read time. +- MUST provide a helper script that runs `npx -y @mermaid-js/mermaid-cli` to regenerate every PNG. +- MUST reference the PNG by filename-without-extension: `![alt](diagram-name)`. + +Why PNG: DocC does not render mermaid source. Why commit the PNG: readers without Node still see the diagram; web UIs render it inline. + +### Rule 8: Maintainer contract: docs update with every PR + +**The documentation catalog is the single source of truth. Every PR that changes code MUST include a matching catalog update in the same PR. No exceptions.** + +The catalog MUST include a `MaintainingTheDocs.md` article with an explicit "if you change X, update Y" table. + +**Table schema (required columns, in this order):** + +| Change | Update | Reviewer check | +|---|---|---| +| (code change, concrete and specific) | (catalog article name + section) | (what a reviewer must verify before approving) | + +- "Change" is a concrete trigger, not a category (e.g. "Add a new `ServerEnvironment` case", not "environment changes"). +- "Update" names the exact article and section (e.g. "`Environments.md` -> `## Available environments`"). +- "Reviewer check" is one sentence, verifiable without running the code (e.g. "New case appears in the table and has a 1-line description"). + +**MUST cover at minimum** (the table rows): + +- Adding/removing a SPM package +- Renaming a public type or module +- Adding/changing a facade or admin endpoint +- Bumping a spec or schema version +- Changing auth mode or session mechanism +- Adding an environment case or scheme variant +- Adding a brand-namespaced component +- Touching the log panel / observability +- Changing offline / caching behavior +- Touching the CI pipeline +- Adding a diagram + +Reviewers MUST block approval when code drifts from docs. If the column "Update" is empty for a code change, either the article does not cover it yet (add it) or the change is not doc-relevant (rare; document the judgment call in the PR description). + +### Rule 9: Building and previewing + +Build from CLI: + +```bash +swift package generate-documentation --target {Name}Documentation +``` + +Preview in browser (live reload on file save): + +```bash +swift package --disable-sandbox preview-documentation --target {Name}Documentation --port 8765 +``` + +**Default port 8080 collides with common dev servers** (Vapor, Node, etc.). Always pass `--port 8765` (or any free port). + +Open built archive in Xcode: + +```bash +open -a Xcode .build/plugins/Swift-DocC/outputs/{Name}Documentation.doccarchive +``` + +`-a Xcode` is required if Dash (Kapeli) is installed; macOS otherwise hands `.doccarchive` files to Dash. + +### Rule 10: Zero warnings is the bar + +**MUST NOT commit documentation changes if `swift package generate-documentation` emits any warnings.** + +The only tolerated warnings are SPM-level "dependency is not used" notices with this exact shape: + +- `warning: 'PackageName': dependency '' is not used by any target` +- `warning: dependency '' is not used by any target` + +All DocC-emitted warnings (anything from `docc`, anything referencing an article, symbol, resource, or link) MUST be fixed before commit. If a warning appears that does not match the tolerated shape above, treat it as a blocker. + +Common fixable warnings: + +- `` 'SomeName' doesn't exist at '/...' ``: `` points at an article that does not exist. Fix the name or create the article. +- `Resource 'foo.png' couldn't be found`: image referenced but not in `Resources/`. Move or rename. +- `' ' doesn't exist`: DocC parsed a malformed `<...>` pattern as a symbol reference. Usually caused by `` in an example block; switch to `{placeholder}` or HTML-escape the angle brackets. + +### Rule 11: No private or user-specific references in the catalog + +**MUST NOT** reference private / user-specific paths or repos inside the catalog. + +- No private-org paths. +- No absolute paths like `/Users/*` or `/Volumes/*`. +- No private helper script names. +- No internal-only chat handles, internal URLs, or credential-adjacent details. + +When referencing an upstream backend you do not control, describe the contract abstractly. The reader is a colleague on the shared repo, not a private-notes reader. + +### Rule 12: No marketing tone + +Technical documentation. Neutral, factual voice. + +**MUST NOT:** +- "That's it!", "Just works!" (as filler), "Super simple", "Even easier", "Absolute simplest" +- "Only N lines of code" (as a boast) +- "Production-ready" / status badges +- Excessive checkmark glyphs in prose (DO/DON'T blocks are fine) +- Exclamation marks in section headings + +**OK:** +- "No `@Environment(\.colorScheme)` needed": factual statement about built-in behavior +- "Just works" when describing a concrete SwiftUI built-in that does auto-adapt +- Short code comments that state fact (not celebrate) + +### Rule 13: Renames, moves, and deletions + +When an article is renamed, moved, or deleted, inbound `` references break silently at build time (Rule 10 catches them, but only if CI runs DocC). The PR that renames is responsible for fixing every inbound reference in the same commit. + +**MUST:** +- Grep the catalog for the old basename before renaming: `grep -rn 'doc:OldName' {Name}Documentation.docc/`. +- Update every hit in the same PR. No stub articles, no redirect shims. DocC does not support redirects, so a stub just duplicates content. +- When deleting an article, remove it from the landing page `## Topics` section in the same commit. + +**MUST NOT:** +- Leave a placeholder article with a "moved to X" pointer. Readers see an empty page; cross-refs still break. +- Rely on a follow-up PR to "clean up" broken links. Rule 10 means the build stays green only when the rename is complete. + +For renamed public Swift symbols, the catalog article describing that symbol MUST be renamed in the same PR and its filename updated to match the new symbol name (DocC's `` is case-sensitive and exact). + +### Rule 14: Symbol-level `///` comments update with public API changes + +Rule 8 covers catalog articles; this rule covers the `///` doc comments attached to declarations in Swift source. Both must move in the same PR as the code change. + +**MUST:** +- Add a `///` doc paragraph when introducing any new `public` type, protocol, enum, actor, method, property, or init. The paragraph explains *why* the declaration exists and any non-obvious behaviour (error branches, actor isolation, side effects, invariants), not a restatement of the signature. +- Revise the `///` in the same commit that changes the semantics of a documented public declaration. Signature-only refactors (renames, parameter reorder) count. +- Delete `///` text that no longer reflects behaviour. Stale docs are worse than missing docs because readers trust them. +- Before deleting a `public` symbol, grep the catalog and the source tree for `` ``SymbolName`` `` references and remove or update each one in the same PR. + +**MAY skip documenting:** +- `var body` on SwiftUI `View` conformances (Apple's own frameworks do not doc it). +- Protocol-default conformance methods where the protocol itself is fully documented (`init(data:)` + `make()` on `Component` implementations, `init(from:)` / `encode(to:)` for `Codable`, forwarders on a mock). +- Data-struct fields whose name IS the documentation (`title: String`, `firstName: String`). "The title" adds no information. +- `Content: View` inner structs that are implementation detail of a documented component. + +**Reviewer check:** +- Any new `public` decl without a `///` paragraph blocks approval (unless it fits a MAY-skip category above; the PR description calls out the skip). +- Any semantic change to a documented public method without a corresponding `///` edit blocks approval. +- Any deletion of a `public` symbol without a matching sweep of `` ``SymbolName`` `` references in the catalog blocks approval. + +**Weighting (how much doc is enough):** +- Types & entry points: one-paragraph abstract + non-obvious-behaviour notes. Always. +- Widely-referenced methods (3 or more caller files outside the declaring package): one-line summary + `- Parameter` / `- Returns` / `- Throws` where non-trivial. +- Leaf / single-caller methods: one-line summary only. + +A weighted audit (undocumented public decls times cross-package usage) should be run before every major release to catch drift. Items referenced in many files but lacking docs are the highest-value gaps; items referenced nowhere outside their file are near-zero priority. + +--- + +## Verification checklist + +Before shipping a docs change: + +- [ ] `swift package generate-documentation --target {Name}Documentation` produces zero warnings (excluding generic "dependency is not used"). +- [ ] Every article referenced in a `` link exists as a file with that exact basename. +- [ ] Every image referenced as `![alt](name)` exists under `Resources/`. +- [ ] Landing page `## Topics` section lists every article, each in exactly one group. +- [ ] No article references a deleted `Docs/` folder or other legacy paths. +- [ ] No private or user-specific references leaked (`/Users/`, `/Volumes/`, etc.). +- [ ] `Product -> Build Documentation` in Xcode shows the updated content after building. +- [ ] Archive opens in Xcode's Developer Documentation window under "Imported Documentation". +- [ ] Every new/changed `public` declaration has a matching `///` comment in the same PR (or a justified MAY-skip per Rule 14). + +## References + +- [DocC overview (swift.org)](https://www.swift.org/documentation/docc/) +- [swift-docc-plugin](https://github.com/swiftlang/swift-docc-plugin) +- [Adding structure to your documentation pages (Apple)](https://developer.apple.com/documentation/xcode/adding-structure-to-your-documentation-pages) +- [Writing symbol documentation (Apple)](https://developer.apple.com/documentation/xcode/writing-symbol-documentation-in-your-source-files) +- Apple sample catalogs: Wishlist, Backyard Birds, SlothCreator, Landmarks (check their `.docc` directories in the public sample repos) diff --git a/docs/rules/engineering.md b/docs/rules/engineering.md new file mode 100644 index 0000000..5ab558a --- /dev/null +++ b/docs/rules/engineering.md @@ -0,0 +1,68 @@ +# Engineering principles + +Judgment rules for every change to PureXML code. Rules a machine can check live +in tooling (pointed to below); this file holds the judgment no tool can make. + +## Primary directive + +Choose the optimal path, not the fastest. Respecting existing code and idioms +outranks speed. + +## Principles + +1. **Progressive architecture.** Simplest thing that works first. Add a protocol + only at the second concrete consumer; generalise only when a pattern has + emerged. Do not pre-abstract. + + ```swift + // 1. Direct + func render() { ... } + + // 2. Protocol once a second implementation appears + protocol Renderable { func render() -> String } + + // 3. Generic once a pattern emerges across types + protocol Registry { ... } + ``` + +2. **Make impossible states unrepresentable.** Model with exhaustive enums and + associated values so invalid combinations cannot be constructed. + +3. **Errors carry a reason and a recovery path.** + + ```swift + enum TileError: LocalizedError { + case unknownType(reason: String, recovery: String) + + var errorDescription: String? { /* reason */ } + var recoverySuggestion: String? { /* recovery */ } + } + ``` + +4. **Testable by design.** Inject every collaborator through the initialiser; test + behaviour, not implementation; wrap concrete framework types behind a protocol + so a fake can substitute. See [dependency-injection.md](dependency-injection.md). + +5. **Profile, then optimise.** Value semantics by default; the right data structure + first; optimise only with a profile in hand. + +## Respect existing idioms + +Read the surrounding files first and match them. Idiom consistency outranks +personal preference. Do not introduce a new naming convention, structural choice, +or dependency-injection style that diverges from what is already in the codebase. + +## Quality gates (judgment) + +Before a change is done: + +- Every error path has a recovery path or a documented terminal failure. +- Edge cases handled: `nil`, empty collection, invalid input, cancelled task. +- Every new public API has a documentation comment. +- Apple-platform APIs (in Apple-only code) verified against the authoritative + reference: confirm the API exists with the claimed signature and follows current + conventions. + +Mechanical gates (no force-unwrap, no em dashes, no tool attribution, formatting, +build, tests) are enforced by tooling and CI, not by this file. See +[verification.md](verification.md), `.swiftlint.yml`, and `.swiftformat`. diff --git a/docs/rules/file-naming.md b/docs/rules/file-naming.md new file mode 100644 index 0000000..de7e721 --- /dev/null +++ b/docs/rules/file-naming.md @@ -0,0 +1,75 @@ +# File Naming Rules + +Conventions for naming committed files in the PureXML repo: lowercase, dash-separated, ASCII, ISO dates. + +Every file committed must follow these conventions. + +## General Rules + +- **Lowercase only**: no uppercase letters in filenames. +- **Dashes for separators**: use `-` instead of spaces, underscores, or camelCase. +- **No spaces**: ever. +- **No special characters**: no accented letters, `()`, `[]`, `&`. Transliterate accented characters to plain ASCII (for example c-with-caron to `c`, s-with-caron to `s`, d-with-stroke to `dj`). +- **No trailing dots**: remove dots before the file extension. +- **ASCII only**: filenames must be plain ASCII. + +## Date Format in Filenames + +Always use ISO format: `YYYY-MM-DD` + +- `30.11.2023` -> `2023-11-30` +- `11.03.2024.` -> `2024-03-11` +- `Mar 24, 2025` -> `2025-03-24` + +## Document Naming Patterns + +### Scanned documents +``` +Scan MMM DD, YYYY at HH.MM.pdf -> scan-YYYY-MM-DD-HH-MM.pdf +Scan DD.MM.YYYY. at HH.MM.pdf -> scan-YYYY-MM-DD-HH-MM.pdf +Scan DD MMM YYYY at HH.MM.pdf -> scan-YYYY-MM-DD-HH-MM.pdf +``` + +### Photos +``` +IMG_XXXX.jpeg -> keep as-is (acceptable) +IMG_XXXX.HEIC -> convert to IMG_XXXX.jpg (sips) +Photo DD-MM-YYYY.heic -> photo-YYYY-MM-DD.jpg (convert + rename) +``` + +### Dated documents +``` +--YYYY-MM-DD.pdf +``` +Use a date suffix only when the document is date-specific; omit it otherwise. + +## Renaming Existing Files + +When renaming, always use `git mv` to preserve history: +```bash +git mv "Old File Name.pdf" "old-file-name.pdf" +``` + +Commit: `rename: normalize filenames` + +## Audit (before any processing) + +Run these in the repo before reporting counts: + +```bash +# Files with spaces +find -name "* *" -not -path "*/.git/*" -not -name "*.md" + +# Files with uppercase +find -regex ".*/[^/]*[A-Z][^/]*" -not -path "*/.git/*" -not -name "README*" -not -name "*.md" + +# Files with underscores (excluding .git) +find -name "*_*" -not -path "*/.git/*" -not -name "*.md" + +# HEIC files +find \( -name "*.heic" -o -name "*.HEIC" \) -not -path "*/.git/*" +``` + +## Skip List + +Some asset paths legitimately carry vendor-supplied names and are exempt from the audit. Document the skip paths per repo (for example `*/webAccessibleResources/*`, generated id-document folders, vendor photo dumps). Keep the list short and justified. diff --git a/docs/rules/folder-grouping.md b/docs/rules/folder-grouping.md new file mode 100644 index 0000000..9f596b8 --- /dev/null +++ b/docs/rules/folder-grouping.md @@ -0,0 +1,216 @@ +# Folder Grouping Rule + +How to lay out source trees in PureXML: one folder per SPM target, with file-grouping rules for the files inside a target. + +Cross-cutting layout rule. Most concrete in Swift / SPM packages where one-target-per-folder is the default; the same intent applies to Python packages, web project trees, docs trees, anywhere a tree of files exists. + +## SPM targets: one folder per target is the MANDATE (not just the default) + +For SwiftPM packages the standing rule is: +**every target maps to exactly one top-level source folder, and the folder is +the target's home** (`Sources//`, `Tests/Tests/`). No +`path:` pointing many targets into a shared parent, no disjoint `sources:` / +`exclude:` slicing to carve multiple targets out of one folder. `ls Sources/` +must enumerate every target; the filesystem boundary IS the target boundary. + +This supersedes the older "family-parent folder" guidance below for the +target-grouping case. The file-grouping rules (grouping FILES *within* a single +target's folder) still apply unchanged. The two earned-exception patterns +(same-kind single-file clusters; sub-targets sharing an umbrella) are now +discouraged for new code; flatten unless there is a compelling, documented reason. + +A migration that flattened a deep `Sources/{Core,Executables,Retailers}/...` +tree to strict one-folder-per-target confirmed the payoff: a cluster of +executables sliced from one folder via `sources:` became sibling +`Sources//` folders, with a byte-identical behaviour set and a fully green +test suite across the migration. + +## The rule (grouping FILES within a target) + +1. **Group related files in a shared parent folder.** When you have several files that belong to the same semantic kind (e.g. 27 importers, 5 renderers, 12 view-models), put them under a single parent folder named for the kind (`Importers/`, `Renderers/`, `ViewModels/`). Do not scatter them across the root or across one-folder-per-file silos. + +2. **Don't create a subfolder for a single file.** A subfolder's job is to make a group of related files easy to scan. A subfolder containing exactly one file forces an extra click / expand without giving anything back; that one file should live one level up. + +3. **Exception: a single-file folder is allowed when the file's semantic differs sharply from its siblings.** Use this when the file *anchors a future group* or when collapsing it up would put it next to unrelated things. Example: the first file dropped into a brand-new utility / domain bucket may keep its own folder so the bucket's purpose is visible even before peers arrive. Apply sparingly. The default is to flatten. + +4. **The rule applies recursively at every nesting level.** Each level of nesting must have a semantic justification. After grouping a kind into a parent folder (`Importers/`), look INSIDE that parent: are there sub-clusters that share a finer semantic? Pull those into their own subfolders. After that pass, look UP one level: is the parent kind folder a peer of other related kind folders (`Importer/` seam + `ImporterUtilities/` + `Importers/` are all import-related)? Wrap them in a common ancestor (`Import/`). Walk the tree once top-down, once bottom-up. Every folder boundary at every depth must answer "what semantic distinguishes the things inside from the things outside?" If it cannot, flatten or rename. + +## Naming the parent folder: singular vs plural + +The kind folder's name is **singular**, not plural. A folder grouping many models is `Model/`, not `Models/`. A folder grouping many passes is `Pass/`, not `Passes/`. The folder name describes the *kind of thing inside*, not the *plural cardinality of that thing*. Singular reads cleaner in paths (`Sources/Enrichment/Model/EnrichmentRunner.swift` vs the noisier `Sources/Enrichment/Models/EnrichmentRunner.swift`) and matches the Swift namespace style (`Indexer.Model.X` not `Indexer.Models.X`). + +Exception: the kind-folder name is plural *only when the kind itself is grammatically a plural noun* (`Resources/`, `Frameworks/`, `Sources/` itself). When you have to coin a singular vs plural, pick singular. + +## SPM default: every target is its own top-level folder + +The Swift Package Manager default, `Sources//` with no `path:` override, is the safer baseline for packages with many SPM targets. The filesystem location mirrors the target identity one-to-one, which means: + +- `ls Sources/` enumerates every target without reading `Package.swift`. +- `grep -r ... Sources/Foo/` answers "what's in target Foo" without ambiguity. +- Lift-out is trivial: copy the folder, write a 5-line standalone `Package.swift`, build. +- New contributors do not have to learn an additional layer of conventions before navigating the tree. + +Deviate from SPM default ONLY when one of the two patterns below earns its keep. + +## When family-parent folders are worth it: same-kind clusters of single-file targets + +A family parent folder with `path:` + `sources:` overrides earns its keep when you have **5 or more peer targets of the same semantic kind**, each a single-file standalone target. The worked example below is the canonical case: 27 importers under `Sources/Importers/.swift`. The kind folder collapses 27 single-file siblings into one scannable list, and SPM's `path:` + `sources:` keeps each importer a separate target. + +The rule of thumb: if you can name the kind in one word and you have many peers, group them. `Importers/`, `Renderers/`, `ViewModels/` qualify. Two targets do not. + +## When family-parent folders are *not* worth it: heterogeneous-kind families + +A family folder with **kind-named subfolders** (`Sources//{Core, Model, ...}/`, where each subfolder holds a different kind of target: live concrete vs foundation seam vs WebKit adapter) is an anti-pattern. It looks symmetric but: + +- The filesystem path no longer maps 1:1 to a target. `Sources/Enrichment/Core/` is the target `Enrichment`; `Sources/Enrichment/Model/` is the target `EnrichmentModels`. The reader needs `Package.swift` to make the connection. +- Overloads the word "Core" across multiple meanings (family root, target's content, kind subfolder). +- Reads worse: `Sources/Source/SampleCode/SampleCodeSource.swift` triples the same word. +- Forces every new contributor to learn a project-specific taxonomy before navigating. + +If a `Foo` target ships alongside `FooModels`, leave them as `Sources/Foo/` + `Sources/FooModels/` (SPM default). The naming pair already signals the relationship; folder nesting adds nothing. + +**Historical note**: one project tried `Sources//{Core, Model, ...}/` across 13 families before reverting to SPM default after a single review pass: "this is confusing." The lesson: filesystem === target boundary is load-bearing for navigability; family-parent folders that obscure it are not worth the cohesion gain. + +## Target name vs folder name decoupling + +SPM target identity is declared via `path:` in `Package.swift`. The filesystem location and the target name can diverge, but **do not make them diverge for cosmetic reasons**. Use `path:` only when: + +1. **Clustering same-kind single-file targets** (the Importers case). +2. **Embedding a sub-target inside a parent target's folder** (e.g. `Sources/MCP/Core/`, `Sources/MCP/Client/`: each is its own SPM target but they share the umbrella name because they ship together as one conceptual framework). +3. **Test targets pre-organised under a domain folder** (`Tests/MCP/CoreTests/`, `Tests/CLICommandTests/DoctorTests/`) when the source side mirrors that shape. + +Anywhere else: keep SPM default. The discoverability win outweighs any cohesion gain. + +## Rename priority order (highest-stability first) + +When restructuring or renaming, treat the surfaces in this order. The higher the stability rank, the more reluctant you must be to change it. **Always anchor a rename around the highest-stability surface that is not moving**, and let the lower-rank surfaces follow. + +1. **SPM target name**, *highest stability*. This is the string consumers write as `import `. Changing it cascades to every consumer's source file, every test file, every doc that names the import, every script that greps for it. Default position: do not rename. A target rename is a separate, dedicated PR with explicit acceptance criteria (every consumer updated, every test green, every doc swept). + +2. **Public type / namespace name**, *high stability*. Surfaces in consumer code as `.`. Renames are tractable because they can be staged behind a `public typealias OldName = NewName` for back-compat, but every direct reference still needs updating eventually. Stage the rename: introduce the new name + alias in one PR, migrate consumers in follow-ups, drop the alias when uses hit zero. + +3. **File name**, *low stability*. The Swift compiler does not care what file a type lives in. Rename freely. Common patterns: `..swift` for namespace-anchored types. `git mv` and you are done; no consumer is affected. + +4. **Folder layout**, *lowest stability*. The filesystem location of source files is an organisational decision, not an identity one. SPM `path:` overrides decouple folder from target name when needed (sparingly, see the section above). Reorganise folders without touching target names: every consumer keeps building unchanged. + +**The load-bearing principle**: when renaming, ask first *"can I do this without touching the SPM target name?"* If yes, the change is local. Only the codebase author sees the file moves. If no, the change is API-breaking and needs the heavy-PR treatment. + +**Worked example**: a family-folder restructure attempted to deepen a codebase by moving source files into family-parent + kind-subfolder shapes. The instinct was right (organize better) but the execution prioritised the wrong surface (folder layout over target name). After a single review pass on the result ("this is confusing") the right move was to keep target names + file names exactly as they were and revert the folder layout to SPM default. The same PR also landed type-name deepening (rank 2 above) with back-compat typealiases, which DID stick because it respected the priority order: target names unchanged, public types staged behind aliases, file names + folders adjusted to match. The folder restructure (rank 4) cost roughly 250 file moves in then out; the type deepening (rank 2) cost about 6 files and shipped. Different ranks, different blast radii. + +## How to apply + +When adding a new file: + +- Find an existing folder of the same kind. Drop the new file directly into it (no inner subfolder). +- If the file has multiple companion files of its own (a 2-or-more-file unit: protocol + helper + fixture, or model + parser), give *that unit* its own subfolder inside the parent kind folder. +- If no kind folder exists yet and the new file is the first of its kind, drop it next to its peers at the current level. Create the kind folder later, when a 2nd peer lands. + +When reviewing an existing tree: + +- Walk it folder-by-folder. Any folder containing exactly one `.swift` / `.py` / `.md` / etc. file is a candidate to flatten. +- Flatten by `git mv`-ing the file up one level and `rmdir`-ing the now-empty folder. +- Preserve the unit-with-multiple-files rule: do not flatten a 2-file folder. + +## How this composes with SPM (Swift specifics) + +This rule conflicts with SPM's default `Sources//` convention when you have many single-file targets, because SPM expects one folder per target. Resolve by giving each affected target an explicit `path:` and `sources:` declaration in `Package.swift`. Multiple targets MAY share the same `path:` as long as each declares disjoint `sources:` lists. Example: + +```swift +let importersSrc = "Sources/Importers" + +func flatImporter(_ name: String, dependencies: [Target.Dependency]) -> Target { + .target( + name: name, + dependencies: dependencies, + path: importersSrc, + sources: ["\(name).swift"] + ) +} +``` + +Then `flatImporter("AnalysesImporter", ...)`, `flatImporter("FollowersImporter", ...)`, etc. all live as flat `.swift` files under `Sources/Importers/` while remaining independent SPM targets. A multi-file target (e.g. one with a protocol + a CSV parser) keeps its own subfolder under the parent kind folder: `path: "Sources/Importers/CsvImporter"`. + +Verified on Swift 6.0 + SPM: `path:` may overlap between targets when `sources:` partitions them disjointly. + +## Tests follow sources + +Test files mirror their source layout: + +- 26 single-file importers under `Sources/Importers/` map to 26 test files under `Tests/Importers/`. +- A multi-file importer at `Sources/Importers/CsvImporter/` maps to a multi-file test target at `Tests/Importers/CsvImporterTests/`. + +Use the same `flatImporterTests(...)` helper pattern in `Package.swift`. + +## Why this matters + +Two costs the rule removes: + +- **Scan cost.** A tree with 27 one-file folders is 27 expand-arrows the reader has to click. A flat `Importers/` directory with 27 files visible at once is one find-in-files away from any target. Brevity wins. +- **Mental overhead during edits.** When all importers are siblings, "show me what every importer does" is `ls Sources/Importers`. When each importer hides one level down, the same question becomes `find Sources/*Importer -name '*.swift'` plus a mental merge. + +The cost the rule preserves: SPM-level target independence (every package lifts out of the package set cleanly). Folder-grouping is a *filesystem* layout decision; SPM-target boundaries stay sharp via `path:` + `sources:`. + +## Lift-out preservation (non-negotiable) + +Every package must lift out as a working standalone Swift package: copy the target + its declared transitive deps to a tmp directory, generate a minimal `Package.swift`, `swift build`, green. Folder-grouping must not regress this. + +It does not, because: + +- Each target still owns a definite set of `.swift` files (declared via `path:` + `sources:` in `Package.swift`). The filesystem locations are colocated, but the file-to-target mapping is unambiguous. +- A lifted target re-roots its sources under the default SPM convention (`Sources//`); the shared-path trick was a *package-set* layout choice, not part of the target's identity. The lifted-package `Package.swift` goes back to plain `.target(name: "X", dependencies: [...])` with no `path:` override. +- Tests follow the same rule: each test target's `.swift` files belong to exactly one test target, and lifting copies just those files. + +**Mechanical verification (do this when in doubt):** copy the importer + its declared transitive deps to a tmp `Sources//` (one folder per target, default layout), generate a one-screen `Package.swift`, `swift build`. Green = lift-out works. + +**Per-target import audit must still work.** A side-effect of shared `path:` is that simple `for d in Sources/*; do audit "$d"; done` scripts collapse a cluster of single-file targets into one bucket and lose per-target granularity. Fix: walk to leaf `.swift` files inside designated cluster directories. Add an `is_cluster_path` predicate to the audit so each clustered target is still audited individually; update that predicate when adding a new cluster bucket. + +If the audit script cannot distinguish your targets, the refactor is leaking: fix the script or revert the grouping. The lift-out test never lies. + +## Worked example: a three-pass flatten + +Three passes, each from a different angle, illustrating rule item 4. + +**Pass 1, flatten one-file folders.** Before: `Sources/AnalysesImporter/AnalysesImporter.swift`, `Sources/FollowersImporter/FollowersImporter.swift`, ... 27 one-file folders at the `Sources/` level. After: `Sources/Importers/.swift` flat. The 2-file `CsvImporter` keeps its inner subfolder. + +**Pass 2, sub-cluster inside the kind folder.** The flat list `Importers/` still had visible clusters: several files sharing a platform prefix. Each cluster got its own subfolder under `Importers/`. Single-file importers with no platform peer stayed flat at `Importers/`. + +**Pass 3, wrap the import family at the root.** `Sources/` contained `Importer/` (seam), `ImporterUtilities/` (helpers), and `Importers/` (concretes) as three peer folders mixed with unrelated things. The three import-related folders got wrapped under `Sources/Import/`. Final import-side layout: + +``` +Sources/Import/ +|-- Importer/ (seam, multi-file) +|-- ImporterUtilities/ (helpers, multi-file) ++-- Importers/ (concretes) + |-- PlatformA/ (multiple PlatformA targets) + |-- PlatformB/ (multiple PlatformB targets) + |-- AnalysesImporter.swift (flat, no platform peer) + +-- SnapshotImporter.swift +``` + +Tests mirror the same shape under `Tests/Import/`. Build + tests + the live CLI pipeline all pass. + +The lesson: pass 1 and pass 2 are both applications of the same rule at different depths. Pass 3 inverts the angle, looking UP from a kind folder at its peers, and finds the next grouping. After three passes the tree settles: every folder at every depth has an answer to "what's inside that is not outside?" + +## Anti-patterns + +- Creating an `Importers/AnalysesImporter/AnalysesImporter.swift` triple where the inner folder holds one file. Flatten. +- Hoisting a multi-file unit (e.g. an importer's protocol + CSV parser) out of its subfolder and into the flat parent. The unit deserves its own folder. +- Inventing a single-file kind folder before peers exist (a brand-new `Renderers/WebDashboardRenderer.swift` when no other renderer is anywhere on the horizon). Wait for a 2nd renderer, then create `Renderers/` and consolidate. +- Creating a parent kind folder that contains only one item *and* duplicates its name (e.g. `Renderers/Renderers/Web.swift`). Same anti-pattern as `Renderers/Web/Web.swift`: the inner layer is empty. + +## Enforcement + +No automated linter today. Verify by eyeballing the tree before declaring a layout change done: + +```bash +find Sources -mindepth 1 -maxdepth 1 -type d | while read d; do + count=$(find "$d" -maxdepth 1 -name "*.swift" | wc -l | tr -d ' ') + printf "%3s %s\n" "$count" "$d" +done | sort -n +``` + +Any line with `1` is a candidate for flattening (unless rule item 3 applies). + +## See also + +- docs/rules/file-naming.md: filename conventions this layout sits on top of. diff --git a/docs/rules/git-discipline.md b/docs/rules/git-discipline.md new file mode 100644 index 0000000..e38a300 --- /dev/null +++ b/docs/rules/git-discipline.md @@ -0,0 +1,255 @@ +# GitHub and Git Discipline + +Conventions for issues, labels, pull requests, branches, commits, and remotes in the PureXML repo. Commit-message format lives in docs/rules/commits.md; this file covers everything else. + +The repo ships issue forms, a PR template, a label set, and git hooks that enforce most of these rules mechanically. The rules below are the durable conventions those mechanisms encode. + +## 1. Issue tracker + +### Rule 1.1: Status block at the top, dated + +Every issue body carries a `## Status (YYYY-MM-DD)` heading as the first section. New-issue form templates make this required. + +When state changes (work starts, acceptance bullets ship, scope narrows, deps close), edit the status block in place with a new date line. The rest of the body stays as the original framing. + +**Why**: bodies without a dated status block age into fiction within a month. Issues with status blocks age well; the ones without age into wrong-default-value claims that shallow audits pass as "well-written, keep." + +### Rule 1.2: No line numbers in issue bodies + +File references use symbol names, not `Foo.swift:142`. Lines drift on every PR; symbols do not. + +When you need a line anchor, write `Foo.swift (the searchSymbols function)` so the symbol survives even if the line moves. + +**Why**: line anchors like `SearchIndex.swift:2409` go dead within one PR cycle the moment a file is split or reordered. + +### Rule 1.3: No phantom paths + +Every backtick-quoted file path in an issue body must EXIST in the repo (or in a declared sibling repo) at write time. + +A mechanical check on this is straightforward: extract backtick-quoted paths, check the filesystem, flag missing ones. + +**Why**: a cited file that was never written is a fabrication that propagates. The work it implies may be real, but the path is a lie until the file exists. File it as an issue instead of citing a path that is not there. + +### Rule 1.4: Cross-reference hygiene + +When citing `#NNN` in blocker phrasing ("blocked on", "pending in", "depends on", "after #N lands", "gated on", "awaits", "waiting on"), the referenced issue must be OPEN at write time. + +When the referenced issue closes, edit the citing issue's body: either remove the cross-reference, or rewrite the surrounding sentence to say the dep shipped. + +**Why**: "blocked on #X" where #X closed weeks earlier is a silent dependency lie. Wrong issue numbers are worse: they point a reader at unrelated work. + +### Rule 1.5: Schema and code claims are checkable + +Do not cite `.` shapes, function signatures, or default values you have not verified against current source. + +When the claim is structural (a schema column, a config default, a flag name), an audit script can mechanically verify it. Migrations move columns; flag defaults change; bodies do not auto-update. + +**Why**: a body claiming a column lives in table A when a migration moved it to table B, or claiming a flag defaults to `0.5s` when the real default is `0.05s` (off by 10x), reads as authoritative and misleads. Verify before you write. + +### Rule 1.6: Issue templates use GitHub forms, not markdown + +Use `.yml` form templates, not `.github/ISSUE_TEMPLATE/feature.md` style markdown. Forms enforce structure mechanically at filing time; markdown templates only suggest it. + +Required fields: status date (input), priority (dropdown), complexity (dropdown). Required structured textareas per template kind (goal + acceptance for features; symptom + expected + reproduce + acceptance for bugs). + +**GitHub forms gotcha**: dropdown selections produce TEXT in the issue body under `### ` headings; they DO NOT auto-apply as labels. To make dropdowns actually apply matching labels, add a labeler workflow that triggers on `issues.opened`, extracts the value under each heading, validates against the known label set, and applies the matching label. + +**Why**: forms make filing discipline structural rather than suggestive. The labeler gotcha is easy to miss; the dropdowns look like they should label the issue, and they do not. + +## 2. Labels + +### Rule 2.1: Brutal-minimum 5-label set + +The canonical label set is **5 labels**. Labels exist only to mechanically partition the issue space; everything else lives in the issue body or in native GitHub primitives (issue state, Milestones, Project boards). + +| Label | Color | Use | +|---|---|---| +| `bug` | Red `#FF3B30` | Something ships incorrectly | +| `enhancement` | Blue `#007AFF` | New feature, refactor, or design proposal | +| `epic` | Purple `#AF52DE` | Aggregation parent of related issues; mechanically excluded from "missing kind" check | +| `priority: high` | Red `#FF3B30` | Critical / release-gating / actively-blocking. Absence means "do when you can." | +| `good first issue` | Green `#34C759` | Newcomer-friendly; GitHub renders specially in the contributor chooser | + +Kind is determined at filing time (feature template applies `enhancement`, bug template applies `bug`). Maintainers add `epic`, `priority: high`, `good first issue` post-filing where warranted. + +**What is not a label here:** + +- **Complexity.** Read the diff. If you need a label to tell readers the issue is hard, your prose is failing. +- **Priority gradient.** `priority: medium` and `priority: low` collapse to "absence of `priority: high`." If the gradient does not change behaviour, the gradient is decoration. +- **Topical categorisation** (search-quality, source-expansion, refactor, area: distribution, etc.). These are composition, not type. Encode in the body or in a milestone. +- **Status / lifecycle** (`wishlist`, `transitional`, `blocker`, `big-win`, `fixed: awaiting release`, `released-in: v`). Status belongs to GitHub-native primitives (issue state, Milestones, Project boards), not to labels. + +**Why this size**: each surviving label has at least one mechanical reason it cannot fold into the body. Form templates apply kind at filing time; a staleness script partitions on `epic`; `priority: high` is the only triage tier (absence is implicit "later"); `good first issue` is GitHub-native. Three lenses converge on this set: + +- **Cut mercilessly**: cut even fine things that do not earn their place. A larger set still carries comfort-tags. +- **Classify by type, not status**: every classification must justify its existence; status / lifecycle / release-tracking are not types, route them to native primitives. +- **Prose over labels**: if you need a label to convey something, your prose is failing. Labels exist only for what mechanical tools must partition on. + +### Rule 2.2: 3-carrier threshold for adding a new label + +If you feel the urge to add a sixth label, ask: does this label have at least 3 expected open carriers (today or within the next planning cycle)? AND does it survive each of the three lenses above? + +- Would you ship this label, or delete it? +- Is this a type/axis, or is it status/lifecycle/composition pretending to be a type? +- Does this label mechanically partition the issue space in a way grep / filter / a CI script must use, or is it commentary that belongs in the body? + +If any lens fails, fold the categorisation into the issue body. A one-line note in the body is as discoverable as a single-carrier label without the dropdown clutter. + +**Why**: single-carrier labels are footnotes pretending to be axes. Sprawling label sets stop being navigable past about 10. + +### Rule 2.3: Apple system palette + +The 5 canonical labels use 4 hues from Apple's published system colour palette: + +| Hex | Color | Use | +|---|---|---| +| `#FF3B30` | Red | Urgent / blocking: `bug`, `priority: high` | +| `#007AFF` | Blue | Active work: `enhancement` | +| `#AF52DE` | Purple | Aggregation / epic: `epic` | +| `#34C759` | Green | Newcomer-friendly / shipped: `good first issue` | + +If you add a sixth label under Rule 2.2, draw from Apple's other system colours by semantic grouping: + +| Hex | Color | Reserved for | +|---|---|---| +| `#FF9500` | Orange | Medium urgency | +| `#FFCC00` | Yellow | Awaiting / pending | +| `#00C7BE` | Mint | Low complexity (if you ever bring complexity back) | +| `#30B0C7` | Teal | Topical / categorical | +| `#32ADE6` | Cyan | Help / outreach | +| `#5856D6` | Indigo | High complexity / structural | +| `#FF2D55` | Pink | Celebration / high-impact | +| `#A2845E` | Brown | Maintainer / internal | +| `#8E8E93` | Gray | Speculative / wishlist | + +**Why Apple's palette**: 13 distinct hues with strong semantic associations. Picking from this set means labels render coherently in any view that supports colour (GitHub UI, project boards, third-party trackers reading from GitHub). + +### Rule 2.4: Label deletion is destructive on closed issues + +`gh label delete ` removes the label from every issue that ever bore it, including closed issues. Use `gh label edit --name ` to rename when historical association matters. + +**Why**: closed issues are the project's audit trail. A `phase-1` label on closed refactor issues told a later reader "this was Phase 1 of that refactor"; deleting `phase-1` removes that context. Renaming preserves it. + +When in doubt: prefer rename over delete for labels with closed carriers. Brutal trims that delete closed-carrier labels should be a deliberate, documented choice (acceptable when the information still lives in Release notes, the CHANGELOG, and merge commits). + +## 3. Pull requests + +### Rule 3.1: One focused change per PR + +A PR ships one cohesive change. If the diff spans two unrelated concerns, split. + +Critic-fix iterations on the same change belong in the same PR (separate commits, same branch). Different concerns belong in different PRs even if they touch the same files. + +### Rule 3.2: CHANGELOG required for non-trivial changes + +Projects that maintain a CHANGELOG require an entry per non-trivial PR. "Non-trivial" means: production source touched. Trivial means: docs / tests / scripts / configuration only. + +A mechanical pre-commit + CI check enforces this. Opt-out via a `[no-changelog]` token in the commit message body when the change genuinely does not warrant an entry. + +**Why**: PR descriptions live in the merge graph; CHANGELOG lives in the release. Without enforcement, source ships ahead of its description. A mechanical gate prevents the batch of PRs that merge without entries. + +### Rule 3.3: Critic-fix loop on every non-trivial PR + +After opening a PR, do a self-critic pass: read your own diff as a reviewer would, find issues, fix them in additional commits on the same branch. Iterate until critique surfaces nothing new. + +Commit naming: `critic-fix(): `. The git history shows the iteration as separate commits; the PR diff shows the converged result. + +**Why**: mechanical edits without re-reading the surrounding paragraph create new bugs. A `sed` that injects a reference inside a sentence saying "this is independent" creates a contradiction. Critic-fix loops catch the self-introduced bugs that the original change did not have. + +### Rule 3.4: PR head is never the canonical branch for release merges + +When merging a release branch into `main` (or `develop` into `main`, etc.), the PR head is a dedicated `release/v` branch, not the canonical working branch. Auto-delete-on-merge would kill the canonical branch otherwise. + +**Why**: the GitHub auto-delete-branch-on-merge feature is useful for short-lived feature branches and destructive for long-lived development branches. Always interpose a release branch as the head. + +## 4. Branches + +### Rule 4.1: Branch naming + +- `fix/-`; bug fixes (e.g. `fix/284-error-page-filter`) +- `feat/`; features +- `chore/`; tooling, infrastructure, non-functional cleanup +- `docs/`; documentation-only changes +- `refactor/`; structural code reorganisation +- `release/v`; release-prep branches (PR head for canonical-branch merges per Rule 3.4) + +Issue-anchored prefixes (`fix/-...`) when an issue exists; otherwise topic-anchored. + +### Rule 4.2: Branch from canonical base + +Branch from the current tip of the canonical base branch (the one the PR will target). For Swift projects this is usually `main` or `develop` depending on the project's branching model. + +`git fetch origin && git checkout -b origin/` is the safe form. Branching from a stale local copy of the base creates merge conflicts later. + +### Rule 4.3: Issue-first workflow + +For every bug fix: file the GitHub issue first, then branch. The branch name carries the issue number; the PR auto-links via `Closes #N` in the description. + +For features: an issue is optional but recommended when scope is non-trivial or when the change crosses two or more bug-list cycles. + +**Why**: the issue is the audit trail. A branch + PR without an issue has no place for the symptom / repro / acceptance discussion. The PR body can hold that data but does not survive squash-merge as well as an issue does. + +## 5. Commits + +See docs/rules/commits.md for commit message format. The following are additional GitHub-related conventions. + +### Rule 5.1: No AI attribution + +Generated content carries no AI attribution in any committed text. Specifically forbidden: + +- Any AI co-author line in commit messages +- Tool-attribution lines in PR descriptions, code comments, or documentation +- The names of AI tools or vendors, or the words "AI", "LLM", "agent", "assistant", "automated", "machine-generated", or any synonym, in commits / PRs / branch names / file names that could end up in front of a colleague +- AI-tell stylistic patterns: emoji signatures, "Here's a quick summary:" preludes, gratuitous emoji-prefixed bullets, "I'll now..." / "Let me..." preambles in committed text +- Watermarks: zero-width characters, hidden HTML comments naming a tool, branch suffixes like `-ai` / `-bot` + +The work must read as entirely human-authored. The standard is "would a colleague reading this guess a tool wrote it?" If yes, remove it. + +For mechanical enforcement at the commit boundary, install the commit-message style hook described in Section 7. Rule 5.1 plus Rule 5.2 plus the hook together close the loop: the rule states intent, the hook refuses violations at commit time, so no commit reaches `git push` carrying either failure. + +### Rule 5.2: No em dashes in committed text + +The em dash (the U+2014 character) is a recognisable writing tell. Replace with commas, periods, colons, semicolons, or restructure the sentence. Apply to commit messages, PR descriptions, code comments, issue bodies, documentation, README content; everything that lands in the repo or in front of a colleague. + +This is a stylistic rule, not a syntactic one. En dashes (U+2013) and hyphens (U+002D) are fine. Only the em dash is forbidden. + +Enforced mechanically by the commit-message style hook in Section 7 for commit messages. For file content, run the same byte check in a `pre-commit` hook over staged diffs. + +### Rule 5.3: Verify after `git commit --amend --no-edit` + +`--no-edit` keeps the previous commit's message verbatim. After any amend with `--no-edit`, run `git log -1` to confirm the message still describes the new tree accurately. If the staged change made the previous message wrong, drop `--no-edit` and rewrite. + +**Why**: silent message staleness is the second-easiest commit-history bug to introduce (after force-pushing the wrong branch). The previous message gets anchored by `--no-edit` and lies about content that has since changed. + +## 6. Remotes + +### Rule 6.1: Push conventions + +Pushes follow the usual conventions: short-lived feature branches push freely; canonical branches (`main` / `develop`) only land via merge of an approved PR. + +## 7. Mechanical enforcement + +A repo that adopts these rules should ship a mechanical backstop: + +- **Body-drift scan**: a scheduled script that greps open issue bodies for renamed paths, phantom paths, stale cross-refs, stale schema claims, and label drift. Output goes into a single tracking issue that gets updated on each run. +- **CHANGELOG gate**: a pre-commit hook + CI gate that refuses commits / PRs touching production source without a CHANGELOG entry. +- **Issue forms with labeler**: GitHub form templates for new issues + a workflow on `issues.opened` that translates form dropdown values into matching labels. +- **Style-tell commit-msg hook**: a `commit-msg` hook that refuses messages containing em dashes (U+2014), the forbidden AI-attribution phrases from Rule 5.1, or AI-signature emojis. Keep it portable (BSD-grep compatible, no PCRE dependency). Install per repo by symlink or copy into `.git/hooks/commit-msg`. Strip comment lines and the diff scissors block before checking so notes in `# ...` lines are not penalised. + +**Why mechanical enforcement matters.** A disciplined author following Rule 5.2 can still push a commit whose message contains an em dash, because nothing at the commit boundary stops it. A 3-line `commit-msg` hook prevents this entire failure class at write time, after which `git push` cannot carry a violation regardless of who or what wrote the message. Discipline scales with attention; hooks do not. + +## Triggers (when to load this rule) + +Load this rule when: + +- Filing a new GitHub issue (Rule 1.1, Rule 1.2, Rule 1.3, Rule 1.6) +- Editing an open issue body (Rule 1.1, Rule 1.4, Rule 1.5) +- Adding or deleting a GitHub label (Rule 2.1, Rule 2.2, Rule 2.3, Rule 2.4) +- Opening a PR (Rule 3.1, Rule 3.2, Rule 3.3, Rule 3.4) +- Creating a branch (Rule 4.1, Rule 4.2, Rule 4.3) +- Writing a commit message that will be pushed (Rule 5.1, Rule 5.2) +- Running `git commit --amend` (Rule 5.3) +- Running `git push` (Rule 6.1, Rule 6.2) +- Designing a tracker-discipline mechanism for a new repo (Rule 7) + +Load on demand per the trigger; do not auto-load. diff --git a/docs/rules/namespacing.md b/docs/rules/namespacing.md new file mode 100644 index 0000000..b505cd8 --- /dev/null +++ b/docs/rules/namespacing.md @@ -0,0 +1,244 @@ +# Swift namespaces via enums or structs, canonical rule + +Single-page rule for picking the namespace-anchor type in PureXML code: when the anchor is `Foo`, should it be `enum Foo`, `struct Foo`, or `class Foo`? + +Companion to [code-style.md](code-style.md), which covers the broader namespacing discipline (folder mirroring, file-naming convention, anchor file placement, one-type-per-file). This file answers the narrower type-choice question. + +## TL;DR + +**Default: caseless `enum`.** Use `struct` only when the type also represents a value. Never `class`. + +```swift +// Pure namespace, caseless enum. +public enum Shared {} +extension Shared { + public enum Constants {} // nested namespace + public enum Configuration {} // nested namespace +} + +// Value type that also acts as a namespace for its own helpers, struct. +public struct URL: Sendable, Equatable { + public let absoluteString: String + public init(_ s: String) { self.absoluteString = s } + + public struct Components { /* nested type */ } + public static let placeholder = URL("about:blank") +} +``` + +## Why caseless `enum`? + +A namespace anchor exists to hold nested types and static members. It should be: + +1. **Uninstantiable.** `let x = Shared()` must not compile. A caseless enum can't be instantiated because there are no cases to construct it from. A `struct` *can* be instantiated (Swift synthesises `init()` for an empty struct, yielding a zero-sized instance); a `class` even more so. Only `enum` gives uninstantiability by construction, with no extra code required. +2. **Free of ARC and runtime overhead.** A `class` carries a refcount header even when empty. A `struct` carries no header but is still a value. A caseless `enum` has no runtime footprint. +3. **Cleanly extensible.** `extension Shared { ... }` adds nested types from any file. Standard Swift practice; works identically across enum, struct, and class. +4. **Idiom recognised by the Swift community since 2015.** Reading `enum Foo {}` tells the next reader "this is a namespace" immediately. Reading `struct Foo {}` is ambiguous: could be a namespace, could be an empty value. + +The Swift Standard Library uses `enum` for the same purpose. `enum Result` and `enum Optional` are case-bearing, but the pure-namespace pattern in user libraries follows this rule. + +## When to use `struct` + +Use `struct` for the namespace anchor **only** when the type: + +- Holds **value-typed state** the consumer cares about, AND +- Also serves as the container for nested types or static helpers. + +```swift +// The type represents a value (the resolved paths) AND holds nested helpers +// (Paths.live()). Struct is correct here. +public struct Paths: Sendable { + public let baseDirectory: URL + public let docsDirectory: URL + // ... derived URLs ... + + public static func live() -> Paths { /* ... */ } +} +``` + +If the namespace has no state and no instances, `enum` is correct. If it represents a value, `struct` is correct. Never both; split into a struct value plus an enum namespace if both roles are needed. + +## When to use `class` + +**Never**, for namespacing. Class adds: + +- Refcounted heap allocation (even for empty classes, Swift still emits an instance header). +- A deinit slot. +- Inheritance complications (`open` vs `final`). + +A namespace doesn't need any of that. If you're tempted to use a class for "shared mutable state in a namespace," you actually want an **actor** or a struct value injected through DI, not a namespace anchor. + +**When to reach for `actor` instead of `enum` namespace.** All three of these must hold: (a) the state is mutable, (b) the state is genuinely shared across concurrent contexts (multiple tasks read or write it), (c) the access pattern fits serial isolation. If any one is false, the right answer is a struct value injected through `init`, not a namespace anchor and not an actor. + +## What about protocols? + +A `protocol` is not a namespace anchor. Protocols name a contract; namespaces hold types and static members. The two roles don't overlap. + +A protocol MAY live inside a namespace: + +```swift +public enum Logging {} +extension Logging { + public protocol Recording: Sendable { ... } +} +``` + +The namespace is still `enum Logging`. The protocol is a nested type. For cross-target seams, the protocol lives in a foundation-only `*Models` target (cross-package coupling via protocols, not concrete imports). The composition root wires the concrete conformer. + +## Anti-patterns + +### `struct` for a pure namespace + +```swift +// Don't. Shared() is constructible even though it has no meaning. +public struct Shared {} + +let s = Shared() // compiles, allocates nothing, but is meaningless +``` + +Use `enum Shared {}` instead. + +### `enum` with a private initialiser (the pre-2015 workaround) + +```swift +// Don't. Caseless enum already prevents instantiation; no init needed. +public enum Shared { + private init() {} // unreachable; clutters the API +} +``` + +Just `public enum Shared {}`. No init. + +### Stateless `class` as namespace + +```swift +// Don't. Class adds ARC overhead for zero benefit. +public final class Shared { + public static let constants = Constants() +} +``` + +Use `enum Shared {}` plus extensions. + +### Nesting a `struct` that's only a namespace inside a value type + +```swift +// The outer struct is a value; the nested "namespace" struct adds no value +// but is constructible. +public struct Configuration { + public let renderer: Renderer + + public struct Constants { // nested but a namespace, should be enum + public static let fileName = "config.json" + } +} +``` + +Inner type should be `public enum Constants` since it carries no state. + +## Singular vs plural for kind-group namespaces + +When a namespace holds N sibling implementations of one concept (e.g. several format parsers, several content fetchers, multiple model types), either singular (`Parser.CSV`, `Fetcher.Remote`, `Model.Tile`) or plural (`Parsers.CSV`, `Fetchers.Remote`, `Models.Tile`) is correct. Pick one per project and stay with it. Apple's Combine uses plural (`Publishers.Map`, `Subscribers.Sink`); some projects pick singular to match folder-name conventions. The rule is intra-project consistency, not the choice itself. + +The one exception is `Protocol` (singular). Swift treats `T.Protocol` as a reserved metatype member on every type expression, so a namespace literally cannot be named `Protocol`. MUST be `Protocols` plural OR a semantic rename (`Wire`, `Spec`) regardless of the project's singular-vs-plural default. See `Reserved names` in [code-style.md](code-style.md). + +## When nesting + +Nest a namespace inside another namespace by extending: + +```swift +public enum Shared {} +extension Shared { + public enum Constants {} + public enum Configuration {} +} +extension Shared.Constants { + public enum FileName { + public static let renderDatabase = "render.db" + public static let metadata = "metadata.json" + } +} +``` + +Each level of nesting stays a caseless `enum`. File layout: one nested namespace per file, named `Shared.Constants.FileName.swift`, etc. Folder layout mirrors the namespace tree: + +```text +Sources/SharedConstants/ +├── Shared.swift # public enum Shared {} +├── Constants/ +│ ├── Shared.Constants.swift # public enum Shared.Constants {} +│ ├── Shared.Constants.FileName.swift +│ └── Shared.Constants.BaseURL.swift +``` + +## Why the file-rename convention matters + +When the namespace is `Shared.Constants.FileName`, the file should be `Shared.Constants.FileName.swift`. Reasons: + +1. `git grep "Shared.Constants.FileName"` finds both the declaration site (via filename) and usages (via type reference) in one search. +2. Re-namespacing the type (moving `FileName` under a different parent) becomes a single `git mv`. +3. Folder structure mirrors the namespace tree without a separate map file. + +See [code-style.md](code-style.md) for the full file-naming rule. + +## Anchor file convention + +Every public namespace gets a single declaration file: + +```swift +// Shared.swift +public enum Shared {} +``` + +```swift +// Shared.Constants.swift +extension Shared { + public enum Constants {} +} +``` + +```swift +// Shared.Constants.FileName.swift +extension Shared.Constants { + public enum FileName { + public static let renderDatabase = "render.db" + // ... + } +} +``` + +Anchor files contain ONLY the `extension X { public enum Y {} }` line, no implementation. Implementation lives in sibling files inside the same nested namespace. The anchor plus folder convention makes the namespace tree mechanically discoverable: `find Sources/SharedConstants -name "Shared.*.swift"` lists every public-anchor file. + +## Apple precedent + +- **Swift Standard Library:** `enum Never`, `enum CommandLine`, `enum FloatingPointRoundingRule`, `enum ProcessInfo.ThermalState`. Caseless enums for both true-namespace types (`CommandLine`) and case-bearing enums. +- **Foundation:** `struct URL`, `struct UUID`, `struct Data`. Structs because they carry value state. Their nested types (`URL.ResourceValues`) are also structs because they carry state. `URLSession` is a class because it owns resources. +- **SwiftUI:** `View` is a protocol; conforming types are structs because they're value-types describing UI. `enum Edge`, `enum Axis` are case-bearing enums. + +The cross-cutting rule: **type follows function.** Namespace, `enum`. Value, `struct`. Reference identity or shared mutable state, `class` or `actor`. + +## Migration tip + +If a codebase has `struct Shared {}` or `class Shared {}` as a namespace, the simple cases are mechanical: + +```bash +# Find every empty-body declaration +git grep -l 'struct Shared {}\|class Shared {}' + +# Replace +sed -i.bak 's/public struct Shared {}/public enum Shared {}/' +sed -i.bak 's/public class Shared {}/public enum Shared {}/' +``` + +**The regex handles the empty-body case only.** Declarations with conformances (`public struct Shared: Sendable {}`), inheritance clauses, or any body content will not match. Do those rewrites by hand, one at a time, after auditing the existing conformances and members. Don't sed-blast complex declarations; the result is too easy to corrupt silently (a missed conformance, a body line left behind referring to `self`, etc.). + +Build and tests verify the safe cases: anything that was constructing the empty struct or class will fail to compile, and those callsites were wrong anyway. + +## Reference + +- Swift API Design Guidelines: https://swift.org/documentation/api-design-guidelines/ +- "Caseless enums for namespaces" idiom: Erica Sadun, 2015 (widely adopted since). + +## Cross-references + +- [code-style.md](code-style.md) covers the broader namespacing discipline (folder mirroring, file-naming convention, anchor file placement, one-type-per-file, reserved names, namespace-vs-module collisions). Read this file first for the anchor-type choice, then code-style.md for the operational details. diff --git a/docs/rules/package-architecture.md b/docs/rules/package-architecture.md new file mode 100644 index 0000000..71c857a --- /dev/null +++ b/docs/rules/package-architecture.md @@ -0,0 +1,37 @@ +# Package Architecture + +PureXML starts as one root Swift package with one library target: + +- product: `PureXML` +- target: `PureXML` +- tests: `PureXMLTests` + +## Rules + +- Keep dependencies explicit and minimal. +- Keep external dependencies at zero unless the maintainer explicitly changes the + project contract. +- Add a new target only when a responsibility needs independent compilation, + testing, or platform boundaries. +- Keep dependencies unidirectional if new targets are added. + +## Current Layers + +```text +PureXML +├── Model ordered YAML value tree +├── Parsing parser implementation +└── Emitting dumper implementation +``` + +The public namespace tree mirrors this layout. + +## When to Split a Target + +Do not split for aesthetics. Split when one of these becomes true: + +- the parser and emitter need independent platform gates +- a reusable scanner/lexer becomes large enough to test independently +- a compatibility layer for Yams-style decoding needs isolation from the core + model +- a CLI or research tool appears and should not ship as library code diff --git a/docs/rules/package-structure.md b/docs/rules/package-structure.md new file mode 100644 index 0000000..736872e --- /dev/null +++ b/docs/rules/package-structure.md @@ -0,0 +1,38 @@ +# Package and Repository Structure + +PureXML is a root Swift package. + +## Required Layout + +```text +PureXML/ +├── Package.swift +├── Sources/ +│ ├── PureXML.swift +│ ├── Model/ +│ ├── Parsing/ +│ └── Emitting/ +├── Tests/ +│ └── PureXMLTests/ +├── docs/ +└── scripts/ +``` + +## Rules + +- Keep `Package.swift` at repository root. +- Do not create a `Packages/` folder. +- Keep production code directly under `Sources`. +- Keep tests in `Tests/PureXMLTests`. +- Keep `Package.swift` dependency-free. +- Add new targets only when a responsibility genuinely needs isolation. + +## Namespace Mapping + +The namespace tree mirrors the source tree: + +- `Sources/Model/Value.swift` -> `PureXML.Model.Value` +- `Sources/Parsing/Parser.swift` -> `PureXML.Parsing.Parser` +- `Sources/Emitting/Dumper.swift` -> `PureXML.Emitting.Dumper` + +The root namespace file, `Sources/PureXML.swift`, is a map of namespaces only. diff --git a/docs/rules/shared-protocols.md b/docs/rules/shared-protocols.md new file mode 100644 index 0000000..50adff7 --- /dev/null +++ b/docs/rules/shared-protocols.md @@ -0,0 +1,178 @@ +# Shared Protocols Target + +The pattern for a single foundation-only target that holds every cross-target protocol seam. PureXML is a monorepo from day one with many targets in one package, so the seam target applies as soon as one producer target needs to talk to another without importing its concretes. Add it when the first cross-target seam appears. + +Companion to [dependency-injection.md](dependency-injection.md) and [package-import-contract.md](package-import-contract.md). The target described here is conventionally named `SharedProtocols`. + +## Target regime + +Across the package's targets, every internal target falls into one of two categories: + +1. **Producer / feature targets** import only `SharedProtocols` plus external dependencies (Foundation, SwiftUI, Combine, third-party SPM). Their SPM `dependencies:` list is `["SharedProtocols", ...external products]` and nothing else internal. +2. **Composition root and live-writer targets** (the CLI, networking, storage, environment, and similar glue) import `SharedProtocols` plus the external framework they bridge. Their `dependencies:` list is `["SharedProtocols", ...the framework being wrapped]`. These are the only places where protocol-to-concrete glue exists. + +There is no third category. + +## Hard rules + +1. **`SharedProtocols` has zero internal dependencies.** Its SPM `dependencies:` list is `[]`. +2. **Allowed imports inside `SharedProtocols`:** any external framework (`Foundation`, `SwiftUI`, `Combine`, a logging library, etc.). **Forbidden:** any internal package. +3. **Declarations are top-level.** `public protocol Recording`, not nested under a namespace anchor like `extension SharedProtocols { protocol Recording }`. +4. **Naming follows the domain noun form.** `Recording`, `FileSystem`, `HTTPRequester`. The protocol is the noun; the conformer is `LiveRecording`. You do not have to suffix the protocol with `-able` or `-ing`. +5. **One concept per file**, filename equal to the type name. Group related files in subfolders (`Coordinators/`, `Providers/`) when there are three or more related declarations. +6. **DEBUG-only preview implementations live here too**, as a `Preview_*_Impl` no-op shape wrapped in `#if DEBUG`. + +## What goes in `SharedProtocols` + +- **Protocol seams:** every protocol that exists to decouple a producer from a concrete (`Recording`, `HTTPRequester`, `FileSystem`, `UUIDProvider`, `DateProvider`, and so on). +- **Coordinator protocols** for navigation/flow when the codebase uses them. +- **Value types** that travel across targets: enums (`HTTPMethod`, `LogLevel`), small structs (`HTTPResponse`, a domain model). +- **Null implementations** (`NullRecording`, `NullHTTPRequester`) so producers can default to a no-op without importing a live writer. +- **DEBUG-only `Preview_*_Impl`** no-op conformances for SwiftUI previews and tests. + +## What does NOT go in `SharedProtocols` + +- **Live writers and concrete services** (`LiveRecording`, `LiveHTTPClient`, `LiveFileSystem`). These live in their own glue targets. +- **Per-capability locator protocols invented just to satisfy a DI checklist.** If the composition root can probe the environment in three lines and decide whether to register a service, do not manufacture a one-method protocol for it. + +## Why + +Cross-package coupling through protocols (rather than concrete imports) only works if the protocol is reachable without dragging in the rest of the producer's universe. If a protocol lives in producer package `Logging`, then any consumer must `import Logging` to see it. Multiply that across every seam type and a producer ends up importing many internal packages. `SharedProtocols` collapses that to one import. + +Live writers import `SharedProtocols` for the protocol and the external framework for the concrete, never an intermediate package. + +## Example + +```swift +// Sources/SharedProtocols/Coordinators/AppCoordinator.swift +import SwiftUI +import Combine + +@MainActor +public protocol AppCoordinator: AnyObject { + func showWelcome() + func showMainApp() + func start() +} +``` + +```swift +// Sources/SharedProtocols/Coordinators/PreviewHelpers.swift +import SwiftUI + +#if DEBUG +public final class Preview_AppCoordinator_Impl: AppCoordinator { + public init() {} + public func showWelcome() {} + public func showMainApp() {} + public func start() {} +} +#endif +``` + +```swift +// Sources/SharedProtocols/TileSummary.swift +import Foundation + +public struct TileSummary: Identifiable { + public var id: UUID + public var title: String + public init(id: UUID = .init(), title: String) { + self.id = id + self.title = title + } +} +``` + +`Package.swift`: + +```swift +let sharedProtocolsTarget = Target.target( + name: "SharedProtocols", + dependencies: [] // zero internal deps +) +``` + +Every feature target depends on `SharedProtocols` and nothing else internal: + +```swift +let tileFeatureTarget = Target.target( + name: "TileFeature", + dependencies: ["SharedProtocols"] +) +``` + +## Anti-patterns + +### Per-capability locator protocols + +```swift +// Avoid: a protocol invented to satisfy a DI checklist +public protocol BinaryLocating: Sendable { + func locateBinary() -> String? +} +``` + +The composition root can probe the filesystem itself in three lines. The producer does not need a locator abstraction. + +### Protocol nested under a namespace anchor + +```swift +// Avoid in SharedProtocols +extension SharedProtocols { + public protocol Recording { /* ... */ } +} +``` + +Top-level (`public protocol Recording`) reduces noise at call sites and matches `import SharedProtocols` ergonomics. + +### Inline protocol declared in a producer file + +```swift +// Avoid: a cross-target seam declared in the producer that uses it +public protocol TileProviding: Sendable { + func validate() async throws -> Bool +} +``` + +A composition root would have to import the producer to see the protocol. The dependency arrow is reversed. + +## Stop rule + +Before adding a new `import` to a producer Swift file: + +1. Is it an external framework or SPM product? Fine. +2. Is it `SharedProtocols`? Fine. +3. Anything else internal? **STOP.** Either move the seam into `SharedProtocols`, or push the orchestration up to the composition root. + +## CI guard + +```bash +# SharedProtocols must have no internal-package imports. +# External imports (Foundation, SwiftUI, Combine, ...) are fine. +INTERNAL_TARGETS=$(swift package describe --type json | jq -r '.targets[].name') +for ln in $(grep -rn '^import ' Sources/SharedProtocols/); do + mod=$(echo "$ln" | awk '{print $2}') + if echo "$INTERNAL_TARGETS" | grep -qx "$mod"; then + echo "FAIL: SharedProtocols imports internal target $mod ($ln)" + exit 1 + fi +done +``` + +## Sequencing + +If you migrate existing scattered protocols into `SharedProtocols`, do it as one coordinated arc, not a per-package vote: + +1. Move every cross-target protocol and value type into `SharedProtocols`. +2. Update every internal target's deps: producers gain `["SharedProtocols"]` and drop intermediate packages; live writers gain `["SharedProtocols", ]`. +3. Update every old `import` to `import SharedProtocols`. +4. Delete the now-empty intermediate source directories and target declarations. +5. Build and test green; commit. + +## Related rules + +- [dependency-injection.md](dependency-injection.md): DI principles the seams serve +- [package-import-contract.md](package-import-contract.md): what each target may import +- [package-architecture.md](package-architecture.md): package layers and granularity +- [package-structure.md](package-structure.md): repository and manifest layout diff --git a/docs/rules/systematic-debugging.md b/docs/rules/systematic-debugging.md new file mode 100644 index 0000000..a5bded7 --- /dev/null +++ b/docs/rules/systematic-debugging.md @@ -0,0 +1,75 @@ +# Systematic Debugging + +Find and articulate the root cause of any bug, test failure, or unexpected behavior before proposing or applying a fix. Symptom fixes are forbidden until you can explain why the symptom occurs. + +## Core rules + +### Rule 1: Reproduce first, investigate second, fix third + +Complete the phases in order. +- Get a deterministic reproduction (test, sample input, repro script) before reading code +- MUST identify the smallest change that flips the outcome before naming it the cause +- MUST NOT propose a fix until you can describe the cause in one sentence + +### Rule 2: The four phases + +``` +1. REPRODUCE + - capture exact failing command (swift test --filter X, app run with input Y) + - confirm it fails consistently + - if flaky, treat the flake as the primary bug + +2. ISOLATE + - bisect: which target, which file, which line + - for tests: shrink to the smallest failing assertion + - for crashes: get the full stack trace with line numbers + +3. EXPLAIN + - state the root cause in one sentence + - identify the invariant that was violated or assumption that was wrong + - if you cannot explain, you have not found the cause yet + +4. FIX + - the smallest change that addresses the cause, not the symptom + - add or update a test that would have caught it + - run the verification gate (see docs/rules/verification.md) +``` + +### Rule 3: Common Swift pitfalls to check first + +Consider these before deeper investigation: + +- **Force unwrap on optional**: search for `!` near the crash site; trace where the optional became nil +- **Concurrency**: was the failing code on the expected actor or thread? Check `@MainActor`, `Sendable` violations, structured concurrency hops +- **Dependency injection**: did a test forget `withDependencies`? Did a default `liveValue` leak into the test? +- **Strict concurrency**: Swift 6 errors often surface as "data race" warnings in Swift 5 mode. Check the migration mode of the affected target. +- **Generic type inference**: a fix that "looks right" but the compiler flags is often hiding an inference loop +- **Cache or stale build**: try `swift package clean && swift build` if behavior contradicts the visible source + +### Rule 4: Banned behaviors during debugging + +**MUST NOT**: +- Apply a fix to make the test pass without understanding why it was failing +- Wrap the failing call in `try?` or `if let` to silence the symptom +- Add `@MainActor` to "fix" a concurrency error without confirming the call site needed it +- Disable the failing test +- Skip the test with `.disabled` and move on +- Attempt multiple fixes in parallel hoping one sticks + +If a fix does not hold, return to phase 2 (Isolate). Do not stack guesses. + +### Rule 5: Reporting + +State, in this order, when reporting on a debugging session: +1. The reproduction (command + expected vs actual) +2. The root cause (one sentence) +3. The fix (what changed and why it addresses the cause, not the symptom) +4. The verification (which test now passes that did not before) + +## Anti-Patterns + +- "I think it might be X, let me try" applied to multiple guesses in series +- Adding logging without a hypothesis to test +- Reading the whole file looking for "something off" instead of bisecting +- Calling a flake "transient" without finding the race +- Declaring a fix complete because the failing test now passes, without checking why the others still pass diff --git a/docs/rules/testing-discipline.md b/docs/rules/testing-discipline.md new file mode 100644 index 0000000..ff0b034 --- /dev/null +++ b/docs/rules/testing-discipline.md @@ -0,0 +1,88 @@ +# Testing Discipline + +When a change touches code, that change is not complete until tests have run. This rule defines when to run existing tests, when to write new ones, and what to report. + +When the change touches code, the change is not complete until tests have run. If tests exist for the area being changed, run them. If no tests exist for the area being changed and the change is non-trivial, write them. Type-checks and successful builds are not tests. + +This rule applies to any code change: new feature, bug fix, refactor, dependency bump, configuration change that affects behaviour. It does not apply to pure prose edits, documentation-only commits, or single-file readme touches. + +## Core rule + +### Rule 1: If a test suite exists, run it. + +Before declaring the change done, run the project's test command: + +- Swift Package: `swift test` +- Swift app target: project test scheme via `xcodebuild test` +- Python: `pytest`, `python -m unittest`, or the project's chosen runner +- JavaScript / TypeScript: `npm test`, `pnpm test`, `yarn test`, or the project's chosen runner +- Shell scripts: `bats`, `shellspec`, or the project's chosen runner +- Anything else: the command in the project's `README.md`, `Makefile`, `package.json` scripts block, or `pyproject.toml` `[tool.*.scripts]`. + +The required output is the actual runner producing pass/fail counts, not a successful compile. `swift build` is not a test run. `tsc --noEmit` is not a test run. The test runner must execute and report. + +Failures block the commit. Investigate and fix before shipping. Skipping with `.skip` / `xfail` / `@Disabled` is allowed only when the disabled test is genuinely unrelated to the current change and a follow-up issue is filed. + +### Rule 2: If no test suite exists for the touched area, write one. + +If the change adds a function, a class, a route, a CLI verb, a SQL view, a database migration, or any other addressable unit of behaviour, that unit gets at least one test. The test must: + +- Exercise the unit by calling the public entry point (not by re-implementing it inside the test). +- Use real fixtures or a real local resource where the project supports one (real SQLite file, real temp directory, real fixture markdown). Mocks only when the dependency is a network call, an external service, or something the test infrastructure cannot reach. +- Fail when the implementation is wrong. Author a deliberate-bug version of the unit and confirm the test catches it. A test that passes against a broken implementation is not a test. +- Live in the project's existing test target / directory. Do not create a new top-level test scaffold beside a working one. + +Trivial changes are exempt: a single-character typo fix, a comment, a doc string. Use judgement, but the bar is low: if there is *any* behaviour change, a test exists or gets written. + +### Rule 3: Match the project's testing style. + +A Swift project using Swift Testing (`@Test`, `@Suite`, `#expect`) does not get XCTest stubs added to it. A Python project using `pytest` does not get a `unittest.TestCase` class added. Read one or two existing tests, mirror their structure (fixtures, naming, parameterisation), and write the new test in the same idiom. Inconsistency in test style is itself a defect. + +### Rule 4: Real tests beat smoke tests. + +A "smoke test" that asserts only that the unit returns without throwing is the minimum acceptable form, not the target. Where the project has fixture-driven tests, the new test loads or constructs realistic input and checks realistic output. Prefer fixture-driven tests over smoke-only tests, and convert smoke-only tests into fixture-driven ones when you touch them. + +A test that does not distinguish a correct implementation from a wrong one is no test. If a test can be satisfied by `return nil` or `return 0` or `return true`, strengthen it until it cannot. + +### Rule 5: State the result in the response. + +When reporting that a change is done, name the test command run and the result counts. "Ran `swift test`: 47 tests, 0 failures" is the deliverable, not "I made the change." If someone is going to verify the work, the test result is the verification, so it must be cited. + +If tests were not run because none exist and the change was too small to justify writing one, say so explicitly. Do not let "I changed X" stand alone as a completion claim when there was a test command available. + +## Exceptions + +The rule does not require tests for: + +- Documentation-only changes (`docs/`, `README.md`, code comments). +- Renames where the compiler proves equivalence (Swift `swift build` passes, TypeScript `tsc` passes, Python `mypy` passes). +- Configuration changes that have no behavioural effect (formatter config, editor settings). +- Generated files (lockfiles, build output, vendored dependencies). + +When in doubt, write the test. The cost of writing a test you did not strictly need is small. The cost of shipping a regression to a system without a test that would have caught it is large. + +## Mechanical enforcement + +Where the project supports it, add the test run to: + +- A pre-push hook that runs the project's test command and refuses the push on non-zero exit. +- A CI workflow (GitHub Actions, or any CI) that runs tests on every PR and blocks merge on red. +- A pre-commit hook for fast-running suites (under a few seconds). Slow suites belong on push or in CI. + +The CI gate is the durable backstop. The local hook catches things before they hit CI. Neither replaces the responsibility to run tests during the work; both prevent forgetting at the boundary. + +## Why + +Tests are the only artifact that can answer "does this change do what I claim it does" without re-reading the code. A change that compiles, type-checks, and was reviewed is still untested. The reviewer assumed the author ran tests; the author assumed the type-checker was enough; the regression ships. The rule closes that loop by making "did you run the tests" a question with a citable answer. + +The cost of running an existing suite is seconds to minutes. The cost of debugging a regression after it ships is hours, plus the cost in trust that a doc, a number, or a guarantee in the codebase is reliable. Run the tests. + +## Triggers (when to apply this rule) + +Apply this rule when: + +- Starting any code change task (feature, fix, refactor, dependency bump). +- About to declare a code change done. +- Reviewing whether a change is ready to commit. +- Designing a new project's test infrastructure or CI workflow. +- Auditing an existing project for tests-vs-code coverage. diff --git a/docs/rules/testing.md b/docs/rules/testing.md new file mode 100644 index 0000000..dfa1acb --- /dev/null +++ b/docs/rules/testing.md @@ -0,0 +1,681 @@ +# Swift Testing Framework Rules + +How to write tests for PureXML: focused, isolated, deterministic suites built on the Swift Testing framework. + +Write comprehensive tests using the Swift Testing framework (`@Test`). Tests must be focused, isolated, deterministic, and leverage modern Swift Testing features for maximum reliability and maintainability. The core engine (`PureXML`, the `Tile` / `TileType` primitives, the `PureXML` namespace) is non-UI and is exercised with plain unit and integration tests. The SwiftUI/ViewInspector/snapshot patterns below apply to the planned native macOS/iOS app, not to the engine. + +The `withDependencies` overrides shown in the patterns below assume the Point-Free Dependencies library, whose adoption is an open question; see [../decisions/point-free-dependencies.md](../decisions/point-free-dependencies.md). Where PureXML does not use that library, control collaborators through plain constructor injection instead; the test-isolation and determinism rules apply either way. + +## Core rules + +### Rule 0: Exact Diagnostics Discipline + +PureXML tests must use Swift Testing and pin exact behavior: + +- Use focused `test_...` method names inside `@Suite` groups. +- Use direct equality checks for concrete values, rendered output, token/event + descriptions, paths, and error values. +- Use helper functions for repeated assertions and round-trip/error checks. +- Cover success and expected-failure behavior for every supported surface. +- For parser/scanner/emitter/model tests, assert all relevant dimensions: + what output must be present, what output must be absent, what must succeed, + what must fail, and the exact value or error returned. +- Error tests must pin concrete error cases and descriptions where the error is + user-facing; do not stop at "throws". +- Keep table-driven/argument tests for families of related cases. +- Keep tests fixture-like and realistic enough that a stub implementation cannot + satisfy them accidentally. +- Do not add XCTest-style `XCTestCase`, `XCTAssert...`, or mixed XCTest helpers + to new PureXML tests. + +### Rule 1: Swift Testing framework usage + +Use modern Swift Testing: +- MUST use `@Test` attribute for test methods +- MUST use `#expect` macro for assertions +- MUST use parameterized tests for multiple scenarios +- MUST NOT use XCTest unless absolutely required + +### Rule 2: Dependency isolation + +Control dependencies in tests: +- MUST control collaborators in tests (via `withDependencies` if the Dependencies library is adopted, otherwise via constructor injection) +- MUST provide deterministic test values +- MUST isolate tests from external systems +- MUST NOT use live dependencies in tests + +### Rule 3: Test organization + +Structure tests clearly: +- MUST use `@Suite` for logical grouping +- MUST name test methods with the `test_...` convention +- MUST keep `@Test("...")` descriptions clear and user-readable +- MUST test one behavior per test +- MUST NOT create kitchen sink tests + +### Rule 4: Async testing + +Handle concurrency properly: +- MUST use async/await for async tests +- MUST avoid race conditions +- MUST use structured concurrency +- MUST NOT use arbitrary delays + +### Rule 5: Test pyramid + +Follow testing hierarchy: +- MUST have ~70% unit tests (fast, isolated) +- MUST have ~20% integration tests (component interaction) +- MUST have ~10% UI/E2E tests (critical paths) +- MUST NOT invert the pyramid + +## TEST TYPE DECISION TREE + +``` +What are you testing? +├─ Pure logic/calculations? +│ └─ Unit Test → Direct function tests +├─ State management? +│ └─ Unit Test → ViewModel/Model tests +├─ Component interaction? +│ └─ Integration Test → Multi-component tests +├─ User interface (planned native app)? +│ ├─ Visual correctness? → Snapshot Test +│ └─ User interaction? → ViewInspector Test +└─ Complete user flow? + └─ E2E Test → Full flow test +``` + +## TESTING PATTERNS + +### Pattern 1: Basic Test Structure + +```swift +// RULE: Each test file follows this structure +import Testing +import Dependencies +@testable import PureXML + +@Suite("Tile Rendering Tests") +struct TileRenderingTests { + // RULE: Group related tests in nested suites + @Suite("Markdown Tile") + struct MarkdownTileTests { + // RULE: Shared test data at suite level + let fixture = TileFixture() + + @Test("Clear description of what should happen") + func renderScenario() async throws { + // RULE: Arrange dependencies + let sut = withDependencies { + $0.fileClient.read = { _ in self.fixture.markdown } + } operation: { + TileRenderer() + } + + // RULE: Act on the system + let result = try await sut.render(.markdown) + + // RULE: Assert the outcome + #expect(result == expectedHTML) + } + } +} +``` + +### Pattern 2: Parameterized Testing + +```swift +// RULE: Use parameterized tests for similar scenarios +@Test("Validates different slug formats", arguments: [ + ("hello-world", true), + ("Hello World", false), + ("", false), + ("trailing-", false), + ("-leading", false) +]) +func slugValidation(slug: String, isValid: Bool) { + let validator = SlugValidator() + #expect(validator.isValid(slug) == isValid) +} + +// RULE: Use table-driven tests for complex scenarios +struct TestCase { + let input: String + let expectedOutput: String + let shouldThrow: Bool +} + +@Test("Processes various inputs correctly", arguments: [ + TestCase(input: "hello", expectedOutput: "HELLO", shouldThrow: false), + TestCase(input: "", expectedOutput: "", shouldThrow: false), + TestCase(input: "123", expectedOutput: "", shouldThrow: true) +]) +func processingScenarios(testCase: TestCase) async throws { + let processor = TextProcessor() + + if testCase.shouldThrow { + await #expect(throws: ProcessingError.self) { + try await processor.process(testCase.input) + } + } else { + let result = try await processor.process(testCase.input) + #expect(result == testCase.expectedOutput) + } +} +``` + +### Pattern 3: ViewModel Testing (planned native app) + +```swift +// RULE: Test ViewModels with controlled dependencies +@Suite("TilePreviewViewModel Tests") +struct TilePreviewViewModelTests { + @Test("Initial state is correct") + func initialState() { + let viewModel = TilePreviewViewModel() + + #expect(viewModel.tile == nil) + #expect(viewModel.isLoading == false) + #expect(viewModel.error == nil) + } + + @Test("Loading a tile shows loading states") + func loadingStateTransitions() async { + // RULE: Track state changes + var states: [LoadingState] = [] + + let viewModel = withDependencies { + $0.tileClient.fetch = { _ in + try await Task.sleep(for: .milliseconds(50)) + return .mock + } + } operation: { + TilePreviewViewModel() + } + + // RULE: Observe state changes + let observation = viewModel.observe(\.isLoading) { loading in + states.append(loading ? .loading : .idle) + } + + await viewModel.loadTile(id: UUID()) + + #expect(states == [.idle, .loading, .idle]) + #expect(viewModel.tile == .mock) + } + + @Test("Handles errors gracefully") + func errorHandling() async { + struct TestError: Error, Equatable {} + + let viewModel = withDependencies { + $0.tileClient.fetch = { _ in throw TestError() } + $0.logger.log = { _, _ in } // Silence logs in tests + } operation: { + TilePreviewViewModel() + } + + await viewModel.loadTile(id: UUID()) + + #expect(viewModel.error as? TestError == TestError()) + #expect(viewModel.tile == nil) + #expect(!viewModel.isLoading) + } +} +``` + +### Pattern 4: Async Stream Testing + +```swift +// RULE: Test async streams properly +@Suite("File Watcher Tests") +struct FileWatcherTests { + @Test("Processes a change stream") + func changeStream() async throws { + let changes = ["post-a.md", "post-b.md", "index.md"] + var received: [String] = [] + + let viewModel = withDependencies { + $0.watcherClient.changes = { + AsyncStream { continuation in + for change in changes { + continuation.yield(change) + } + continuation.finish() + } + } + } operation: { + RebuildViewModel() + } + + // RULE: Collect all changes + for await change in viewModel.changeStream { + received.append(change) + } + + #expect(received == changes) + } + + @Test("Handles stream errors") + func streamErrorHandling() async throws { + struct StreamError: Error {} + + let viewModel = withDependencies { + $0.watcherClient.changes = { + AsyncThrowingStream { continuation in + continuation.yield("first.md") + continuation.finish(throwing: StreamError()) + } + } + } operation: { + RebuildViewModel() + } + + var changes: [String] = [] + var errorCaught = false + + do { + for try await change in viewModel.changeStream { + changes.append(change) + } + } catch is StreamError { + errorCaught = true + } + + #expect(changes == ["first.md"]) + #expect(errorCaught) + } +} +``` + +### Pattern 5: View Testing with ViewInspector (planned native app) + +```swift +// RULE: Test SwiftUI views in isolation +import ViewInspector + +@Suite("TileCardView Tests") +struct TileCardViewTests { + @Test("Displays tile information") + func tileDisplay() throws { + let tile = Tile(title: "Hello World", type: .markdown) + let view = TileCardView(tile: tile) + + let sut = try view.inspect() + + // RULE: Find and verify UI elements + let titleText = try sut.find(text: "Hello World") + #expect(try titleText.string() == "Hello World") + + let typeText = try sut.find(text: "Markdown") + #expect(try typeText.string() == "Markdown") + } + + @Test("Interaction triggers callbacks") + func buttonInteraction() throws { + var tapped = false + let view = TileCardView( + tile: .mock, + onTap: { tapped = true } + ) + + let sut = try view.inspect() + let button = try sut.find(button: "Open") + try button.tap() + + #expect(tapped) + } +} +``` + +### Pattern 6: Snapshot Testing (planned native app) + +```swift +// RULE: Use snapshot tests for visual regression +import SnapshotTesting + +@Suite("Visual Regression Tests") +struct VisualRegressionTests { + @Test("Tile list appearance", arguments: [ + ("iPhone", ViewImageConfig.iPhone13Pro), + ("iPad", ViewImageConfig.iPadPro11), + ("SE", ViewImageConfig.iPhoneSe) + ]) + func deviceSnapshots(device: String, config: ViewImageConfig) { + let view = TileListView(tiles: .mockArray) + + assertSnapshot( + matching: view, + as: .image(layout: .device(config: config)), + named: device + ) + } + + @Test("Dark mode support") + func darkMode() { + let view = ContentView() + .preferredColorScheme(.dark) + + assertSnapshot( + matching: view, + as: .image(traits: .init(userInterfaceStyle: .dark)) + ) + } +} +``` + +## DEPENDENCY MOCKING PATTERNS + +### Pattern 1: Test Dependency Configuration + +```swift +// RULE: Create reusable test configurations +extension DependencyValues { + static func testValue( + configuring: (inout DependencyValues) -> Void = { _ in } + ) -> DependencyValues { + var dependencies = DependencyValues() + + // RULE: Set safe defaults for all dependencies + dependencies.tileClient = .noop + dependencies.date.now = Date(timeIntervalSince1970: 0) + dependencies.uuid = .incrementing + dependencies.mainQueue = .immediate + + // Apply custom configuration + configuring(&dependencies) + + return dependencies + } +} + +// RULE: Use in tests for consistency +@Test("Example usage") +func testWithConfiguration() async { + let viewModel = withDependencies { + $0 = .testValue { deps in + deps.tileClient.fetch = { _ in .mock } + } + } operation: { + TileViewModel() + } + + await viewModel.loadTile() + #expect(viewModel.tile == .mock) +} +``` + +### Pattern 2: Mock Implementations + +```swift +// RULE: Create predictable mock implementations +extension TileClient { + static let noop = TileClient( + fetch: { _ in throw CancellationError() }, + write: { _ in throw CancellationError() }, + delete: { _ in throw CancellationError() } + ) + + static func succeeding( + tile: Tile = .mock, + delay: Duration = .zero + ) -> TileClient { + TileClient( + fetch: { _ in + if delay > .zero { + try await Task.sleep(for: delay) + } + return tile + }, + write: { _ in + if delay > .zero { + try await Task.sleep(for: delay) + } + }, + delete: { _ in + if delay > .zero { + try await Task.sleep(for: delay) + } + } + ) + } + + static func failing( + error: Error = TestError() + ) -> TileClient { + TileClient( + fetch: { _ in throw error }, + write: { _ in throw error }, + delete: { _ in throw error } + ) + } +} +``` + +## TESTING ANTI-PATTERNS + +### DON'T: Test implementation details +```swift +// WRONG: Testing private state +@Test +func badTest() { + let viewModel = TileViewModel() + + // Don't access private properties + let mirror = Mirror(reflecting: viewModel) + // This is brittle and breaks encapsulation +} + +// RIGHT: Test behavior through public API +@Test +func goodTest() { + let viewModel = TileViewModel() + + viewModel.updateTitle("New Title") + #expect(viewModel.displayTitle == "New Title") +} +``` + +### DON'T: Use arbitrary delays +```swift +// WRONG: Race condition waiting +@Test +func badAsyncTest() async { + let viewModel = SearchViewModel() + viewModel.search("query") + + // Arbitrary delay - flaky! + try? await Task.sleep(for: .seconds(1)) + + #expect(!viewModel.results.isEmpty) +} + +// RIGHT: Proper synchronization +@Test +func goodAsyncTest() async { + let viewModel = withDependencies { + $0.tileClient.search = { _ in [.mock] } + } operation: { + SearchViewModel() + } + + await viewModel.search("query") + #expect(viewModel.results == [.mock]) +} +``` + +### DON'T: Share state between tests +```swift +// WRONG: Shared mutable state +class SharedCache { + static let shared = SharedCache() + var data: [String: Any] = [:] +} + +@Test +func badTest1() { + SharedCache.shared.data["key"] = "value" +} + +@Test +func badTest2() { + // This test depends on badTest1! + let value = SharedCache.shared.data["key"] +} + +// RIGHT: Isolated dependencies +@Test +func goodTest() { + let cache = withDependencies { + $0.cacheClient = .previewValue + } operation: { + CacheClient() + } + + // Test is completely isolated +} +``` + +## Test target placement + +### Rule: One test target per source target + +Declare a matching `Target.testTarget` for every `Target.target` in `Package.swift`: + +```swift +let tileKitTarget = Target.target( + name: "PureXML", + dependencies: ["PureXMLModels", "FileClient"] +) +let tileKitTestsTarget = Target.testTarget( + name: "PureXMLTests", + dependencies: ["PureXML"] +) +let tileKitTargets = [tileKitTarget, tileKitTestsTarget] +``` + +- MUST name the test target `Tests` (no other suffix) +- MUST list the source target as a dependency of the test target +- MUST group the source + test target as a pair in a named local sub-array (`let tileKitTargets = [...]`) so platform conditionals and grouping comments stay clean +- MUST NOT skip the test target, even for trivial packages +- MUST NOT define the test target inline in the top-level `targets:` array + +### Folder layout + +PureXML is a monorepo from day one: sources live under `Sources//` and tests under `Tests/Tests/`, mirroring each other: + +``` +. +├── +│ ├── Sources/ +│ │ └── PureXML/ +│ │ └── ...source files... +│ └── Tests/ +│ └── PureXMLTests/ +│ ├── ...test files... +│ └── Mocks/ +│ └── ...mock implementations... +``` + +### Mocks live in the test target, never in the source package + +Public test doubles (mocks, fakes, stubs) are placed in `Tests/Tests/Mocks/`, NOT in `Sources//`. Mocks shipped from `Sources/` leak into production binaries. + +```swift +// Sources/FileClient/FileClientProtocol.swift +public protocol FileClientProtocol { + func read(path: String) async throws -> String +} + +// Tests/FileClientTests/Mocks/MockFileClient.swift +public struct MockFileClient: FileClientProtocol { + public var readResult: Result + public func read(path: String) async throws -> String { + try readResult.get() + } +} +``` + +The protocol MAY be public (so the mock can implement it). The mock MUST live in the test target. + +### Running a single target's tests + +```bash +swift test --filter Tests +``` + +## TEST ORGANIZATION CHECKLIST + +Before submitting tests, verify: + +- [ ] Tests use `@Test` attribute, not XCTest +- [ ] Each test has single, clear purpose +- [ ] Test names describe scenario and outcome +- [ ] Dependencies controlled with `withDependencies` +- [ ] No live network/database calls +- [ ] Async tests use proper concurrency +- [ ] No arbitrary delays or race conditions +- [ ] Test data is deterministic +- [ ] Error cases thoroughly tested +- [ ] Edge cases covered +- [ ] Tests run in isolation +- [ ] Follows test pyramid (70/20/10) +- [ ] Critical paths have E2E tests +- [ ] Visual changes have snapshot tests + +## Testing decision flowchart + +``` +Writing a new test: +├─ Can it be a unit test? +│ ├─ YES → Write focused unit test +│ └─ NO → Why not? +│ ├─ Needs multiple components? +│ │ └─ Write integration test +│ └─ Tests UI (planned native app)? +│ ├─ Visual? → Snapshot test +│ └─ Interaction? → ViewInspector +├─ Is it deterministic? +│ ├─ NO → Fix non-determinism +│ └─ YES → Continue +└─ Does it run fast (<100ms)? + ├─ NO → Can it be optimized? + │ ├─ YES → Optimize + │ └─ NO → Tag as slow + └─ YES → Good to go +``` + +## Troubleshooting: stale SwiftPM build artifacts + +Before spending time debugging an impossible-looking test failure, check if it's a stale build. Swift 6.2 on recent macOS has a known incremental-build bug where adding, moving, or renaming a method on an `actor` (or any type with concurrency surface) can leave downstream `.o` / `.swiftmodule` files with the old method-table layout. Async dispatch then lands in the wrong slot, reads garbage, and trips a Swift stdlib `_precondition` far from the real call site. + +### How to recognise it + +All of these together strongly suggest staleness, not a real bug: + +- Stack trace points at a stdlib `.swiftinterface` line: "Not enough bits to represent the passed value" or a similar stdlib integer-conversion trap +- Reported crash frame is inside a function that is **not** actually in the call path (linker ICF-folding + symbolizer ambiguity) +- Adding or removing a *trivial* method (even an empty one) toggles the crash +- The same test passes in isolation but fails when run alongside others, or vice versa +- Git bisect blames a commit whose diff has no logical relationship to the trap +- Parallelism toggles (`--parallel / --no-parallel`, `SWIFT_TESTING_PARALLELIZATION_ENABLED=false`) have no effect + +### Remedy + +```bash +swift package clean && rm -rf .build && swift test +``` + +Or, if the project has a `Makefile`, run the `test-clean` (or equivalent) target. + +CI is not affected; it always builds from scratch. This only bites local development after method-surface changes on actors. + +### Dead-end diagnostic paths (do NOT repeat) + +- Adding `print` / `FileHandle.standardError.write` to the apparent trap site: the prints never fire because the reported symbol is wrong +- Switching `Int32(x)` to `Int32(clamping: x)`: masks the UInt assertion as a stack canary trap (SIGTRAP to SIGABRT), does not fix it +- Per-commit bisection: will blame the commit that happened to add a method, not the real cause +- Debugging across a test binary rebuild: rebuild resets the staleness, making the bug intermittent + +If you've exhausted these paths, the answer is almost always a clean rebuild. diff --git a/docs/rules/verification.md b/docs/rules/verification.md new file mode 100644 index 0000000..ec80b4a --- /dev/null +++ b/docs/rules/verification.md @@ -0,0 +1,40 @@ +# Verification Before Completion + +Never claim a PureXML change is complete without fresh command output from the +relevant gates. + +## Required Gate + +Run this local gate before committing or reporting completion: + +```sh +bash scripts/check-style.sh +bash scripts/check-namespacing.sh +bash scripts/check-forbidden-patterns.sh +bash scripts/check-changelog-touched.sh +swiftformat . --config .swiftformat --lint +swiftlint --config .swiftlint.yml --strict +swift build +swift test +bash scripts/check-linux.sh +bash scripts/check-wasm.sh +``` + +Hosted CI must also pass the Windows job, which runs `scripts/check-windows.ps1`. + +## Claims and Evidence + +| Claim | Required evidence | +|---|---| +| Style clean | `scripts/check-style.sh` exits 0 | +| Namespace clean | `scripts/check-namespacing.sh` exits 0 | +| Forbidden-pattern clean | `scripts/check-forbidden-patterns.sh` exits 0 | +| Format clean | `swiftformat . --config .swiftformat --lint` exits 0 | +| Lint clean | `swiftlint --config .swiftlint.yml --strict` exits 0 | +| Builds on macOS | `swift build` exits 0 | +| Tests pass | `swift test` exits 0 and reports zero failures | +| Linux compatible | `bash scripts/check-linux.sh` exits 0 | +| Windows compatible | hosted `scripts/check-windows.ps1` exits 0 | +| WASI compatible | `bash scripts/check-wasm.sh` exits 0 | + +If a tool is unavailable, say so explicitly and do not claim that gate passed. diff --git a/scripts/check-all.sh b/scripts/check-all.sh new file mode 100755 index 0000000..4fb4fdd --- /dev/null +++ b/scripts/check-all.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Full local verification gate for PureXML. +# +# Runs the checks required before completion. The Linux and WASM gates are +# available as separate scripts (scripts/check-linux.sh, scripts/check-wasm.sh) +# and run in hosted CI; they are not chained here so this gate stays runnable on +# any machine without remote infrastructure or a Wasm SDK. + +set -euo pipefail + +run_gate() { + name="$1" + shift + printf '\n==> %s\n' "$name" + "$@" +} + +run_gate "style" bash scripts/check-style.sh +run_gate "namespacing" bash scripts/check-namespacing.sh +run_gate "forbidden patterns" bash scripts/check-forbidden-patterns.sh +run_gate "swiftformat" swiftformat . --config .swiftformat --lint +run_gate "swiftlint" swiftlint --config .swiftlint.yml --strict +run_gate "swift build" swift build +run_gate "swift test" swift test diff --git a/scripts/check-forbidden-patterns.sh b/scripts/check-forbidden-patterns.sh new file mode 100755 index 0000000..9e2de1e --- /dev/null +++ b/scripts/check-forbidden-patterns.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Repo contract gate for PureXML "never" rules. +# +# These checks cover patterns that Swift unit tests cannot see reliably: +# package dependencies, source imports, C/generated parser files, XCTest-style +# tests, platform branches, and singleton/service-locator wiring. + +set -u + +FAIL=0 + +fail() { + echo "forbidden-patterns: $1" >&2 + FAIL=1 +} + +report_matches() { + message="$1" + matches="$2" + if [ -n "$matches" ]; then + fail "$message" + printf '%s\n' "$matches" | sed 's/^/ /' >&2 + fi +} + +if ! grep -qE '^[[:space:]]*dependencies:[[:space:]]*\[[[:space:]]*\],[[:space:]]*$' Package.swift; then + fail "Package.swift must keep dependencies: []" +fi + +package_forbidden=$( + grep -nE '\.package[[:space:]]*\(|\.binaryTarget[[:space:]]*\(|\.systemLibrary[[:space:]]*\(' Package.swift 2>/dev/null || true +) +report_matches "Package.swift must not declare external, binary, or system-library targets" "$package_forbidden" + +source_imports=$( + grep -RInE '^[[:space:]]*import[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' Sources --include '*.swift' 2>/dev/null || true +) +report_matches "Sources must stay standard-library-only and import no modules" "$source_imports" + +source_platform_branches=$( + grep -RInE '^[[:space:]]*#if[[:space:]]+(os|canImport)[[:space:]]*\(' Sources --include '*.swift' 2>/dev/null || true +) +report_matches "Sources must not add platform-specific branches without an explicit rule change" "$source_platform_branches" + +source_runtimes=$( + find Sources -type f \( \ + -name '*.c' -o \ + -name '*.cc' -o \ + -name '*.cpp' -o \ + -name '*.h' -o \ + -name '*.hpp' -o \ + -name '*.m' -o \ + -name '*.mm' -o \ + -name '*.js' -o \ + -name '*.ts' -o \ + -name '*.wasm' -o \ + -name '*.y' -o \ + -name '*.l' -o \ + -name '*.g4' \ + \) -print 2>/dev/null +) +report_matches "Sources must not contain C, JavaScript, WebAssembly blobs, or generated parser runtime files" "$source_runtimes" + +source_singletons=$( + grep -RInE 'static[[:space:]]+(let|var)[[:space:]]+shared\b|^[[:space:]]*(public[[:space:]]+)?(final[[:space:]]+)?(class|struct|enum)[[:space:]]+ServiceLocator\b|[A-Za-z0-9_]*(Resolver|Container|Environment)[[:space:]]*\.[[:space:]]*shared\b' Sources --include '*.swift' 2>/dev/null || true +) +report_matches "Sources must not introduce singleton or service-locator access patterns" "$source_singletons" + +source_traps=$( + grep -RInE '\bfatalError[[:space:]]*\(|\bpreconditionFailure[[:space:]]*\(|\bassertionFailure[[:space:]]*\(|\btry!|\bas!|[A-Za-z0-9_)\]}][[:space:]]*![[:space:]]*([).,\];:]|$)' Sources --include '*.swift' 2>/dev/null || true +) +report_matches "Sources must not use traps, forced casts, try!, or force unwraps" "$source_traps" + +test_imports=$( + grep -RInE '^[[:space:]]*(@testable[[:space:]]+)?import[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' Tests --include '*.swift' 2>/dev/null \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*@testable[[:space:]]+import[[:space:]]+PureXML[[:space:]]*$' \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*import[[:space:]]+Foundation[[:space:]]*$' \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*import[[:space:]]+Testing[[:space:]]*$' || true +) +report_matches "Tests may import only Foundation, Testing, and @testable PureXML" "$test_imports" + +test_xctest=$( + grep -RInE '\bXCTest(Case|Expectation)?\b|\bXCTAssert[A-Za-z]*\b|^[[:space:]]*import[[:space:]]+XCTest[[:space:]]*$' Tests --include '*.swift' 2>/dev/null || true +) +report_matches "Tests must stay on Swift Testing and avoid XCTest-style APIs" "$test_xctest" + +if [ "$FAIL" -eq 0 ]; then + echo "forbidden-patterns: OK" +fi + +exit "$FAIL" diff --git a/scripts/check-linux.sh b/scripts/check-linux.sh new file mode 100755 index 0000000..7dca6a2 --- /dev/null +++ b/scripts/check-linux.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_linux_gate() { + if [ -n "${SWIFTLY_HOME_DIR:-}" ] && [ -f "$SWIFTLY_HOME_DIR/env.sh" ]; then + # shellcheck source=/dev/null + . "$SWIFTLY_HOME_DIR/env.sh" + fi + swift --version + swift build + swift test + swift build -c release + swift test -c release +} + +if [ "$(uname -s)" = "Linux" ]; then + run_linux_gate + exit 0 +fi + +HOST="${PUREYAML_LINUX_HOST:-claw}" +LIMA_INSTANCE="${PUREYAML_LIMA_INSTANCE:-debian}" +REMOTE_PARENT="${PUREYAML_LINUX_REMOTE_PARENT:-/Volumes/ClawSSD/tmp}" +REMOTE_DIR="$REMOTE_PARENT/purexml-linux-check" +REMOTE_SWIFTLY_HOME="${PUREYAML_LINUX_SWIFTLY_HOME:-/Volumes/ClawSSD/swiftly-linux/home}" +REMOTE_SWIFTLY_BIN="${PUREYAML_LINUX_SWIFTLY_BIN:-/Volumes/ClawSSD/swiftly-linux/bin}" + +if ! command -v ssh >/dev/null 2>&1; then + echo "linux: ssh is required outside Linux" >&2 + exit 2 +fi + +if ! command -v rsync >/dev/null 2>&1; then + echo "linux: rsync is required outside Linux" >&2 + exit 2 +fi + +ssh "$HOST" "command -v limactl >/dev/null && mkdir -p '$REMOTE_DIR'" +rsync -a --delete \ + --exclude '.build' \ + --exclude '.git' \ + ./ "$HOST:$REMOTE_DIR/" + +ssh "$HOST" "limactl start '$LIMA_INSTANCE' >/tmp/purexml-lima-start.log 2>&1 || true; limactl shell '$LIMA_INSTANCE' -- bash -lc 'export SWIFTLY_HOME_DIR=\"$REMOTE_SWIFTLY_HOME\" SWIFTLY_BIN_DIR=\"$REMOTE_SWIFTLY_BIN\"; [ -f \"\$SWIFTLY_HOME_DIR/env.sh\" ] && . \"\$SWIFTLY_HOME_DIR/env.sh\"; cd \"$REMOTE_DIR\" && swift --version && swift build && swift test && swift build -c release && swift test -c release'" diff --git a/scripts/check-namespacing.sh b/scripts/check-namespacing.sh new file mode 100755 index 0000000..e95b083 --- /dev/null +++ b/scripts/check-namespacing.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Namespacing gate (docs/rules/code-style.md): one non-private top-level type per +# file. A file with more than one file-scope type declaration is a violation: +# split it, or mark helper types private/fileprivate. +# +# Inert until Sources exists. Portable: bash 3.2 and 4+. + +set -u + +SRC="Sources" +if [ ! -d "$SRC" ]; then + echo "namespacing: no $SRC yet, skipping." + exit 0 +fi + +FAIL=0 +while IFS= read -r f; do + count=$(grep -cE "^(public |package |internal )?(actor|struct|enum|protocol|class|final class) [A-Z]" "$f") + if [ "$count" -gt 1 ]; then + echo "namespacing: $count file-scope types in $f (one per file)" >&2 + FAIL=1 + fi + + public_types=$(grep -nE "^public (actor|struct|enum|protocol|class|final class) [A-Z]" "$f" || true) + if [ -n "$public_types" ]; then + if [ "$f" = "Sources/PureXML.swift" ] && + [ "$public_types" = "2:public enum PureXML {" ]; then + : + else + echo "namespacing: public file-scope type in $f (wrap it under PureXML)" >&2 + printf '%s\n' "$public_types" | sed 's/^/ /' >&2 + FAIL=1 + fi + fi +done < <(find "$SRC" -name "*.swift") + +if [ "$FAIL" -ne 0 ]; then + echo "namespacing: gate failed. Rule: docs/rules/code-style.md" >&2 +fi +exit "$FAIL" diff --git a/scripts/check-style.sh b/scripts/check-style.sh new file mode 100755 index 0000000..4c245f5 --- /dev/null +++ b/scripts/check-style.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Repo-wide style gate: no em dashes, no tool-attribution phrases in any tracked +# file. Mirrors the .githooks checks, run over the whole tree in CI so a bypassed +# local hook is still caught at merge time. +# +# Portable: BSD-compatible grep, bash 3.2 (macOS) and bash 4+ (CI). + +set -u + +FAIL=0 + +# Em dash U+2014, built via printf so this script contains no em-dash byte. +EMDASH=$(printf '\xe2\x80\x94') + +# Enforcement files legitimately contain the forbidden phrases in order to +# detect them, so exclude them from the phrase scan. +is_enforcement_file() { + case "$1" in + .githooks/*|scripts/check-style.sh) return 0 ;; + *) return 1 ;; + esac +} + +# Tool-attribution phrases. Assembled from fragments so this script does not +# contain the full literal phrases (which would self-flag in other scanners). +TOOL="Cla""ude" +PHRASES=( + "Co-Authored-By: $TOOL" + "Co-authored-by: $TOOL" + "Generated with $TOOL" + "Generated by $TOOL" + "Powered by $TOOL" + "as an AI" +) + +while IFS= read -r f; do + [ -f "$f" ] || continue + if LC_ALL=C grep -qF -- "$EMDASH" "$f" 2>/dev/null; then + echo "style: em dash (U+2014) in $f" >&2 + FAIL=1 + fi + if ! is_enforcement_file "$f"; then + for p in "${PHRASES[@]}"; do + if LC_ALL=C grep -qF -- "$p" "$f" 2>/dev/null; then + echo "style: forbidden attribution phrase in $f" >&2 + FAIL=1 + fi + done + fi +done < <(git ls-files) + +if [ "$FAIL" -ne 0 ]; then + echo "style: gate failed. Rules: docs/rules/git-discipline.md" >&2 +fi + +exit "$FAIL" diff --git a/scripts/check-wasm.sh b/scripts/check-wasm.sh new file mode 100755 index 0000000..e09b5de --- /dev/null +++ b/scripts/check-wasm.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SDK_ID="${SWIFT_WASM_SDK_ID:-swift-6.3.2-RELEASE_wasm}" +SWIFT_SELECTOR="${SWIFT_WASM_SWIFT_SELECTOR:-+6.3.2}" + +swift_command() { + if command -v swiftly >/dev/null 2>&1; then + swiftly run swift "$@" "$SWIFT_SELECTOR" + else + swift "$@" + fi +} + +if ! swift_command sdk list | grep -qx "$SDK_ID"; then + echo "wasm: missing Swift SDK '$SDK_ID'" >&2 + echo "wasm: install the SDK or set SWIFT_WASM_SDK_ID" >&2 + exit 2 +fi + +swift_command build --swift-sdk "$SDK_ID" +swift_command build -c release --swift-sdk "$SDK_ID" diff --git a/scripts/check-windows.ps1 b/scripts/check-windows.ps1 new file mode 100644 index 0000000..a565518 --- /dev/null +++ b/scripts/check-windows.ps1 @@ -0,0 +1,8 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +swift --version +swift build +swift test +swift build -c release +swift test -c release